diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index 4581b508fe8b6f462e86a39916dc940a20dabc39..beb79d027c4cee8f42cf4c08f07344618846f36e 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -46,6 +46,7 @@ export const initGitlabWebIDE = async (el) => { forkInfo: forkInfoJSON, editorFont: editorFontJSON, codeSuggestionsEnabled, + extensionsGallerySettings: extensionsGallerySettingsJSON, } = el.dataset; const rootEl = setupRootElement(el); @@ -53,6 +54,9 @@ export const initGitlabWebIDE = async (el) => { ? convertObjectPropsToCamelCase(JSON.parse(editorFontJSON), { deep: true }) : null; const forkInfo = forkInfoJSON ? JSON.parse(forkInfoJSON) : null; + const extensionsGallerySettings = extensionsGallerySettingsJSON + ? convertObjectPropsToCamelCase(JSON.parse(extensionsGallerySettingsJSON), { deep: true }) + : undefined; const oauthConfig = getOAuthConfig(el.dataset); const httpHeaders = oauthConfig @@ -85,6 +89,7 @@ export const initGitlabWebIDE = async (el) => { settingsSync: true, }, editorFont, + extensionsGallerySettings, codeSuggestionsEnabled, handleTracking, // See https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/main/packages/web-ide-types/src/config.ts#L86 diff --git a/app/assets/javascripts/profile/preferences/components/extensions_marketplace_warning.vue b/app/assets/javascripts/profile/preferences/components/extensions_marketplace_warning.vue new file mode 100644 index 0000000000000000000000000000000000000000..1dd7bc8352da549836eed1aa063990acefc580a6 --- /dev/null +++ b/app/assets/javascripts/profile/preferences/components/extensions_marketplace_warning.vue @@ -0,0 +1,130 @@ +<script> +import { GlIcon, GlLink, GlModal, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export const WARNING_PARAGRAPH_1 = s__( + 'PreferencesIntegrations|Third-party extensions are now available in the Web IDE. While each extension runs in a secure browser sandbox, %{boldStart}third-party extensions%{boldEnd} may have access to the contents of the files opened in the Web IDE, %{boldStart}including any personal data in those files%{boldEnd}, and may communicate with external servers.', +); + +export const WARNING_PARAGRAPH_2 = s__( + 'PreferencesIntegrations|GitLab does not assume any responsibility for the functionality of these third-party extensions. Each %{boldStart}third-party%{boldEnd} extension has %{boldStart}their own independent%{boldEnd} terms and conditions listed in the marketplace. By installing an extension, you are agreeing to the terms & conditions and Privacy Policy that govern each individual extension as listed in the marketplace.', +); + +export const WARNING_PARAGRAPH_3 = s__( + 'PreferencesIntegrations|By using the Extension Marketplace, you will send data, such as IP address and other device information, to %{url} in accordance with their independent terms and privacy policy.', +); + +export default { + components: { + GlIcon, + GlLink, + GlModal, + GlSprintf, + }, + inject: { + extensionsMarketplaceUrl: { + default: '', + }, + }, + props: { + value: { + type: Boolean, + required: true, + }, + helpUrl: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + // If we have already enabled, let's consider warning not necessary + needsWarning: !this.value, + showWarning: false, + }; + }, + computed: { + actionSecondary() { + if (!this.helpUrl) { + return undefined; + } + + return { + text: s__('PreferencesIntegrations|Learn more'), + attributes: { + href: this.helpUrl, + variant: 'default', + }, + }; + }, + }, + watch: { + value(val) { + // Have we tried to accept but we need to show the warning? + if (val && this.needsWarning) { + this.showWarning = true; + } + }, + async showWarning(val) { + // Wait a bit so that `needsWarning` is properly updated if accepting. + await this.$nextTick(); + + // If we are showing the warning, the value should be false. Don't treat the user as accepting yet. + if (val) { + this.$emit('input', false); + } else if (!this.needsWarning) { + this.$emit('input', true); + } + }, + }, + methods: { + onPrimary() { + this.needsWarning = false; + }, + }, + actionPrimary: { + text: s__('PreferencesIntegrations|I understand'), + }, + TITLE: s__('PreferencesIntegrations|Third-Party Extensions Acknowledgement'), + WARNING_PARAGRAPH_1, + WARNING_PARAGRAPH_2, + WARNING_PARAGRAPH_3, +}; +</script> + +<template> + <gl-modal + v-model="showWarning" + modal-id="extensions-marketplace-warning-modal" + :title="$options.TITLE" + :action-primary="$options.actionPrimary" + :action-secondary="actionSecondary" + @primary="onPrimary" + > + <p> + <gl-sprintf :message="$options.WARNING_PARAGRAPH_1"> + <template #bold="{ content }"> + <span class="gl-font-weight-bold">{{ content }}</span> + </template> + </gl-sprintf> + </p> + <p> + <gl-sprintf :message="$options.WARNING_PARAGRAPH_2"> + <template #bold="{ content }"> + <span class="gl-font-weight-bold">{{ content }}</span> + </template> + </gl-sprintf> + </p> + <p> + <gl-sprintf v-if="extensionsMarketplaceUrl" :message="$options.WARNING_PARAGRAPH_3"> + <template #url> + <gl-link :href="extensionsMarketplaceUrl" target="_blank" + >{{ extensionsMarketplaceUrl }} + <gl-icon name="external-link" class="gl-align-middle" :size="12" /> + </gl-link> + </template> + </gl-sprintf> + </p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/profile/preferences/components/integration_view.vue b/app/assets/javascripts/profile/preferences/components/integration_view.vue index 9924f248b891ab892e51d009e28c544bdc3622d3..06f8459e637cc36d315ef160cc4e6604f956c278 100644 --- a/app/assets/javascripts/profile/preferences/components/integration_view.vue +++ b/app/assets/javascripts/profile/preferences/components/integration_view.vue @@ -2,6 +2,8 @@ import { GlIcon, GlLink, GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue'; +const toCheckboxValue = (bool) => (bool ? '1' : false); + export default { name: 'IntegrationView', components: { @@ -11,7 +13,6 @@ export default { GlFormCheckbox, IntegrationHelpText, }, - inject: ['userFields'], props: { helpLink: { type: String, @@ -29,10 +30,14 @@ export default { type: Object, required: true, }, + value: { + type: Boolean, + required: true, + }, }, data() { return { - isEnabled: this.userFields[this.config.formName] ? '1' : '0', + checkboxValue: toCheckboxValue(this.value), }; }, computed: { @@ -43,6 +48,17 @@ export default { return `user_${this.config.formName}`; }, }, + watch: { + value(val) { + this.checkboxValue = toCheckboxValue(val); + }, + checkboxValue(val) { + // note: When checked we get '1' since we set `value` prop. Unchecked is `false` as expected. + // This value="1" needs to be set to properly handle the Rails form. + // https://bootstrap-vue.org/docs/components/form-checkbox#comp-ref-b-form-checkbox-props + this.$emit('input', Boolean(val)); + }, + }, }; </script> @@ -61,7 +77,7 @@ export default { value="0" data-testid="profile-preferences-integration-hidden-field" /> - <gl-form-checkbox :id="formId" :checked="isEnabled" :name="formName" value="1" + <gl-form-checkbox :id="formId" v-model="checkboxValue" :name="formName" value="1" >{{ config.label }} <template #help> <integration-help-text :message="message" :message-url="messageUrl" /> diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue index 87296200822829222291be623b7656dfed65eec1..f42ca4cf569d98eb546733a5c625ef7ce4de4f54 100644 --- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue +++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue @@ -1,8 +1,9 @@ <script> import { GlButton } from '@gitlab/ui'; import { createAlert, VARIANT_DANGER } from '~/alert'; -import { INTEGRATION_VIEW_CONFIGS, i18n } from '../constants'; +import { INTEGRATION_VIEW_CONFIGS, i18n, INTEGRATION_EXTENSIONS_MARKETPLACE } from '../constants'; import IntegrationView from './integration_view.vue'; +import ExtensionsMarketplaceWarning from './extensions_marketplace_warning.vue'; function updateClasses(bodyClasses = '', applicationTheme, layout) { // Remove documentElement class for any previous theme, re-add current one @@ -24,6 +25,7 @@ export default { components: { IntegrationView, GlButton, + ExtensionsMarketplaceWarning, }, inject: { integrationViews: { @@ -44,13 +46,28 @@ export default { }, integrationViewConfigs: INTEGRATION_VIEW_CONFIGS, i18n, + INTEGRATION_EXTENSIONS_MARKETPLACE, data() { + const integrationValues = this.integrationViews.reduce((acc, { name }) => { + const { formName } = INTEGRATION_VIEW_CONFIGS[name]; + + acc[name] = Boolean(this.userFields[formName]); + + return acc; + }, {}); + return { isSubmitEnabled: true, darkModeOnCreate: null, schemeOnCreate: null, + integrationValues, }; }, + computed: { + extensionsMarketplaceView() { + return this.integrationViews.find(({ name }) => name === INTEGRATION_EXTENSIONS_MARKETPLACE); + }, + }, created() { this.formEl.addEventListener('ajax:beforeSend', this.handleLoading); this.formEl.addEventListener('ajax:success', this.handleSuccess); @@ -117,7 +134,11 @@ export default { > <div class="settings-sticky-header"> <div class="settings-sticky-header-inner"> - <h4 class="gl-my-0" data-testid="profile-preferences-integrations-heading"> + <h4 + id="integrations" + class="gl-my-0" + data-testid="profile-preferences-integrations-heading" + > {{ $options.i18n.integrations }} </h4> </div> @@ -129,6 +150,7 @@ export default { <integration-view v-for="view in integrationViews" :key="view.name" + v-model="integrationValues[view.name]" :help-link="view.help_link" :message="view.message" :message-url="view.message_url" @@ -148,5 +170,10 @@ export default { {{ $options.i18n.saveChanges }} </gl-button> </div> + <extensions-marketplace-warning + v-if="extensionsMarketplaceView" + v-model="integrationValues[$options.INTEGRATION_EXTENSIONS_MARKETPLACE]" + :help-url="extensionsMarketplaceView.help_link" + /> </div> </template> diff --git a/app/assets/javascripts/profile/preferences/constants.js b/app/assets/javascripts/profile/preferences/constants.js index ea8464ba06544f3877e4edb5dc0ad4588bc2290b..ab838772729b169107cc35a168693812e61c2383 100644 --- a/app/assets/javascripts/profile/preferences/constants.js +++ b/app/assets/javascripts/profile/preferences/constants.js @@ -1,5 +1,7 @@ import { s__, __ } from '~/locale'; +export const INTEGRATION_EXTENSIONS_MARKETPLACE = 'extensions_marketplace'; + export const INTEGRATION_VIEW_CONFIGS = { sourcegraph: { title: s__('Preferences|Sourcegraph'), @@ -11,6 +13,11 @@ export const INTEGRATION_VIEW_CONFIGS = { label: s__('Preferences|Enable Gitpod integration'), formName: 'gitpod_enabled', }, + [INTEGRATION_EXTENSIONS_MARKETPLACE]: { + title: s__('Preferences|Web IDE'), + label: s__('Preferences|Enable extension marketplace'), + formName: 'extensions_marketplace_enabled', + }, }; export const i18n = { diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 77ae313b5736e509df189f1314208363053b3c0a..c793d86618216466fee5869e6d06b5ade9dbeb3c 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -56,6 +56,7 @@ def preferences_param_names :tab_width, :sourcegraph_enabled, :gitpod_enabled, + :extensions_marketplace_enabled, :render_whitespace_in_code, :project_shortcut_buttons, :keyboard_shortcuts_enabled, diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 312807c004a54733794236809117a168874bc686..e2c41f6da9940bd9875862ecaaa3634bf4e3ee0c 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -71,7 +71,8 @@ def new_ide_data(project:) 'csp-nonce' => content_security_policy_nonce, # We will replace these placeholders in the FE 'ide-remote-path' => ide_remote_path(remote_host: ':remote_host', remote_path: ':remote_path'), - 'editor-font' => new_ide_fonts.to_json + 'editor-font' => new_ide_fonts.to_json, + 'extensions-gallery-settings' => extensions_gallery_settings }.merge(new_ide_code_suggestions_data).merge(new_ide_oauth_data) end @@ -105,4 +106,8 @@ def convert_to_project_entity_json(project) def has_dismissed_ide_environments_callout? current_user.dismissed_callout?(feature_name: 'web_ide_ci_environments_guidance') end + + def extensions_gallery_settings + Gitlab::WebIde::ExtensionsMarketplace.webide_extensions_gallery_settings(user: current_user).to_json + end end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 53d6d35ba8cc4445899e28251be6a6d978e3cf09..6fdfde13a6199d097ab9001805161687d5bcbe70 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -128,11 +128,25 @@ def integration_views [].tap do |views| views << { name: 'gitpod', message: gitpod_enable_description, message_url: gitpod_url_placeholder, help_link: help_page_path('integration/gitpod') } if Gitlab::CurrentSettings.gitpod_enabled views << { name: 'sourcegraph', message: sourcegraph_url_message, message_url: Gitlab::CurrentSettings.sourcegraph_url, help_link: help_page_path('user/profile/preferences', anchor: 'sourcegraph') } if Gitlab::Sourcegraph.feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled + views << extensions_marketplace_view if Gitlab::WebIde::ExtensionsMarketplace.feature_enabled?(user: current_user) end end private + def extensions_marketplace_view + # We handle the linkStart / linkEnd inside of a Vue sprintf + extensions_marketplace_home = "%{linkStart}#{::Gitlab::WebIde::ExtensionsMarketplace.marketplace_home_url}%{linkEnd}" + message = format(s_('PreferencesIntegrations|Uses %{extensions_marketplace_home} as the extension marketplace for the Web IDE.'), extensions_marketplace_home: extensions_marketplace_home) + + { + name: 'extensions_marketplace', + message: message, + message_url: Gitlab::WebIde::ExtensionsMarketplace.marketplace_home_url, + help_link: Gitlab::WebIde::ExtensionsMarketplace.help_preferences_url + } + end + def gitpod_url_placeholder Gitlab::CurrentSettings.gitpod_url.presence || 'https://gitpod.io/' end diff --git a/app/models/user.rb b/app/models/user.rb index a1d75f2d080053c69b13542d3be18ca9b2e9d5dd..89daea0e804b9d7eb4426d350b6b5752f3aa505f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -410,6 +410,7 @@ def update_tracked_fields!(request) :gitpod_enabled, :gitpod_enabled=, :use_web_ide_extension_marketplace, :use_web_ide_extension_marketplace=, :extensions_marketplace_opt_in_status, :extensions_marketplace_opt_in_status=, + :extensions_marketplace_enabled, :extensions_marketplace_enabled=, :setup_for_company, :setup_for_company=, :project_shortcut_buttons, :project_shortcut_buttons=, :keyboard_shortcuts_enabled, :keyboard_shortcuts_enabled=, diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 5845b521e14dba5f4ec6c9a15d91ad6e957007d9..73162ba6e8434055c9515fbe9e14222923e3d020 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -131,6 +131,18 @@ def early_access_event_tracking? early_access_program_participant? && early_access_program_tracking? end + # NOTE: Despite this returning a boolean, it does not end in `?` out of + # symmetry with the other integration fields like `gitpod_enabled` + def extensions_marketplace_enabled + extensions_marketplace_opt_in_status == "enabled" + end + + def extensions_marketplace_enabled=(value) + status = ActiveRecord::Type::Boolean.new.cast(value) ? 'enabled' : 'disabled' + + self.extensions_marketplace_opt_in_status = status + end + private def user_belongs_to_home_organization diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 5065f27f40333b6d7afa71285ec210f7c1bd940b..a7a44db96cd6a4f0fe8223267ad3796b4a72e5d8 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -3,12 +3,13 @@ - user_theme_id = Gitlab::Themes.for_user(@user).id - user_color_mode_id = Gitlab::ColorModes.for_user(@user).id - user_color_schema_id = Gitlab::ColorSchemes.for_user(@user).id -- user_fields = { color_mode_id: user_color_mode_id, theme: user_theme_id, gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled }.to_json +- user_fields = { color_mode_id: user_color_mode_id, theme: user_theme_id, gitpod_enabled: @user.gitpod_enabled, sourcegraph_enabled: @user.sourcegraph_enabled, extensions_marketplace_enabled: @user.extensions_marketplace_enabled }.to_json - fixed_help_text = s_('Preferences|Content will be a maximum of 1280 pixels wide.') - fluid_help_text = s_('Preferences|Content will span %{percentage} of the page width.').html_safe % { percentage: '100%' } - @color_modes = Gitlab::ColorModes::available_modes.to_json - @themes = Gitlab::Themes::available_themes.to_json -- data_attributes = { color_modes: @color_modes, themes: @themes, integration_views: integration_views.to_json, user_fields: user_fields, body_classes: Gitlab::Themes.body_classes, profile_preferences_path: profile_preferences_path } +- extensions_marketplace_url = ::Gitlab::WebIde::ExtensionsMarketplace.marketplace_home_url +- data_attributes = { color_modes: @color_modes, themes: @themes, integration_views: integration_views.to_json, user_fields: user_fields, body_classes: Gitlab::Themes.body_classes, profile_preferences_path: profile_preferences_path, extensions_marketplace_url: extensions_marketplace_url } - @force_desktop_expanded_sidebar = true = gitlab_ui_form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { id: "profile-preferences-form" } do |f| diff --git a/config/feature_flags/beta/web_ide_extensions_marketplace.yml b/config/feature_flags/beta/web_ide_extensions_marketplace.yml new file mode 100644 index 0000000000000000000000000000000000000000..ba76b1d3fb598d7acef9c41af88c2884ee9724a6 --- /dev/null +++ b/config/feature_flags/beta/web_ide_extensions_marketplace.yml @@ -0,0 +1,9 @@ +--- +name: web_ide_extensions_marketplace +feature_issue_url: https://gitlab.com/groups/gitlab-org/-/epics/7685 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/151352 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/459028 +milestone: '17.0' +group: group::ide +type: beta +default_enabled: false diff --git a/doc/user/profile/preferences.md b/doc/user/profile/preferences.md index 78f18b2465528ce6b452a5fa954e3c37505c3ec2..573a3571807c16949fed082d87adfe794ae29406 100644 --- a/doc/user/profile/preferences.md +++ b/doc/user/profile/preferences.md @@ -342,7 +342,7 @@ To access your **Followers** and **Following** tabs: ## Integrate your GitLab instance with third-party services -Give third-party services access to your GitLab account. +Give third-party services access to enhance the GitLab experience. ### Integrate your GitLab instance with Gitpod @@ -370,6 +370,28 @@ To integrate with Sourcegraph: You must be the administrator of the GitLab instance to configure GitLab with Sourcegraph. +### Integrate with the extension marketplace + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/151352) in GitLab 17.0 [with flags](../../administration/feature_flags.md) named `web_ide_oauth` and `web_ide_extensions_marketplace`. Disabled by default. +> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/459028) in GitLab 17.0. + +FLAG: +The availability of this feature is controlled by a feature flag. +For more information, see the history. + +You can use the [extension marketplace](../project/web_ide/index.md#extension-marketplace) +to search and manage extensions for the Web IDE. +For third-party extensions, you must enable the marketplace in your user preferences. + +To enable the extension marketplace for the Web IDE: + +1. On the left sidebar, select your avatar. +1. Select **Preferences**. +1. Go to the **Integrations** section. +1. Select the **Enable extension marketplace** checkbox. +1. In the third-party extension acknowledgement, select **I understand**. +1. Select **Save changes**. + <!-- ## Troubleshooting Include any troubleshooting steps that you can foresee. If you know beforehand what issues diff --git a/doc/user/project/web_ide/index.md b/doc/user/project/web_ide/index.md index f286b3224b3e5e8f0cc6e29e62016844f8d5bc50..eb2f64332c09d3936183157118b2c3979f4c38a8 100644 --- a/doc/user/project/web_ide/index.md +++ b/doc/user/project/web_ide/index.md @@ -203,20 +203,6 @@ To view any notification you might have missed: 1. On the bottom status bar, on the right, select the bell icon (**{notifications}**) for a list of notifications. 1. Select the notification you want to view. -<!-- ## Privacy and data collection for extensions - -The Web IDE Extension Marketplace is based on Open VSX. Open VSX does not collect any -data about you or your activities on the platform. - -However, the privacy and data collection practices of extensions available on Open VSX can vary. -Some extensions might collect data to provide personalized recommendations or to improve the functionality. -Other extensions might collect data for analytics or advertising purposes. - -To protect your privacy and data: - -- Carefully review the permissions requested by an extension before you install the extension. -- Keep your extensions up to date to ensure that any security or privacy vulnerabilities are addressed promptly. --> - ## Interactive web terminals DETAILS: @@ -235,6 +221,50 @@ However, you can use a terminal to install dependencies and compile and debug co For more information, see [Remote development](../remote_development/index.md). +## Extension marketplace + +DETAILS: +**Status**: Beta + +WARNING: +This feature is in [Beta](../../../policy/experiment-beta-support.md#beta) and subject to change without notice. + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/151352) in GitLab 17.0 [with flags](../../../administration/feature_flags.md) named `web_ide_oauth` and `web_ide_extensions_marketplace`. Disabled by default. +> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/459028) in GitLab 17.0. + +FLAG: +The availability of this feature is controlled by a feature flag. +For more information, see the history. + +Prerequisites: + +- You must enable the extension marketplace in your [user preferences](../../profile/preferences.md#integrate-with-the-extension-marketplace). + +You can use the extension marketplace to download and run VS Code extensions in the Web IDE. + +The extension marketplace is preconfigured at the GitLab instance level +and is hardcoded to [`https://open-vsx.org/`](https://open-vsx.org/). +[Epic 11770](https://gitlab.com/groups/gitlab-org/-/epics/11770) proposes to change this behavior. + +### Install an extension + +To install an extension in the Web IDE: + +1. On the top menu bar, select **View > Extensions**, + or press <kbd>Command</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd>. +1. In the search box, enter the extension name. +1. Select the extension you want to install. +1. Select **Install**. + +### Uninstall an extension + +To uninstall an extension in the Web IDE: + +1. On the top menu bar, select **View > Extensions**, + or press <kbd>Command</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd>. +1. From the list of installed extensions, select the extension you want to uninstall. +1. Select **Uninstall**. + ## Related topics - [GitLab Duo Chat in the Web IDE](../../gitlab_duo_chat.md#use-gitlab-duo-chat-in-the-web-ide) diff --git a/ee/lib/remote_development/settings/extensions_gallery_metadata_generator.rb b/ee/lib/remote_development/settings/extensions_gallery_metadata_generator.rb index 4cb20daabbc6e1fe5e9f8f1e1bd9019232dae990..ca12819275c05acb7564b3d52e0e8ff8db785359 100644 --- a/ee/lib/remote_development/settings/extensions_gallery_metadata_generator.rb +++ b/ee/lib/remote_development/settings/extensions_gallery_metadata_generator.rb @@ -5,19 +5,6 @@ module Settings class ExtensionsGalleryMetadataGenerator include Messages - # NOTE: These `disabled_reason` enumeration values are also referenced/consumed in - # the "gitlab-web-ide" and "gitlab-web-ide-vscode-fork" projects - # (https://gitlab.com/gitlab-org/gitlab-web-ide & https://gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork), - # so we must ensure that any changes made here are also reflected in those projects. - DISABLED_REASONS = - %i[ - no_user - no_flag - instance_disabled - opt_in_unset - opt_in_disabled - ].to_h { |reason| [reason, reason] }.freeze - # @param [Hash] value # @return [Hash] def self.generate(value) @@ -29,7 +16,7 @@ def self.generate(value) extensions_marketplace_feature_flag_enabled } - extensions_gallery_metadata = generate_settings( + extensions_gallery_metadata = ::Gitlab::WebIde::ExtensionsMarketplace.metadata_for_user( user: user, flag_enabled: extensions_marketplace_feature_flag_enabled ) @@ -37,30 +24,6 @@ def self.generate(value) value[:settings][:vscode_extensions_gallery_metadata] = extensions_gallery_metadata value end - - # @param [User, nil] user - # @param [Boolean, nil] flag_enabled - # @return [Hash] - def self.generate_settings(user:, flag_enabled:) - return { enabled: false, disabled_reason: DISABLED_REASONS.fetch(:no_user) } unless user - return { enabled: false, disabled_reason: DISABLED_REASONS.fetch(:no_flag) } if flag_enabled.nil? - return { enabled: false, disabled_reason: DISABLED_REASONS.fetch(:instance_disabled) } unless flag_enabled - - # noinspection RubyNilAnalysis -- RubyMine doesn't realize user can't be nil because of guard clause above - opt_in_status = user.extensions_marketplace_opt_in_status.to_sym - - return { enabled: true } if opt_in_status == :enabled - return { enabled: false, disabled_reason: DISABLED_REASONS.fetch(:opt_in_unset) } if opt_in_status == :unset - - if opt_in_status == :disabled - return { enabled: false, disabled_reason: DISABLED_REASONS.fetch(:opt_in_disabled) } - end - - # This is an internal bug due to an enumeration mismatch/inconsistency with the model, so lets throw an - # exception up the stack and let it be returned as a 500 - don't try to handle it via the ROP chain - raise "Invalid user.extensions_marketplace_opt_in_status: '#{opt_in_status}'. " \ - "Supported statuses are: #{Enums::WebIde::ExtensionsMarketplaceOptInStatus.statuses.keys}." # rubocop:disable Layout/LineEndStringConcatenationIndentation -- This is already changed in the next version of gitlab-styles - end end end end diff --git a/lib/gitlab/web_ide/extensions_marketplace.rb b/lib/gitlab/web_ide/extensions_marketplace.rb new file mode 100644 index 0000000000000000000000000000000000000000..36a8db999fefc5dc18655bc3fcfa5400c9bee243 --- /dev/null +++ b/lib/gitlab/web_ide/extensions_marketplace.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Gitlab + module WebIde + module ExtensionsMarketplace + # NOTE: These `disabled_reason` enumeration values are also referenced/consumed in + # the "gitlab-web-ide" and "gitlab-web-ide-vscode-fork" projects + # (https://gitlab.com/gitlab-org/gitlab-web-ide & https://gitlab.com/gitlab-org/gitlab-web-ide-vscode-fork), + # so we must ensure that any changes made here are also reflected in those projects. + DISABLED_REASONS = + %i[ + no_user + no_flag + instance_disabled + opt_in_unset + opt_in_disabled + ].to_h { |reason| [reason, reason] }.freeze + + class << self + def feature_enabled?(user:) + # TODO: Add instance-level setting for this https://gitlab.com/gitlab-org/gitlab/-/issues/451871 + + # note: OAuth **must** be enabled for us to use the extension marketplace + ::Gitlab::WebIde::DefaultOauthApplication.feature_enabled?(user) && + Feature.enabled?(:web_ide_extensions_marketplace, user) + end + + def vscode_settings + # TODO: Add instance-level setting for this https://gitlab.com/gitlab-org/gitlab/-/issues/451871 + # TODO: We need to harmonize this with `ee/lib/remote_development/settings/defaults_initializer.rb` + # https://gitlab.com/gitlab-org/gitlab/-/issues/460515 + { + item_url: 'https://open-vsx.org/vscode/item', + service_url: 'https://open-vsx.org/vscode/gallery', + resource_url_template: + 'https://open-vsx.org/vscode/unpkg/{publisher}/{name}/{version}/{path}', + control_url: '', + nls_base_url: '', + publisher_url: '' + } + end + + # This value is used when the end-user is accepting the third-party extension marketplace integration. + def marketplace_home_url + "https://open-vsx.org" + end + + def help_url + ::Gitlab::Routing.url_helpers.help_page_url('user/project/web_ide/index', anchor: 'extension-marketplace') + end + + def help_preferences_url + ::Gitlab::Routing.url_helpers.help_page_url('user/profile/preferences', + anchor: 'integrate-with-the-extension-marketplace') + end + + def user_preferences_url + ::Gitlab::Routing.url_helpers.profile_preferences_url(anchor: 'integrations') + end + + # This returns a value to be used in the Web IDE config `extensionsGallerySettings` + # It should match the type expected by the Web IDE: + # + # - https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/51f9e91f890752596e7a3ef51f436fea07885eff/packages/web-ide-types/src/config.ts#L109 + # + # @return [Hash] + def webide_extensions_gallery_settings(user:) + flag_enabled = feature_enabled?(user: user) + metadata = metadata_for_user(user: user, flag_enabled: flag_enabled) + + return { enabled: true, vscode_settings: vscode_settings } if metadata.fetch(:enabled) + + disabled_reason = metadata.fetch(:disabled_reason, nil) + result = { enabled: false, reason: disabled_reason, help_url: help_url } + + if disabled_reason == :opt_in_unset || disabled_reason == :opt_in_disabled + result[:user_preferences_url] = user_preferences_url + end + + result + end + + # @param [User, nil] user + # @param [Boolean, nil] flag_enabled + # @return [Hash] + def metadata_for_user(user:, flag_enabled:) + return metadata_disabled(:no_user) unless user + return metadata_disabled(:no_flag) if flag_enabled.nil? + return metadata_disabled(:instance_disabled) unless flag_enabled + + # noinspection RubyNilAnalysis -- RubyMine doesn't realize user can't be nil because of guard clause above + opt_in_status = user.extensions_marketplace_opt_in_status.to_sym + + case opt_in_status + when :enabled + return metadata_enabled + when :unset + return metadata_disabled(:opt_in_unset) + when :disabled + return metadata_disabled(:opt_in_disabled) + end + + # This is an internal bug due to an enumeration mismatch/inconsistency with the model + raise "Invalid user.extensions_marketplace_opt_in_status: '#{opt_in_status}'. " \ + "Supported statuses are: #{Enums::WebIde::ExtensionsMarketplaceOptInStatus.statuses.keys}." # rubocop:disable Layout/LineEndStringConcatenationIndentation -- This is already changed in the next version of gitlab-styles + end + + private + + def metadata_enabled + { enabled: true } + end + + def metadata_disabled(reason) + { enabled: false, disabled_reason: DISABLED_REASONS.fetch(reason) } + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index efa55e8281be2b0b6708f30c6e8fb3c1a39602e8..2a941af32a2a70d5ffcf630cfc0b64c26e35f04c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -38771,6 +38771,27 @@ msgstr "" msgid "Preferences saved." msgstr "" +msgid "PreferencesIntegrations|By using the Extension Marketplace, you will send data, such as IP address and other device information, to %{url} in accordance with their independent terms and privacy policy." +msgstr "" + +msgid "PreferencesIntegrations|GitLab does not assume any responsibility for the functionality of these third-party extensions. Each %{boldStart}third-party%{boldEnd} extension has %{boldStart}their own independent%{boldEnd} terms and conditions listed in the marketplace. By installing an extension, you are agreeing to the terms & conditions and Privacy Policy that govern each individual extension as listed in the marketplace." +msgstr "" + +msgid "PreferencesIntegrations|I understand" +msgstr "" + +msgid "PreferencesIntegrations|Learn more" +msgstr "" + +msgid "PreferencesIntegrations|Third-Party Extensions Acknowledgement" +msgstr "" + +msgid "PreferencesIntegrations|Third-party extensions are now available in the Web IDE. While each extension runs in a secure browser sandbox, %{boldStart}third-party extensions%{boldEnd} may have access to the contents of the files opened in the Web IDE, %{boldStart}including any personal data in those files%{boldEnd}, and may communicate with external servers." +msgstr "" + +msgid "PreferencesIntegrations|Uses %{extensions_marketplace_home} as the extension marketplace for the Web IDE." +msgstr "" + msgid "Preferences|%{link_start}List of keyboard shortcuts%{link_end}" msgstr "" @@ -38837,6 +38858,9 @@ msgstr "" msgid "Preferences|Enable Zoekt code search" msgstr "" +msgid "Preferences|Enable extension marketplace" +msgstr "" + msgid "Preferences|Enable follow users" msgstr "" @@ -38927,6 +38951,9 @@ msgstr "" msgid "Preferences|Use relative times" msgstr "" +msgid "Preferences|Web IDE" +msgstr "" + msgid "Preferences|When you type in a description or comment box, pressing %{kbdOpen}Enter%{kbdClose} in a list adds a new item below." msgstr "" diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb index 7a026da540125c2ae20063d0fa0af292280978e1..504c75aaf532a20d0a599604421e24ea794d3403 100644 --- a/spec/controllers/profiles/preferences_controller_spec.rb +++ b/spec/controllers/profiles/preferences_controller_spec.rb @@ -61,7 +61,8 @@ def go(params: {}, format: :json) tab_width: '5', project_shortcut_buttons: 'true', keyboard_shortcuts_enabled: 'true', - render_whitespace_in_code: 'true' + render_whitespace_in_code: 'true', + extensions_marketplace_enabled: '1' }.with_indifferent_access expect(user).to receive(:assign_attributes).with(ActionController::Parameters.new(prefs).permit!) diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js index a757b8ec622838978cc9ec2981d2a76ab10e4e06..3a75e110c73a3ac80b12efbab1667d6f138bd5fb 100644 --- a/spec/frontend/ide/init_gitlab_web_ide_spec.js +++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js @@ -36,6 +36,13 @@ const TEST_START_REMOTE_PARAMS = { remotePath: '/test/projects/f oo', connectionToken: '123abc', }; +const TEST_EXTENSIONS_GALLERY_SETTINGS = JSON.stringify({ + enabled: true, + vscode_settings: { + item_url: 'https://gitlab.test/vscode/marketplace/item/url', + service_url: 'https://gitlab.test/vscode/marketplace/service/url', + }, +}); const TEST_EDITOR_FONT_SRC_URL = 'http://gitlab.test/assets/gitlab-mono/GitLabMono.woff2'; const TEST_EDITOR_FONT_FORMAT = 'woff2'; const TEST_EDITOR_FONT_FAMILY = 'GitLab Mono'; @@ -262,4 +269,28 @@ describe('ide/init_gitlab_web_ide', () => { ); }); }); + + describe('when extensionsGallerySettings is in dataset', () => { + beforeEach(() => { + findRootElement().dataset.extensionsGallerySettings = TEST_EXTENSIONS_GALLERY_SETTINGS; + + createSubject(); + }); + + it('calls start with element and extensionsGallerySettings', () => { + expect(start).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledWith( + findRootElement(), + expect.objectContaining({ + extensionsGallerySettings: { + enabled: true, + vscodeSettings: { + itemUrl: 'https://gitlab.test/vscode/marketplace/item/url', + serviceUrl: 'https://gitlab.test/vscode/marketplace/service/url', + }, + }, + }), + ); + }); + }); }); diff --git a/spec/frontend/profile/preferences/components/extensions_marketplace_warning_spec.js b/spec/frontend/profile/preferences/components/extensions_marketplace_warning_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c037b406633cafea99335ac643aa03a72e5035c3 --- /dev/null +++ b/spec/frontend/profile/preferences/components/extensions_marketplace_warning_spec.js @@ -0,0 +1,154 @@ +import { nextTick } from 'vue'; +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ExtensionsMarketplaceWarning, { + WARNING_PARAGRAPH_1, + WARNING_PARAGRAPH_2, + WARNING_PARAGRAPH_3, +} from '~/profile/preferences/components/extensions_marketplace_warning.vue'; + +const TEST_HELP_URL = 'http://localhost/help/url'; +const TEST_MARKETPLACE_URL = 'http://localhost/extensions/marketplace'; + +describe('profile/preferences/components/extensions_marketplace_warning', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ExtensionsMarketplaceWarning, { + propsData: { + value: false, + helpUrl: TEST_HELP_URL, + ...props, + }, + provide: { + extensionsMarketplaceUrl: TEST_MARKETPLACE_URL, + }, + stubs: { + GlSprintf, + }, + }); + }; + + const findModal = () => wrapper.findComponent(GlModal); + + const closeModal = async () => { + findModal().vm.$emit('change', false); + findModal().vm.$emit('hide'); + + await nextTick(); + }; + + const setValue = async (value) => { + wrapper.setProps({ value }); + await nextTick(); + }; + + describe('when initializes with value: false', () => { + beforeEach(() => { + createComponent({ value: false }); + }); + + it('does not show modal', () => { + expect(findModal().props('visible')).toBe(false); + }); + + describe('when value changes to true', () => { + beforeEach(async () => { + await setValue(true); + }); + + it('shows modal with props', () => { + expect(findModal().props()).toMatchObject({ + visible: true, + modalId: 'extensions-marketplace-warning-modal', + title: 'Third-Party Extensions Acknowledgement', + actionPrimary: { + text: 'I understand', + }, + actionSecondary: { + text: 'Learn more', + attributes: { + href: TEST_HELP_URL, + variant: 'default', + }, + }, + }); + }); + + it('shows modal text', () => { + expect(findModal().text()).toMatchInterpolatedText( + `${WARNING_PARAGRAPH_1} ${WARNING_PARAGRAPH_2} ${WARNING_PARAGRAPH_3}`.replace( + '%{url}', + TEST_MARKETPLACE_URL, + ), + ); + }); + + it('emits input to reset value to false', () => { + expect(wrapper.emitted('input')).toEqual([[false]]); + }); + + describe('when modal canceled', () => { + beforeEach(async () => { + await closeModal(); + }); + + it('does not change anything', () => { + expect(wrapper.emitted('input')).toEqual([[false]]); + }); + + it('opens modal again when value changes', async () => { + await setValue(false); + + expect(findModal().props('visible')).toBe(false); + + await setValue(true); + + expect(findModal().props('visible')).toBe(true); + }); + }); + + describe('when modal is accepted', () => { + beforeEach(async () => { + findModal().vm.$emit('primary'); + + await closeModal(); + }); + + it('updates value', () => { + expect(wrapper.emitted('input')).toEqual([[false], [true]]); + }); + + it('does not open modal when value changes', async () => { + await setValue(false); + + expect(findModal().props('visible')).toBe(false); + + await setValue(true); + + expect(findModal().props('visible')).toBe(false); + }); + }); + }); + }); + + describe('when initiailized with value: true', () => { + beforeEach(() => { + createComponent({ value: true }); + }); + + it('does not show modal', () => { + expect(findModal().props('visible')).toBe(false); + }); + + it('does not open modal when value changes', async () => { + await setValue(false); + + expect(findModal().props('visible')).toBe(false); + + await setValue(true); + + expect(findModal().props('visible')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/profile/preferences/components/integration_view_spec.js b/spec/frontend/profile/preferences/components/integration_view_spec.js index b809f2f4aede8dc7cd505f827979074d3128424f..0ea6271c944cb317cb7b6c0a4c09acf6ce6c8d36 100644 --- a/spec/frontend/profile/preferences/components/integration_view_spec.js +++ b/spec/frontend/profile/preferences/components/integration_view_spec.js @@ -1,10 +1,11 @@ +import { nextTick } from 'vue'; import { GlFormGroup } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import IntegrationView from '~/profile/preferences/components/integration_view.vue'; import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue'; -import { integrationViews, userFields } from '../mock_data'; +import { integrationViews } from '../mock_data'; const viewProps = convertObjectPropsToCamelCase(integrationViews[0]); @@ -16,16 +17,12 @@ describe('IntegrationView component', () => { label: 'Enable foo', formName: 'foo_enabled', }, + value: true, ...viewProps, }; - function createComponent(options = {}) { - const { props = {}, provide = {} } = options; + function createComponent(props = {}) { return mountExtended(IntegrationView, { - provide: { - userFields, - ...provide, - }, propsData: { ...defaultProps, ...props, @@ -73,19 +70,7 @@ describe('IntegrationView component', () => { }); it('should set the checkbox value to be false when false is provided', () => { - wrapper = createComponent({ - provide: { - userFields: { - foo_enabled: false, - }, - }, - }); - - expect(findCheckbox().element.checked).toBe(false); - }); - - it('should set the checkbox value to be false when not provided', () => { - wrapper = createComponent({ provide: { userFields: {} } }); + wrapper = createComponent({ value: false }); expect(findCheckbox().element.checked).toBe(false); }); @@ -95,4 +80,28 @@ describe('IntegrationView component', () => { expect(wrapper.findComponent(IntegrationHelpText).exists()).toBe(true); }); + + describe('when prop value changes', () => { + beforeEach(async () => { + wrapper = createComponent(); + + wrapper.setProps({ value: false }); + await nextTick(); + }); + + it('should update the checkbox value', () => { + expect(findCheckbox().element.checked).toBe(false); + }); + }); + + it('when checkbox clicked, should update the checkbox value', async () => { + wrapper = createComponent({ value: false }); + + expect(wrapper.emitted('input')).toBe(undefined); + + findCheckbox().setChecked(true); + await nextTick(); + + expect(wrapper.emitted('input')).toEqual([[true]]); + }); }); diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js index 6fa7f4303f4469bb00290e6231e76205590ebf3f..67f766ad1161c339f0a519abb24476f22ce7506c 100644 --- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js +++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js @@ -6,7 +6,12 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { createAlert, VARIANT_DANGER } from '~/alert'; import IntegrationView from '~/profile/preferences/components/integration_view.vue'; import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue'; -import { i18n } from '~/profile/preferences/constants'; +import ExtensionsMarketplaceWarning from '~/profile/preferences/components/extensions_marketplace_warning.vue'; +import { + i18n, + INTEGRATION_EXTENSIONS_MARKETPLACE, + INTEGRATION_VIEW_CONFIGS, +} from '~/profile/preferences/constants'; import { integrationViews, userFields, @@ -243,4 +248,47 @@ describe('ProfilePreferences component', () => { expect(window.location.reload).toHaveBeenCalledTimes(1); }); }); + + describe('with extensions marketplace integration view', () => { + beforeEach(() => { + wrapper = createComponent({ + provide: { + integrationViews: [ + { + name: INTEGRATION_EXTENSIONS_MARKETPLACE, + help_link: 'http://foo.com/help-extensions-marketplace', + message: 'Click %{linkStart}Foo%{linkEnd}!', + message_url: 'http://foo.com', + }, + ], + }, + }); + }); + + it('renders view with 2-way-bound value', async () => { + const integrationView = wrapper.findComponent(IntegrationView); + + expect(integrationView.props()).toMatchObject({ + value: false, + config: INTEGRATION_VIEW_CONFIGS[INTEGRATION_EXTENSIONS_MARKETPLACE], + }); + + await integrationView.vm.$emit('input', true); + + expect(integrationView.props('value')).toBe(true); + }); + + it('renders extensions marketplace warning with 2-way-bound value', async () => { + const warning = wrapper.findComponent(ExtensionsMarketplaceWarning); + + expect(warning.props()).toEqual({ + helpUrl: 'http://foo.com/help-extensions-marketplace', + value: false, + }); + + await warning.vm.$emit('input', true); + + expect(warning.props('value')).toBe(true); + }); + }); }); diff --git a/spec/helpers/ide_helper_spec.rb b/spec/helpers/ide_helper_spec.rb index 99ef0998fda82b5f8190deefde957d7d86ea4695..4af32085d66901cbc918a0f3e989ac74787c5e31 100644 --- a/spec/helpers/ide_helper_spec.rb +++ b/spec/helpers/ide_helper_spec.rb @@ -83,6 +83,15 @@ .to include(base_data) end + it 'includes extensions gallery settings' do + expect(Gitlab::WebIde::ExtensionsMarketplace).to receive(:webide_extensions_gallery_settings) + .with(user: user).and_return({ enabled: false }) + + actual = helper.ide_data(project: nil, fork_info: fork_info, params: params) + + expect(actual).to include({ 'extensions-gallery-settings' => { enabled: false }.to_json }) + end + it 'includes editor font configuration' do ide_data = helper.ide_data(project: nil, fork_info: fork_info, params: params) editor_font = ::Gitlab::Json.parse(ide_data.fetch('editor-font'), symbolize_names: true) diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb index b259cb8eaf7159e5dd1047de66bee5de9d5a55b6..5306b86d08f2e2ad77234eb2a891dadd372c968b 100644 --- a/spec/helpers/preferences_helper_spec.rb +++ b/spec/helpers/preferences_helper_spec.rb @@ -3,11 +3,14 @@ require 'spec_helper' RSpec.describe PreferencesHelper do - describe '#dashboard_choices' do - let(:user) { build(:user) } + let_it_be(:user) { build(:user) } + + before do + allow(helper).to receive(:current_user).and_return(user) + end + describe '#dashboard_choices' do before do - allow(helper).to receive(:current_user).and_return(user) allow(helper).to receive(:can?).and_return(false) end @@ -242,16 +245,15 @@ def stub_user(messages = {}) describe '#integration_views' do let(:gitpod_url) { 'http://gitpod.test' } + let(:gitpod_enabled) { false } before do allow(Gitlab::CurrentSettings).to receive(:gitpod_enabled).and_return(gitpod_enabled) allow(Gitlab::CurrentSettings).to receive(:gitpod_url).and_return(gitpod_url) end - context 'when Gitpod is not enabled' do - let(:gitpod_enabled) { false } - - it 'does not include Gitpod integration' do + context 'on default' do + it 'does not include integration views' do expect(helper.integration_views).to be_empty end end @@ -260,20 +262,36 @@ def stub_user(messages = {}) let(:gitpod_enabled) { true } it 'includes Gitpod integration' do - expect(helper.integration_views[0][:name]).to eq 'gitpod' - end - - it 'returns the Gitpod url configured in settings' do - expect(helper.integration_views[0][:message_url]).to eq gitpod_url + expect(helper.integration_views).to include( + a_hash_including({ name: 'gitpod', message_url: gitpod_url }) + ) end context 'when Gitpod url is not set' do let(:gitpod_url) { '' } - it 'returns the Gitpod default url' do - expect(helper.integration_views[0][:message_url]).to eq 'https://gitpod.io/' + it 'includes Gitpod integration with default url' do + expect(helper.integration_views).to include( + a_hash_including({ name: 'gitpod', message_url: 'https://gitpod.io/' }) + ) end end end + + context 'when WebIdeExtensionsMarketplace is enabled' do + before do + allow(Gitlab::WebIde::ExtensionsMarketplace).to receive(:feature_enabled?).with(user: user).and_return(true) + end + + it 'includes extension marketplace integration' do + expect(helper.integration_views).to include( + a_hash_including({ + name: 'extensions_marketplace', + message: 'Uses %{linkStart}https://open-vsx.org%{linkEnd} as the extension marketplace for the Web IDE.', + message_url: 'https://open-vsx.org' + }) + ) + end + end end end diff --git a/spec/lib/gitlab/web_ide/extensions_marketplace_spec.rb b/spec/lib/gitlab/web_ide/extensions_marketplace_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7c9ba0553a60330ad405b608b90cac1d1c1d599c --- /dev/null +++ b/spec/lib/gitlab/web_ide/extensions_marketplace_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::WebIde::ExtensionsMarketplace, feature_category: :web_ide do + using RSpec::Parameterized::TableSyntax + + let_it_be_with_reload(:current_user) { create(:user) } + let_it_be(:default_vscode_settings) do + { + item_url: 'https://open-vsx.org/vscode/item', + service_url: 'https://open-vsx.org/vscode/gallery', + resource_url_template: + 'https://open-vsx.org/vscode/unpkg/{publisher}/{name}/{version}/{path}' + } + end + + describe '#feature_enabled?' do + where(:web_ide_extensions_marketplace, :web_ide_oauth, :expectation) do + ref(:current_user) | false | false + false | true | false + ref(:current_user) | true | true + end + + with_them do + it 'returns the expected value' do + stub_feature_flags(web_ide_extensions_marketplace: web_ide_extensions_marketplace) + expect(::Gitlab::WebIde::DefaultOauthApplication).to receive(:feature_enabled?) + .with(current_user).and_return(web_ide_oauth) + + expect(described_class.feature_enabled?(user: current_user)).to be(expectation) + end + end + end + + describe '#vscode_settings' do + it { expect(described_class.vscode_settings).to match(hash_including(default_vscode_settings)) } + end + + describe '#marketplace_home_url' do + it { expect(described_class.marketplace_home_url).to eq('https://open-vsx.org') } + end + + describe '#help_url' do + it { expect(described_class.help_url).to match('/help/user/project/web_ide/index#extension-marketplace') } + end + + describe '#help_preferences_url' do + it do + expect(described_class.help_preferences_url).to match( + '/help/user/profile/preferences#integrate-with-the-extension-marketplace' + ) + end + end + + describe '#user_preferences_url' do + it { expect(described_class.user_preferences_url).to match('/-/profile/preferences#integrations') } + end + + describe '#metadata_for_user' do + where(:user, :opt_in_status, :flag_enabled, :expectation) do + nil | nil | false | { enabled: false, disabled_reason: :no_user } + ref(:current_user) | nil | nil | { enabled: false, disabled_reason: :no_flag } + ref(:current_user) | nil | false | { enabled: false, disabled_reason: :instance_disabled } + ref(:current_user) | :enabled | true | { enabled: true } + ref(:current_user) | :disabled | true | { enabled: false, disabled_reason: :opt_in_disabled } + ref(:current_user) | :unset | true | { enabled: false, disabled_reason: :opt_in_unset } + end + + with_them do + subject(:metadata) { described_class.metadata_for_user(user: user, flag_enabled: flag_enabled) } + + before do + user.update!(extensions_marketplace_opt_in_status: opt_in_status) if user && opt_in_status + end + + it 'returns expected metadata for user' do + expect(metadata).to eq(expectation) + end + end + end + + describe '#webide_extensions_gallery_settings' do + subject(:webide_settings) { described_class.webide_extensions_gallery_settings(user: current_user) } + + context 'when instance enabled' do + before do + stub_feature_flags( + web_ide_extensions_marketplace: current_user, + web_ide_oauth: current_user, + vscode_web_ide: current_user + ) + end + + it 'when user opt in enabled, returns enabled settings' do + current_user.update!(extensions_marketplace_opt_in_status: :enabled) + + expect(webide_settings).to match({ + enabled: true, + vscode_settings: hash_including(default_vscode_settings) + }) + end + + context 'when user opt in disabled' do + where(:opt_in_status, :reason) do + :unset | :opt_in_unset + :disabled | :opt_in_disabled + end + + with_them do + it 'returns disabled settings' do + current_user.update!(extensions_marketplace_opt_in_status: opt_in_status) + + expect(webide_settings).to match({ + enabled: false, + reason: reason, + help_url: described_class.help_url, + user_preferences_url: described_class.user_preferences_url + }) + end + end + end + end + + context 'when instance disabled' do + it 'returns disabled settings and help url' do + expect(webide_settings).to match({ + enabled: false, + reason: :instance_disabled, + help_url: described_class.help_url + }) + end + end + end +end diff --git a/spec/models/user_preference_spec.rb b/spec/models/user_preference_spec.rb index 1c520f08b7cbd46c124d7ca28d3858d75945a462..d90958aa95acc8bc226933a2c1ed4925fe6311ab 100644 --- a/spec/models/user_preference_spec.rb +++ b/spec/models/user_preference_spec.rb @@ -340,4 +340,43 @@ it { expect(user_preference.early_access_event_tracking?).to be false } end end + + describe '#extensions_marketplace_enabled' do + where(:opt_in_status, :expected_value) do + [ + ["enabled", true], + ["disabled", false], + ["unset", false] + ] + end + + with_them do + it 'returns boolean from extensions_marketplace_opt_in_status' do + user_preference.update!(extensions_marketplace_opt_in_status: opt_in_status) + + expect(user_preference.extensions_marketplace_enabled).to be expected_value + end + end + end + + describe '#extensions_marketplace_enabled=' do + where(:value, :expected_opt_in_status) do + [ + [true, "enabled"], + [false, "disabled"], + [0, "disabled"], + [1, "enabled"] + ] + end + + with_them do + it 'updates extensions_marketplace_opt_in_status' do + user_preference.update!(extensions_marketplace_opt_in_status: 'unset') + + user_preference.extensions_marketplace_enabled = value + + expect(user_preference.extensions_marketplace_opt_in_status).to be expected_opt_in_status + end + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index e4f8be682bc1832516963517a4236a7bfcaf61c2..0f85f74deea5944413e19afc8e7d1d01732da0ed 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -89,6 +89,9 @@ it { is_expected.to delegate_method(:use_new_navigation).to(:user_preference) } it { is_expected.to delegate_method(:use_new_navigation=).to(:user_preference).with_arguments(:args) } + it { is_expected.to delegate_method(:extensions_marketplace_enabled).to(:user_preference) } + it { is_expected.to delegate_method(:extensions_marketplace_enabled=).to(:user_preference).with_arguments(:args) } + it { is_expected.to delegate_method(:pinned_nav_items).to(:user_preference) } it { is_expected.to delegate_method(:pinned_nav_items=).to(:user_preference).with_arguments(:args) }