diff --git a/ee/app/assets/javascripts/pages/subscriptions/groups/new/index.js b/ee/app/assets/javascripts/pages/subscriptions/groups/new/index.js new file mode 100644 index 0000000000000000000000000000000000000000..593adc02f372d078b9f3ee0d43cb85ddc196e0f8 --- /dev/null +++ b/ee/app/assets/javascripts/pages/subscriptions/groups/new/index.js @@ -0,0 +1,3 @@ +import mountGroupCreationApp from 'ee/subscriptions/groups/new'; + +mountGroupCreationApp(); diff --git a/ee/app/assets/javascripts/subscriptions/groups/new/components/subscription_group_selector.vue b/ee/app/assets/javascripts/subscriptions/groups/new/components/subscription_group_selector.vue new file mode 100644 index 0000000000000000000000000000000000000000..8e9c9b9b3203965057c5bf7209e5073350e157fa --- /dev/null +++ b/ee/app/assets/javascripts/subscriptions/groups/new/components/subscription_group_selector.vue @@ -0,0 +1,178 @@ +<script> +import { + GlAccordion, + GlAccordionItem, + GlButton, + GlCard, + GlCollapsibleListbox, + GlFormGroup, +} from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { __, s__, sprintf } from '~/locale'; +import { visitUrl } from '~/lib/utils/url_utility'; + +export default { + name: 'SubscriptionGroupSelector', + components: { + GlAccordion, + GlAccordionItem, + GlButton, + GlCard, + GlCollapsibleListbox, + GlFormGroup, + }, + props: { + eligibleGroups: { + type: Array, + required: false, + default: () => [], + }, + plansData: { + type: Object, + required: true, + }, + rootUrl: { + type: String, + required: true, + }, + }, + data() { + return { + selectedGroupId: null, + isValidated: false, + isLoading: false, + }; + }, + computed: { + selectedGroup() { + return this.eligibleGroups.find((group) => group.id === this.selectedGroupId); + }, + toggleText() { + return this.selectedGroup + ? this.selectedGroup.name + : this.$options.i18n.groupSelection.placeholder; + }, + groupOptions() { + return this.eligibleGroups.map(({ id, name }) => ({ text: name, value: id })); + }, + hasValidGroupSelection() { + return Boolean(this.selectedGroupId); + }, + groupValidationError() { + if (!this.isValidated || this.hasValidGroupSelection) { + return null; + } + return this.$options.i18n.groupSelection.validationMessage; + }, + planName() { + switch (this.plansData.code) { + case 'premium': + return s__('BillingPlans|Premium'); + case 'ultimate': + return s__('BillingPlans|Ultimate'); + default: + return this.plansData.name; + } + }, + title() { + return sprintf( + s__('SubscriptionGroupsNew|Select a group for your %{planName} subscription'), + { planName: this.planName }, + ); + }, + }, + methods: { + handleGroupSelection(value) { + this.selectedGroupId = value; + }, + continueWithSelection() { + this.isValidated = true; + + if (!this.hasValidGroupSelection) { + return; + } + + this.isLoading = true; + this.navigateToPurchaseFlow(this.selectedGroupId); + }, + navigateToPurchaseFlow(groupId) { + // We should always have a purchase link available. In the unlikely scenario where + // we don't, we want to know about it, so let's report the error to Sentry + if (!this.plansData.purchase_link?.href) { + this.reportError(`Missing purchase link for plan ${JSON.stringify(this.plansData)}`); + return; + } + + const purchaseLink = `${this.plansData.purchase_link.href}&gl_namespace_id=${groupId}`; + visitUrl(purchaseLink); + }, + reportError(error) { + Sentry.captureException(error, { + tags: { + vue_component: this.$options.name, + }, + }); + }, + }, + i18n: { + groupSelection: { + placeholder: __('Select a group'), + label: __('Group'), + description: s__('Checkout|Your subscription will be applied to this group'), + validationMessage: s__('SubscriptionGroupsNew|Select a group for your subscription'), + }, + accordion: { + title: s__(`SubscriptionGroupsNew|Why can't I find my group?`), + description: s__( + 'SubscriptionGroupsNew|Your group will only be displayed in the list above if:', + ), + reasonOne: s__(`SubscriptionGroupsNew|You're assigned the Owner role of the group`), + reasonTwo: s__('SubscriptionGroupsNew|The group is a top-level group on a Free tier'), + }, + }, +}; +</script> +<template> + <div class="gl-flex gl-justify-center"> + <div class="gl-max-w-88"> + <h2>{{ title }}</h2> + <gl-card class="gl-max-w-62 gl-mx-auto gl-p-5 gl-mt-10"> + <label class="gl-block gl-mb-1">{{ $options.i18n.groupSelection.label }}</label> + <span class="gl-text-secondary">{{ $options.i18n.groupSelection.description }}</span> + <gl-form-group + :state="!groupValidationError" + :invalid-feedback="groupValidationError" + data-testid="group-selector" + > + <gl-collapsible-listbox + v-model="selectedGroupId" + block + fluid-width + :items="groupOptions" + :toggle-text="toggleText" + category="secondary" + :variant="groupValidationError ? 'danger' : 'default'" + @select="handleGroupSelection" + /> + </gl-form-group> + <gl-accordion :header-level="3"> + <gl-accordion-item :title="$options.i18n.accordion.title"> + {{ $options.i18n.accordion.description }} + <ul class="gl-mt-4"> + <li>{{ $options.i18n.accordion.reasonOne }}</li> + <li>{{ $options.i18n.accordion.reasonTwo }}</li> + </ul> + </gl-accordion-item> + </gl-accordion> + <gl-button + class="gl-mt-5 gl-w-full" + category="primary" + variant="confirm" + :loading="isLoading" + @click="continueWithSelection" + >{{ __('Continue') }}</gl-button + > + </gl-card> + </div> + </div> +</template> diff --git a/ee/app/assets/javascripts/subscriptions/groups/new/index.js b/ee/app/assets/javascripts/subscriptions/groups/new/index.js new file mode 100644 index 0000000000000000000000000000000000000000..84886c853892ff7b6a8f23b17daa8dda733006b3 --- /dev/null +++ b/ee/app/assets/javascripts/subscriptions/groups/new/index.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import App from './components/subscription_group_selector.vue'; + +export default () => { + const el = document.getElementById('js-new-subscription-group'); + + if (!el) return null; + + const { rootUrl } = el.dataset; + const plansData = JSON.parse(el.dataset.plansData); + const eligibleGroups = JSON.parse(el.dataset.eligibleGroups); + + return new Vue({ + el, + components: { + App, + }, + render(createElement) { + return createElement(App, { + props: { + rootUrl, + plansData, + eligibleGroups, + }, + }); + }, + }); +}; diff --git a/ee/app/views/subscriptions/groups/new.html.haml b/ee/app/views/subscriptions/groups/new.html.haml index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0348613be375f636b1bf26414e913225543b6d09 100644 --- a/ee/app/views/subscriptions/groups/new.html.haml +++ b/ee/app/views/subscriptions/groups/new.html.haml @@ -0,0 +1 @@ +#js-new-subscription-group{ data: { root_url: root_url, plans_data: @plan_data.to_json, eligible_groups: present_groups(@eligible_groups).to_json } } diff --git a/ee/spec/frontend/subscriptions/groups/subscription_group_selector_spec.js b/ee/spec/frontend/subscriptions/groups/subscription_group_selector_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0206ca5ead06babd03c0448c0c49f1ab410ba27e --- /dev/null +++ b/ee/spec/frontend/subscriptions/groups/subscription_group_selector_spec.js @@ -0,0 +1,165 @@ +import { nextTick } from 'vue'; +import { + GlAccordion, + GlAccordionItem, + GlCollapsibleListbox, + GlButton, + GlFormGroup, +} from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Component from 'ee/subscriptions/groups/new/components/subscription_group_selector.vue'; +import { stubComponent } from 'helpers/stub_component'; +import { visitUrl } from '~/lib/utils/url_utility'; + +jest.mock('~/sentry/sentry_browser_wrapper'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); + +describe('SubscriptionGroupSelector component', () => { + let wrapper; + + const eligibleGroups = [ + { id: 1, name: 'Group one' }, + { id: 2, name: 'Group two' }, + { id: 3, name: 'Group three' }, + { id: 4, name: 'Group four' }, + ]; + + const plansData = { + code: 'premium', + id: 'premium-plan-id', + purchase_link: { href: 'path/to/purchase?plan_id=premium-plan-id' }, + }; + + const rootUrl = 'https://gitlab.com/'; + + const defaultPropsData = { eligibleGroups, plansData, rootUrl }; + + const findAccordion = () => wrapper.findComponent(GlAccordion); + const findAccordionItem = () => wrapper.findComponent(GlAccordionItem); + const findCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findGroupSelectionFormGroup = () => wrapper.findByTestId('group-selector'); + const findContinueButton = () => wrapper.findComponent(GlButton); + const findHeader = () => wrapper.find('h2'); + + const createComponent = (propsData = {}) => { + wrapper = shallowMountExtended(Component, { + propsData: { + ...defaultPropsData, + ...propsData, + }, + stubs: { + GlFormGroup: stubComponent(GlFormGroup, { + props: ['state', 'invalidFeedback'], + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + describe('title', () => { + it('renders title correctly for premium plan', () => { + expect(findHeader().text()).toBe(`Select a group for your Premium subscription`); + }); + + it('renders title correctly for ultimate plan', () => { + createComponent({ plansData: { ...plansData, code: 'ultimate' } }); + + expect(findHeader().text()).toBe(`Select a group for your Ultimate subscription`); + }); + + it('renders title correctly for other plans', () => { + createComponent({ plansData: { ...plansData, code: 'non-premium', name: 'SaaS' } }); + + expect(findHeader().text()).toBe(`Select a group for your SaaS subscription`); + }); + }); + + describe('group selection', () => { + it('renders collapsible list box with correct options', () => { + const expectedResult = eligibleGroups.map(({ id, name }) => ({ value: id, text: name })); + + expect(findCollapsibleListbox().props().items).toEqual(expectedResult); + }); + + it('renders collapsible list box with correct variant', () => { + expect(findCollapsibleListbox().props('variant')).toBe('default'); + }); + + it('does not show validation message on initial render', () => { + expect(findGroupSelectionFormGroup().props('state')).toBe(true); + }); + + it('shows validation message when no group is selected', async () => { + findContinueButton().vm.$emit('click'); + + await nextTick(); + + expect(findGroupSelectionFormGroup().props('state')).toBe(false); + expect(findGroupSelectionFormGroup().props('invalidFeedback')).toBe( + 'Select a group for your subscription', + ); + expect(findCollapsibleListbox().props('variant')).toBe('danger'); + }); + + it('does not redirect when no group is selected', async () => { + findContinueButton().vm.$emit('click'); + + await nextTick(); + + expect(visitUrl).not.toHaveBeenCalled(); + }); + + it('redirects to purchase flow when a valid group is selected', async () => { + const selectedGroupId = eligibleGroups[2].id; + const expectedUrl = `${plansData.purchase_link.href}&gl_namespace_id=${selectedGroupId}`; + + findCollapsibleListbox().vm.$emit('select', selectedGroupId); + findContinueButton().vm.$emit('click'); + + await nextTick(); + + expect(visitUrl).toHaveBeenCalledWith(expectedUrl); + }); + + it('reports an error when no purchase link URL is provided', async () => { + const plansDataProp = { ...plansData, purchase_link: null }; + const error = `Missing purchase link for plan ${JSON.stringify(plansDataProp)}`; + + createComponent({ plansData: plansDataProp }); + + findCollapsibleListbox().vm.$emit('select', eligibleGroups[2].id); + findContinueButton().vm.$emit('click'); + + await nextTick(); + + expect(visitUrl).not.toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(error, { + tags: { vue_component: 'SubscriptionGroupSelector' }, + }); + }); + }); + + describe('accordion', () => { + it('renders accordion', () => { + expect(findAccordion().props('headerLevel')).toBe(3); + }); + + it('renders accordion item', () => { + const accordionItem = findAccordionItem(); + + expect(accordionItem.props('title')).toBe(`Why can't I find my group?`); + expect(accordionItem.text()).toContain( + `Your group will only be displayed in the list above if:`, + ); + expect(accordionItem.text()).toContain(`You're assigned the Owner role of the group`); + expect(accordionItem.text()).toContain(`The group is a top-level group on a Free tier`); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5714b73b1c1d8350a662d6cb99fd146d63b9034a..a1e410d06fef01305f452165e0b7cae877293a5e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -51719,6 +51719,24 @@ msgstr "" msgid "SubscriptionBanner|Upload new license" msgstr "" +msgid "SubscriptionGroupsNew|Select a group for your %{planName} subscription" +msgstr "" + +msgid "SubscriptionGroupsNew|Select a group for your subscription" +msgstr "" + +msgid "SubscriptionGroupsNew|The group is a top-level group on a Free tier" +msgstr "" + +msgid "SubscriptionGroupsNew|Why can't I find my group?" +msgstr "" + +msgid "SubscriptionGroupsNew|You're assigned the Owner role of the group" +msgstr "" + +msgid "SubscriptionGroupsNew|Your group will only be displayed in the list above if:" +msgstr "" + msgid "SubscriptionMangement|If you'd like to add more seats, upgrade your plan, or purchase additional products, contact your GitLab sales representative." msgstr ""