diff --git a/app/assets/javascripts/ide/components/web_ide_error.vue b/app/assets/javascripts/ide/components/web_ide_error.vue new file mode 100644 index 0000000000000000000000000000000000000000..9bb806ce3658640832f44a3df49f01491f6b897f --- /dev/null +++ b/app/assets/javascripts/ide/components/web_ide_error.vue @@ -0,0 +1,52 @@ + + diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index 74fc00432d18d1e69b50923d658171546f7db800..11929fb4ef562c4cc2b9e44a43f08cd3da4e5775 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -13,6 +13,7 @@ import { handleTracking, } from './lib/gitlab_web_ide'; import { GITLAB_WEB_IDE_FEEDBACK_ISSUE } from './constants'; +import { renderWebIdeError } from './render_web_ide_error'; const buildRemoteIdeURL = (ideRemotePath, remoteHost, remotePathArg) => { const remotePath = cleanLeadingSeparator(remotePathArg); @@ -55,6 +56,7 @@ export const initGitlabWebIDE = async (el) => { editorFont: editorFontJSON, codeSuggestionsEnabled, extensionsGallerySettings: extensionsGallerySettingsJSON, + signOutPath, } = el.dataset; const rootEl = setupRootElement(el); @@ -75,54 +77,58 @@ export const initGitlabWebIDE = async (el) => { 'X-Requested-With': 'XMLHttpRequest', }; - // See ClientOnlyConfig https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/main/packages/web-ide-types/src/config.ts#L17 - start(rootEl, { - ...getBaseConfig(), - nonce, - httpHeaders, - auth: oauthConfig, - projectPath, - ref, - filePath, - mrId, - mrTargetProject: getMRTargetProject(), - forkInfo, - username: gon.current_username, - links: { - feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE, - userPreferences: el.dataset.userPreferencesPath, - signIn: el.dataset.signInPath, - }, - featureFlags: { - settingsSync: true, - crossOriginExtensionHost: getCrossOriginExtensionHostFlagValue(extensionsGallerySettings), - }, - editorFont, - extensionsGallerySettings, - codeSuggestionsEnabled, - handleTracking, - // See https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/main/packages/web-ide-types/src/config.ts#L86 - telemetryEnabled: Tracking.enabled(), - async handleStartRemote({ remoteHost, remotePath, connectionToken }) { - const confirmed = await confirmAction( - __('Are you sure you want to leave the Web IDE? All unsaved changes will be lost.'), - { - primaryBtnText: __('Start remote connection'), - cancelBtnText: __('Continue editing'), - }, - ); + try { + // See ClientOnlyConfig https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/main/packages/web-ide-types/src/config.ts#L17 + await start(rootEl, { + ...getBaseConfig(), + nonce, + httpHeaders, + auth: oauthConfig, + projectPath, + ref, + filePath, + mrId, + mrTargetProject: getMRTargetProject(), + forkInfo, + username: gon.current_username, + links: { + feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE, + userPreferences: el.dataset.userPreferencesPath, + signIn: el.dataset.signInPath, + }, + featureFlags: { + settingsSync: true, + crossOriginExtensionHost: getCrossOriginExtensionHostFlagValue(extensionsGallerySettings), + }, + editorFont, + extensionsGallerySettings, + codeSuggestionsEnabled, + handleTracking, + // See https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/main/packages/web-ide-types/src/config.ts#L86 + telemetryEnabled: Tracking.enabled(), + async handleStartRemote({ remoteHost, remotePath, connectionToken }) { + const confirmed = await confirmAction( + __('Are you sure you want to leave the Web IDE? All unsaved changes will be lost.'), + { + primaryBtnText: __('Start remote connection'), + cancelBtnText: __('Continue editing'), + }, + ); - if (!confirmed) { - return; - } + if (!confirmed) { + return; + } - createAndSubmitForm({ - url: buildRemoteIdeURL(ideRemotePath, remoteHost, remotePath), - data: { - connection_token: connectionToken, - return_url: window.location.href, - }, - }); - }, - }); + createAndSubmitForm({ + url: buildRemoteIdeURL(ideRemotePath, remoteHost, remotePath), + data: { + connection_token: connectionToken, + return_url: window.location.href, + }, + }); + }, + }); + } catch (error) { + renderWebIdeError({ error, signOutPath }); + } }; diff --git a/app/assets/javascripts/ide/render_web_ide_error.js b/app/assets/javascripts/ide/render_web_ide_error.js new file mode 100644 index 0000000000000000000000000000000000000000..d33d85407f4b9f8dec8fb0fb3088cb7dfbd9b0e8 --- /dev/null +++ b/app/assets/javascripts/ide/render_web_ide_error.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import { logError } from '~/lib/logger'; +import WebIdeError from '~/ide/components/web_ide_error.vue'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; + +export function renderWebIdeError({ error, signOutPath }) { + // eslint-disable-next-line @gitlab/require-i18n-strings + logError('Failed to load Web IDE', error); + Sentry.captureException(error); + + const alertContainer = document.querySelector('.flash-container'); + if (!alertContainer) return null; + + const el = document.createElement('div'); + alertContainer.appendChild(el); + + return new Vue({ + el, + render(createElement) { + return createElement(WebIdeError, { props: { signOutPath } }); + }, + }); +} diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index c7397a5b3ea488df04d86a99837a41f83343e315..c770ba696d9c6504dee5e575963bdf35df5b8ff7 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -7,6 +7,7 @@ def ide_data(project:, fork_info:, params:) 'use-new-web-ide' => use_new_web_ide?.to_s, 'new-web-ide-help-page-path' => help_page_path('user/project/web_ide/index', anchor: 'vscode-reimplementation'), 'sign-in-path' => new_session_path(current_user), + 'sign-out-path' => destroy_user_session_path, 'user-preferences-path' => profile_preferences_path }.merge(use_new_web_ide? ? new_ide_data(project: project) : legacy_ide_data(project: project)) diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml index 7a14c270ae6698eff5457a211da3d36e02708888..310bbab1aec634df7e9f2cbfa449bb5714290c4f 100644 --- a/app/views/ide/_show.html.haml +++ b/app/views/ide/_show.html.haml @@ -18,4 +18,4 @@ - data = ide_data(project: @project, fork_info: @fork_info, params: params) -= render partial: 'shared/ide_root', locals: { data: data, loading_text: _('Loading the GitLab IDE') } += render partial: 'shared/ide_root', locals: { data: data, loading_text: _('Loading the Web IDE') } diff --git a/doc/user/project/web_ide/index.md b/doc/user/project/web_ide/index.md index 489b8faea40066aa285a816b5d4377a2fdf5eabb..b0abe2bf12a4de6254c040b78975163a4fc2812c 100644 --- a/doc/user/project/web_ide/index.md +++ b/doc/user/project/web_ide/index.md @@ -280,3 +280,15 @@ As a workaround: or modify the `"editor.fontFamily"` setting. For more information, see [VS Code issue 80170](https://github.com/microsoft/vscode/issues/80170). + +### Report a problem + +To report a problem, [create a new issue](https://gitlab.com/gitlab-org/gitlab-web-ide/-/issues/new) +with the following information: + +- The error message +- The full error details +- How often the problem occurs +- Steps to reproduce the problem + +If you're on a paid tier, you can also [contact Support](https://about.gitlab.com/support/#contact-support) for help. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a81ce9a2d59c00fd1b45b61923cc08dd8c7f47f2..292e15df33351ff20bd649f254ee12a0763c0b9a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -21841,6 +21841,9 @@ msgstr "" msgid "Failed to load stacktrace." msgstr "" +msgid "Failed to load the Web IDE" +msgstr "" + msgid "Failed to make repository read-only: %{reason}" msgstr "" @@ -22550,6 +22553,9 @@ msgstr "" msgid "For more information, see the File Hooks documentation." msgstr "" +msgid "For more information, see the developer console. Try to reload the page or sign out and in again. If the issue persists, %{reportIssueStart}report a problem%{reportIssueEnd}." +msgstr "" + msgid "For the GitLab Team to keep your subscription data up to date, this is a reminder to report your license usage on a monthly basis, or at the cadence set in your agreement with GitLab. This allows us to simplify the billing process for overages and renewals. To report your usage data, export your license usage file and email it to %{renewal_service_email}. If you need an updated license, GitLab will send the license to the email address registered in the %{customers_dot}, and you can upload this license to your instance." msgstr "" @@ -31063,7 +31069,7 @@ msgstr "" msgid "Loading snippet" msgstr "" -msgid "Loading the GitLab IDE" +msgid "Loading the Web IDE" msgstr "" msgid "Loading..." @@ -43420,6 +43426,9 @@ msgstr "" msgid "Release|You can edit the content later by editing the release. %{linkStart}How do I edit a release?%{linkEnd}" msgstr "" +msgid "Reload" +msgstr "" + msgid "Reload page" msgstr "" diff --git a/spec/frontend/ide/components/web_ide_error_spec.js b/spec/frontend/ide/components/web_ide_error_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..85fb504033609b61d82b6e5f769aae5d755ade49 --- /dev/null +++ b/spec/frontend/ide/components/web_ide_error_spec.js @@ -0,0 +1,51 @@ +import { GlAlert, GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import WebIdeError from '~/ide/components/web_ide_error.vue'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; + +const findButtons = (wrapper) => wrapper.findAllComponents(GlButton); + +describe('WebIdeError', () => { + const MOCK_SIGN_OUT_PATH = '/users/sign_out'; + + let wrapper; + + useMockLocationHelper(); + function createWrapper() { + wrapper = mount(WebIdeError, { + propsData: { + signOutPath: MOCK_SIGN_OUT_PATH, + }, + }); + } + + it('renders alert component', () => { + createWrapper(); + const alert = wrapper.findComponent(GlAlert); + + expect(alert.text()).toMatchInterpolatedText( + 'Failed to load the Web IDE For more information, see the developer console. Try to reload the page or sign out and in again. If the issue persists, report a problem. Reload Sign out', + ); + }); + + it('renders reload page button', () => { + createWrapper(); + const reloadButton = findButtons(wrapper).at(0); + + expect(reloadButton.text()).toEqual('Reload'); + + reloadButton.vm.$emit('click'); + expect(window.location.reload).toHaveBeenCalled(); + }); + + it('renders sign out button', () => { + createWrapper(); + const signOutButton = findButtons(wrapper).at(1); + + expect(signOutButton.text()).toEqual('Sign out'); + expect(signOutButton.attributes()).toMatchObject({ + 'data-method': 'post', + href: MOCK_SIGN_OUT_PATH, + }); + }); +}); diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js index 453bad21e4a28c7f3fa0227166c2689bd6ba74e2..5f7e4caed1992b40efc23404c036736848eb5741 100644 --- a/spec/frontend/ide/init_gitlab_web_ide_spec.js +++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js @@ -8,6 +8,7 @@ import Tracking from '~/tracking'; import { TEST_HOST } from 'helpers/test_constants'; import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import { renderWebIdeError } from '~/ide/render_web_ide_error'; import { getMockCallbackUrl } from './helpers'; jest.mock('@gitlab/web-ide'); @@ -18,6 +19,7 @@ jest.mock('~/lib/utils/csrf', () => ({ headerKey: 'mock-csrf-header', })); jest.mock('~/tracking'); +jest.mock('~/ide/render_web_ide_error'); const ROOT_ELEMENT_ID = 'ide'; const TEST_NONCE = 'test123nonce'; @@ -30,6 +32,7 @@ const TEST_FILE_PATH = 'foo/README.md'; const TEST_MR_ID = '7'; const TEST_MR_TARGET_PROJECT = 'gitlab-org/the-real-gitlab'; const TEST_SIGN_IN_PATH = 'sign-in'; +const TEST_SIGN_OUT_PATH = 'sign-out'; const TEST_FORK_INFO = { fork_path: '/forky' }; const TEST_IDE_REMOTE_PATH = '/-/ide/remote/:remote_host/:remote_path'; const TEST_START_REMOTE_PARAMS = { @@ -82,6 +85,7 @@ describe('ide/init_gitlab_web_ide', () => { ], }); el.dataset.signInPath = TEST_SIGN_IN_PATH; + el.dataset.signOutPath = TEST_SIGN_OUT_PATH; document.body.append(el); }; @@ -272,6 +276,27 @@ describe('ide/init_gitlab_web_ide', () => { }); }); + describe('on start error', () => { + const mockError = new Error('error'); + + beforeEach(() => { + jest.mocked(start).mockImplementationOnce(() => { + throw mockError; + }); + + createSubject(); + }); + + it('shows alert', () => { + expect(start).toHaveBeenCalledTimes(1); + expect(renderWebIdeError).toHaveBeenCalledTimes(1); + expect(renderWebIdeError).toHaveBeenCalledWith({ + error: mockError, + signOutPath: TEST_SIGN_OUT_PATH, + }); + }); + }); + describe('when extensionsGallerySettings is in dataset', () => { function setMockExtensionGallerySettingsDataset( mockSettings = TEST_EXTENSIONS_GALLERY_SETTINGS, diff --git a/spec/frontend/ide/render_web_ide_error_spec.js b/spec/frontend/ide/render_web_ide_error_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..46d2451b36af87389c79f3643c23c0ccc42d321f --- /dev/null +++ b/spec/frontend/ide/render_web_ide_error_spec.js @@ -0,0 +1,55 @@ +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { renderWebIdeError } from '~/ide/render_web_ide_error'; +import { logError } from '~/lib/logger'; +import { resetHTMLFixture } from 'helpers/fixtures'; + +jest.mock('~/sentry/sentry_browser_wrapper'); +jest.mock('~/lib/logger'); + +describe('render web IDE error', () => { + const MOCK_ERROR = new Error('error'); + const MOCK_SIGNOUT_PATH = '/signout'; + + const setupFlashContainer = () => { + const flashContainer = document.createElement('div'); + flashContainer.classList.add('flash-container'); + + document.body.appendChild(flashContainer); + }; + + const findAlert = () => document.querySelector('.flash-container .gl-alert'); + + afterEach(() => { + resetHTMLFixture(); + }); + + describe('with flash-container', () => { + beforeEach(() => { + setupFlashContainer(); + + renderWebIdeError({ error: MOCK_ERROR, signOutPath: MOCK_SIGNOUT_PATH }); + }); + + it('logs error to Sentry', () => { + expect(Sentry.captureException).toHaveBeenCalledWith(MOCK_ERROR); + }); + + it('logs error to console', () => { + expect(logError).toHaveBeenCalledWith('Failed to load Web IDE', MOCK_ERROR); + }); + + it('should render alert', () => { + expect(findAlert()).toBeInstanceOf(HTMLElement); + }); + }); + + describe('no .flash-container', () => { + beforeEach(() => { + renderWebIdeError({ error: MOCK_ERROR, signOutPath: MOCK_SIGNOUT_PATH }); + }); + + it('does not render alert', () => { + expect(findAlert()).toBeNull(); + }); + }); +});