From f86eeac52292f32adcd082ec128dbc4187756b99 Mon Sep 17 00:00:00 2001 From: Alexandre Bourdin Date: Wed, 22 Feb 2023 13:10:02 +0100 Subject: [PATCH] Merge pull request #11862 from overleaf/ab-group-subscription-memberships-dash-react [web] Migrate group subscription memberships to React dash GitOrigin-RevId: d5ff3ae4e5d8c422530502af22edda6c24c9a593 --- .../views/subscriptions/dashboard-react.pug | 1 + .../web/frontend/extracted-translations.json | 4 + .../group-subscription-membership.tsx | 54 +++++++ .../group-subscription-memberships.tsx | 32 ++++ .../dashboard/leave-group-modal.tsx | 71 +++++++++ .../dashboard/subscription-dashboard.tsx | 2 + .../subscription-dashboard-context.tsx | 36 ++++- .../js/main/subscription-dashboard.js | 2 +- .../group-subscription-memberships.test.tsx | 142 ++++++++++++++++++ .../types/subscription/dashboard/modal-ids.ts | 1 + .../subscription/dashboard/subscription.ts | 12 +- 11 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 services/web/frontend/js/features/subscription/components/dashboard/group-subscription-membership.tsx create mode 100644 services/web/frontend/js/features/subscription/components/dashboard/group-subscription-memberships.tsx create mode 100644 services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx create mode 100644 services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug index 08fb6479db..3b21ea8584 100644 --- a/services/web/app/views/subscriptions/dashboard-react.pug +++ b/services/web/app/views/subscriptions/dashboard-react.pug @@ -8,6 +8,7 @@ block head-scripts block append meta meta(name="ol-managedGroupSubscriptions", data-type="json" content=managedGroupSubscriptions) + meta(name="ol-memberGroupSubscriptions", data-type="json" content=memberGroupSubscriptions) meta(name="ol-managedInstitutions", data-type="json", content=managedInstitutions) meta(name="ol-managedPublishers", data-type="json" content=managedPublishers) meta(name="ol-planCodesChangingAtTermEnd", data-type="json", content=planCodesChangingAtTermEnd) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index a073ec4273..f554ad0d40 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -410,6 +410,8 @@ "learn_more": "", "learn_more_about_link_sharing": "", "leave": "", + "leave_group": "", + "leave_now": "", "leave_projects": "", "let_us_know": "", "license_for_educational_purposes": "", @@ -759,6 +761,7 @@ "sure_you_want_to_cancel_plan_change": "", "sure_you_want_to_change_plan": "", "sure_you_want_to_delete": "", + "sure_you_want_to_leave_group": "", "switch_to_editor": "", "switch_to_pdf": "", "symbol_palette": "", @@ -909,6 +912,7 @@ "you_are_a_manager_of_publisher_x": "", "you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "", "you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "", + "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "", "you_can_now_log_in_sso": "", "you_dont_have_any_repositories": "", "you_have_added_x_of_group_size_y": "", diff --git a/services/web/frontend/js/features/subscription/components/dashboard/group-subscription-membership.tsx b/services/web/frontend/js/features/subscription/components/dashboard/group-subscription-membership.tsx new file mode 100644 index 0000000000..fc7b461a80 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/dashboard/group-subscription-membership.tsx @@ -0,0 +1,54 @@ +import { Button } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import { MemberGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription' +import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' +import { LEAVE_GROUP_MODAL_ID } from './leave-group-modal' +import PremiumFeaturesLink from './premium-features-link' + +type GroupSubscriptionMembershipProps = { + subscription: MemberGroupSubscription + isLast: boolean +} + +export default function GroupSubscriptionMembership({ + subscription, + isLast, +}: GroupSubscriptionMembershipProps) { + const { t } = useTranslation() + const { handleOpenModal, setLeavingGroupId } = + useSubscriptionDashboardContext() + + const leaveGroup = () => { + handleOpenModal(LEAVE_GROUP_MODAL_ID) + setLeavingGroupId(subscription._id) + } + + return ( +
+

+ , ]} // eslint-disable-line react/jsx-key, jsx-a11y/anchor-has-content + values={{ + planName: subscription.planLevelName, + groupName: subscription.teamName || '', + adminEmail: subscription.admin_id.email, + }} + /> +

+ {subscription.teamNotice && ( +

+ {/* Team notice is sanitized in SubscriptionViewModelBuilder */} + {subscription.teamNotice} +

+ )} + {isLast && } + + + +
+
+ ) +} diff --git a/services/web/frontend/js/features/subscription/components/dashboard/group-subscription-memberships.tsx b/services/web/frontend/js/features/subscription/components/dashboard/group-subscription-memberships.tsx new file mode 100644 index 0000000000..8b42636ddc --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/dashboard/group-subscription-memberships.tsx @@ -0,0 +1,32 @@ +import { MemberGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription' +import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' +import GroupSubscriptionMembership from './group-subscription-membership' +import LeaveGroupModal from './leave-group-modal' + +export default function GroupSubscriptionMemberships() { + const { memberGroupSubscriptions } = useSubscriptionDashboardContext() + + if (!memberGroupSubscriptions) { + return null + } + + const memberOnlyGroupSubscriptions = memberGroupSubscriptions.filter( + subscription => !subscription.userIsGroupManager + ) + + return ( + <> + {memberOnlyGroupSubscriptions.map( + (subscription: MemberGroupSubscription, index: number) => ( + + ) + )} + + + + ) +} diff --git a/services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx new file mode 100644 index 0000000000..b15b426434 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx @@ -0,0 +1,71 @@ +import { useCallback, useState } from 'react' +import { Button, Modal } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { deleteJSON } from '../../../../infrastructure/fetch-json' +import AccessibleModal from '../../../../shared/components/accessible-modal' +import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' + +export const LEAVE_GROUP_MODAL_ID = 'leave-group' + +export default function LeaveGroupModal() { + const { t } = useTranslation() + const { handleCloseModal, modalIdShown, leavingGroupId } = + useSubscriptionDashboardContext() + const [inflight, setInflight] = useState(false) + + const handleConfirmLeaveGroup = useCallback(async () => { + if (!leavingGroupId) { + return + } + setInflight(true) + try { + const params = new URLSearchParams() + params.set('subscriptionId', leavingGroupId) + await deleteJSON(`/subscription/group/user?${params}`) + window.location.reload() + } catch (error) { + console.log('something went wrong', error) + setInflight(false) + } + }, [leavingGroupId]) + + if (modalIdShown !== LEAVE_GROUP_MODAL_ID || !leavingGroupId) { + return null + } + + return ( + + + {t('leave_group')} + + + +

{t('sure_you_want_to_leave_group')}

+
+ + + + + +
+ ) +} diff --git a/services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx b/services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx index bb6994731d..72c44388ff 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next' +import GroupSubscriptionMemberships from './group-subscription-memberships' import InstitutionMemberships from './institution-memberships' import FreePlan from './free-plan' import ManagedPublishers from './managed-publishers' @@ -24,6 +25,7 @@ function SubscriptionDashboard() { + {!hasDisplayedSubscription && } diff --git a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx index 213bda972f..800bf4e1cd 100644 --- a/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx +++ b/services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx @@ -9,6 +9,7 @@ import { } from 'react' import { ManagedGroupSubscription, + MemberGroupSubscription, Subscription, } from '../../../../../types/subscription/dashboard/subscription' import { @@ -40,6 +41,7 @@ type SubscriptionDashboardContextValue = { hasDisplayedSubscription: boolean institutionMemberships?: Institution[] managedGroupSubscriptions: ManagedGroupSubscription[] + memberGroupSubscriptions: MemberGroupSubscription[] managedInstitutions: ManagedInstitution[] managedPublishers: ManagedPublisher[] updateManagedInstitution: (institution: ManagedInstitution) => void @@ -64,6 +66,8 @@ type SubscriptionDashboardContextValue = { setShowCancellation: React.Dispatch> showChangePersonalPlan: boolean setShowChangePersonalPlan: React.Dispatch> + leavingGroupId?: string + setLeavingGroupId: React.Dispatch> } export const SubscriptionDashboardContext = createContext< @@ -100,23 +104,33 @@ export function SubscriptionDashboardProvider({ useState() const [groupPlanToChangeToPriceError, setGroupPlanToChangeToPriceError] = useState(false) + const [leavingGroupId, setLeavingGroupId] = useState() const plansWithoutDisplayPrice = getMeta('ol-plans') - const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence') - const personalSubscription = getMeta('ol-subscription') - const managedGroupSubscriptions = getMeta('ol-managedGroupSubscriptions') + const institutionMemberships: Institution[] = getMeta( + 'ol-currentInstitutionsWithLicence' + ) + const personalSubscription: Subscription = getMeta('ol-subscription') + const managedGroupSubscriptions: ManagedGroupSubscription[] = getMeta( + 'ol-managedGroupSubscriptions' + ) + const memberGroupSubscriptions: MemberGroupSubscription[] = getMeta( + 'ol-memberGroupSubscriptions' + ) const [managedInstitutions, setManagedInstitutions] = useState< ManagedInstitution[] >(getMeta('ol-managedInstitutions')) const managedPublishers = getMeta('ol-managedPublishers') const recurlyApiKey = getMeta('ol-recurlyApiKey') - const hasDisplayedSubscription = + const hasDisplayedSubscription = Boolean( institutionMemberships?.length > 0 || - personalSubscription || - managedGroupSubscriptions?.length > 0 || - managedInstitutions?.length > 0 || - managedPublishers?.length > 0 + personalSubscription || + memberGroupSubscriptions?.length > 0 || + managedGroupSubscriptions?.length > 0 || + managedInstitutions?.length > 0 || + managedPublishers?.length > 0 + ) useEffect(() => { if (!isRecurlyLoaded()) { @@ -227,6 +241,7 @@ export function SubscriptionDashboardProvider({ hasDisplayedSubscription, institutionMemberships, managedGroupSubscriptions, + memberGroupSubscriptions, managedInstitutions, managedPublishers, updateManagedInstitution, @@ -247,6 +262,8 @@ export function SubscriptionDashboardProvider({ setShowCancellation, showChangePersonalPlan, setShowChangePersonalPlan, + leavingGroupId, + setLeavingGroupId, }), [ groupPlanToChangeToCode, @@ -259,6 +276,7 @@ export function SubscriptionDashboardProvider({ hasDisplayedSubscription, institutionMemberships, managedGroupSubscriptions, + memberGroupSubscriptions, managedInstitutions, managedPublishers, updateManagedInstitution, @@ -279,6 +297,8 @@ export function SubscriptionDashboardProvider({ setShowCancellation, showChangePersonalPlan, setShowChangePersonalPlan, + leavingGroupId, + setLeavingGroupId, ] ) diff --git a/services/web/frontend/js/main/subscription-dashboard.js b/services/web/frontend/js/main/subscription-dashboard.js index 921ed1f9e6..f3f7272c32 100644 --- a/services/web/frontend/js/main/subscription-dashboard.js +++ b/services/web/frontend/js/main/subscription-dashboard.js @@ -335,7 +335,7 @@ App.controller( _csrf: window.csrfToken, }, }) - .then(() => location.reload()) + .then(() => window.location.reload()) .catch(() => console.log('something went wrong changing plan')) } diff --git a/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx new file mode 100644 index 0000000000..b4370db780 --- /dev/null +++ b/services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx @@ -0,0 +1,142 @@ +import { expect } from 'chai' +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import sinon from 'sinon' +import GroupSubscriptionMemberships from '../../../../../../frontend/js/features/subscription/components/dashboard/group-subscription-memberships' +import { SubscriptionDashboardProvider } from '../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context' +import fetchMock from 'fetch-mock' +import { MemberGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription' +import { + groupActiveSubscription, + groupActiveSubscriptionWithPendingLicenseChange, +} from '../../fixtures/subscriptions' + +const userId = 'fff999fff999' +const memberGroupSubscriptions: MemberGroupSubscription[] = [ + { + ...groupActiveSubscription, + userIsGroupManager: false, + planLevelName: 'Professional', + admin_id: { + id: 'abc123abc123', + email: 'you@example.com', + }, + }, + { + ...groupActiveSubscriptionWithPendingLicenseChange, + userIsGroupManager: true, + planLevelName: 'Collaborator', + admin_id: { + id: 'bcd456bcd456', + email: 'someone@example.com', + }, + }, +] + +describe('', function () { + beforeEach(function () { + window.metaAttributesCache = new Map() + window.metaAttributesCache.set( + 'ol-memberGroupSubscriptions', + memberGroupSubscriptions + ) + window.user_id = userId + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + delete window.user_id + fetchMock.reset() + }) + + it('renders all group subscriptions not managed', function () { + render( + + + + ) + + const elements = screen.getAllByText('You are on our', { + exact: false, + }) + expect(elements.length).to.equal(1) + expect(elements[0].textContent).to.equal( + 'You are on our Professional plan as a member of the group subscription GAS administered by you@example.com' + ) + }) + + describe('opens leave group modal when button is clicked', function () { + let reloadStub: () => void + const originalLocation = window.location + + beforeEach(function () { + reloadStub = sinon.stub() + Object.defineProperty(window, 'location', { + value: { reload: reloadStub }, + }) + + render( + + + + ) + + const leaveGroupButton = screen.getByText('Leave group') + fireEvent.click(leaveGroupButton) + + this.confirmModal = screen.getByRole('dialog') + within(this.confirmModal).getByText( + 'Are you sure you want to leave this group?' + ) + + this.cancelButton = within(this.confirmModal).getByText('Cancel') + this.leaveNowButton = within(this.confirmModal).getByText('Leave now') + }) + + afterEach(function () { + Object.defineProperty(window, 'location', { + value: originalLocation, + }) + }) + + it('close the modal', function () { + fireEvent.click(this.cancelButton) + expect(screen.queryByRole('dialog')).to.not.exist + }) + + it('leave the group', async function () { + const leaveGroupApiMock = fetchMock.delete( + `/subscription/group/user?subscriptionId=bcd567`, + { + status: 204, + } + ) + + fireEvent.click(this.leaveNowButton) + + expect(leaveGroupApiMock.called()).to.be.true + await waitFor(() => { + expect(reloadStub).to.have.been.called + }) + }) + }) + + it('renders nothing when there are no group subscriptions', function () { + window.metaAttributesCache.set('ol-memberGroupSubscriptions', undefined) + + render( + + + + ) + const elements = screen.queryAllByText('You are on our', { + exact: false, + }) + expect(elements.length).to.equal(0) + }) +}) diff --git a/services/web/types/subscription/dashboard/modal-ids.ts b/services/web/types/subscription/dashboard/modal-ids.ts index cfe06b7ebd..0c49515501 100644 --- a/services/web/types/subscription/dashboard/modal-ids.ts +++ b/services/web/types/subscription/dashboard/modal-ids.ts @@ -2,3 +2,4 @@ export type SubscriptionDashModalIds = | 'change-to-plan' | 'change-to-group' | 'keep-current-plan' + | 'leave-group' diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index ea6e63c09e..d10ab0953d 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -1,3 +1,4 @@ +import { CurrencyCode } from '../../../frontend/js/features/subscription/data/currency' import { Nullable } from '../../utils' import { Plan } from '../plan' import { User } from '../../../types/user' @@ -24,7 +25,7 @@ export type Subscription = { additionalLicenses: number totalLicenses: number nextPaymentDueAt: string - currency: string + currency: CurrencyCode state?: SubscriptionState trialEndsAtFormatted: Nullable trial_ends_at: Nullable @@ -53,7 +54,8 @@ export type Subscription = { } export type GroupSubscription = Subscription & { - teamName: string + teamName?: string + teamNotice?: string } export type ManagedGroupSubscription = Omit & { @@ -61,3 +63,9 @@ export type ManagedGroupSubscription = Omit & { planLevelName: string admin_id: User } + +export type MemberGroupSubscription = Omit & { + userIsGroupManager: boolean + planLevelName: string + admin_id: User +}