Просмотр исходного кода

Merge pull request #11862 from overleaf/ab-group-subscription-memberships-dash-react

[web] Migrate group subscription memberships to React dash

GitOrigin-RevId: d5ff3ae4e5d8c422530502af22edda6c24c9a593
Alexandre Bourdin 3 лет назад
Родитель
Сommit
f86eeac522

+ 1 - 0
services/web/app/views/subscriptions/dashboard-react.pug

@@ -8,6 +8,7 @@ block head-scripts
 
 
 block append meta
 block append meta
 	meta(name="ol-managedGroupSubscriptions", data-type="json" content=managedGroupSubscriptions)
 	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-managedInstitutions", data-type="json", content=managedInstitutions)
 	meta(name="ol-managedPublishers", data-type="json" content=managedPublishers)
 	meta(name="ol-managedPublishers", data-type="json" content=managedPublishers)
 	meta(name="ol-planCodesChangingAtTermEnd", data-type="json", content=planCodesChangingAtTermEnd)
 	meta(name="ol-planCodesChangingAtTermEnd", data-type="json", content=planCodesChangingAtTermEnd)

+ 4 - 0
services/web/frontend/extracted-translations.json

@@ -410,6 +410,8 @@
   "learn_more": "",
   "learn_more": "",
   "learn_more_about_link_sharing": "",
   "learn_more_about_link_sharing": "",
   "leave": "",
   "leave": "",
+  "leave_group": "",
+  "leave_now": "",
   "leave_projects": "",
   "leave_projects": "",
   "let_us_know": "",
   "let_us_know": "",
   "license_for_educational_purposes": "",
   "license_for_educational_purposes": "",
@@ -759,6 +761,7 @@
   "sure_you_want_to_cancel_plan_change": "",
   "sure_you_want_to_cancel_plan_change": "",
   "sure_you_want_to_change_plan": "",
   "sure_you_want_to_change_plan": "",
   "sure_you_want_to_delete": "",
   "sure_you_want_to_delete": "",
+  "sure_you_want_to_leave_group": "",
   "switch_to_editor": "",
   "switch_to_editor": "",
   "switch_to_pdf": "",
   "switch_to_pdf": "",
   "symbol_palette": "",
   "symbol_palette": "",
@@ -909,6 +912,7 @@
   "you_are_a_manager_of_publisher_x": "",
   "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_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_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_can_now_log_in_sso": "",
   "you_dont_have_any_repositories": "",
   "you_dont_have_any_repositories": "",
   "you_have_added_x_of_group_size_y": "",
   "you_have_added_x_of_group_size_y": "",

+ 54 - 0
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 (
+    <div>
+      <p>
+        <Trans
+          i18nKey="you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z"
+          components={[<a href="/user/subscription/plans" />, <strong />]} // eslint-disable-line react/jsx-key, jsx-a11y/anchor-has-content
+          values={{
+            planName: subscription.planLevelName,
+            groupName: subscription.teamName || '',
+            adminEmail: subscription.admin_id.email,
+          }}
+        />
+      </p>
+      {subscription.teamNotice && (
+        <p>
+          {/* Team notice is sanitized in SubscriptionViewModelBuilder */}
+          <em>{subscription.teamNotice}</em>
+        </p>
+      )}
+      {isLast && <PremiumFeaturesLink />}
+      <span>
+        <Button bsStyle="danger" onClick={leaveGroup}>
+          {t('leave_group')}
+        </Button>
+      </span>
+      <hr />
+    </div>
+  )
+}

+ 32 - 0
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) => (
+          <GroupSubscriptionMembership
+            subscription={subscription}
+            isLast={index === memberOnlyGroupSubscriptions.length - 1}
+            key={subscription._id}
+          />
+        )
+      )}
+
+      <LeaveGroupModal />
+    </>
+  )
+}

+ 71 - 0
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 (
+    <AccessibleModal
+      id={LEAVE_GROUP_MODAL_ID}
+      show
+      animation
+      onHide={handleCloseModal}
+      backdrop="static"
+    >
+      <Modal.Header>
+        <Modal.Title>{t('leave_group')}</Modal.Title>
+      </Modal.Header>
+
+      <Modal.Body>
+        <p>{t('sure_you_want_to_leave_group')}</p>
+      </Modal.Body>
+
+      <Modal.Footer>
+        <Button
+          bsStyle={null}
+          className="btn-secondary"
+          onClick={handleCloseModal}
+          disabled={inflight}
+        >
+          {t('cancel')}
+        </Button>
+        <Button
+          bsStyle="danger"
+          onClick={handleConfirmLeaveGroup}
+          disabled={inflight}
+        >
+          {inflight ? t('processing_uppercase') + '…' : t('leave_now')}
+        </Button>
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}

+ 2 - 0
services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx

@@ -1,4 +1,5 @@
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
+import GroupSubscriptionMemberships from './group-subscription-memberships'
 import InstitutionMemberships from './institution-memberships'
 import InstitutionMemberships from './institution-memberships'
 import FreePlan from './free-plan'
 import FreePlan from './free-plan'
 import ManagedPublishers from './managed-publishers'
 import ManagedPublishers from './managed-publishers'
@@ -24,6 +25,7 @@ function SubscriptionDashboard() {
             <ManagedGroupSubscriptions />
             <ManagedGroupSubscriptions />
             <ManagedInstitutions />
             <ManagedInstitutions />
             <ManagedPublishers />
             <ManagedPublishers />
+            <GroupSubscriptionMemberships />
             <InstitutionMemberships />
             <InstitutionMemberships />
             {!hasDisplayedSubscription && <FreePlan />}
             {!hasDisplayedSubscription && <FreePlan />}
           </div>
           </div>

+ 28 - 8
services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx

@@ -9,6 +9,7 @@ import {
 } from 'react'
 } from 'react'
 import {
 import {
   ManagedGroupSubscription,
   ManagedGroupSubscription,
+  MemberGroupSubscription,
   Subscription,
   Subscription,
 } from '../../../../../types/subscription/dashboard/subscription'
 } from '../../../../../types/subscription/dashboard/subscription'
 import {
 import {
@@ -40,6 +41,7 @@ type SubscriptionDashboardContextValue = {
   hasDisplayedSubscription: boolean
   hasDisplayedSubscription: boolean
   institutionMemberships?: Institution[]
   institutionMemberships?: Institution[]
   managedGroupSubscriptions: ManagedGroupSubscription[]
   managedGroupSubscriptions: ManagedGroupSubscription[]
+  memberGroupSubscriptions: MemberGroupSubscription[]
   managedInstitutions: ManagedInstitution[]
   managedInstitutions: ManagedInstitution[]
   managedPublishers: ManagedPublisher[]
   managedPublishers: ManagedPublisher[]
   updateManagedInstitution: (institution: ManagedInstitution) => void
   updateManagedInstitution: (institution: ManagedInstitution) => void
@@ -64,6 +66,8 @@ type SubscriptionDashboardContextValue = {
   setShowCancellation: React.Dispatch<React.SetStateAction<boolean>>
   setShowCancellation: React.Dispatch<React.SetStateAction<boolean>>
   showChangePersonalPlan: boolean
   showChangePersonalPlan: boolean
   setShowChangePersonalPlan: React.Dispatch<React.SetStateAction<boolean>>
   setShowChangePersonalPlan: React.Dispatch<React.SetStateAction<boolean>>
+  leavingGroupId?: string
+  setLeavingGroupId: React.Dispatch<React.SetStateAction<string | undefined>>
 }
 }
 
 
 export const SubscriptionDashboardContext = createContext<
 export const SubscriptionDashboardContext = createContext<
@@ -100,23 +104,33 @@ export function SubscriptionDashboardProvider({
     useState<PriceForDisplayData>()
     useState<PriceForDisplayData>()
   const [groupPlanToChangeToPriceError, setGroupPlanToChangeToPriceError] =
   const [groupPlanToChangeToPriceError, setGroupPlanToChangeToPriceError] =
     useState(false)
     useState(false)
+  const [leavingGroupId, setLeavingGroupId] = useState<string | undefined>()
 
 
   const plansWithoutDisplayPrice = getMeta('ol-plans')
   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<
   const [managedInstitutions, setManagedInstitutions] = useState<
     ManagedInstitution[]
     ManagedInstitution[]
   >(getMeta('ol-managedInstitutions'))
   >(getMeta('ol-managedInstitutions'))
   const managedPublishers = getMeta('ol-managedPublishers')
   const managedPublishers = getMeta('ol-managedPublishers')
   const recurlyApiKey = getMeta('ol-recurlyApiKey')
   const recurlyApiKey = getMeta('ol-recurlyApiKey')
 
 
-  const hasDisplayedSubscription =
+  const hasDisplayedSubscription = Boolean(
     institutionMemberships?.length > 0 ||
     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(() => {
   useEffect(() => {
     if (!isRecurlyLoaded()) {
     if (!isRecurlyLoaded()) {
@@ -227,6 +241,7 @@ export function SubscriptionDashboardProvider({
       hasDisplayedSubscription,
       hasDisplayedSubscription,
       institutionMemberships,
       institutionMemberships,
       managedGroupSubscriptions,
       managedGroupSubscriptions,
+      memberGroupSubscriptions,
       managedInstitutions,
       managedInstitutions,
       managedPublishers,
       managedPublishers,
       updateManagedInstitution,
       updateManagedInstitution,
@@ -247,6 +262,8 @@ export function SubscriptionDashboardProvider({
       setShowCancellation,
       setShowCancellation,
       showChangePersonalPlan,
       showChangePersonalPlan,
       setShowChangePersonalPlan,
       setShowChangePersonalPlan,
+      leavingGroupId,
+      setLeavingGroupId,
     }),
     }),
     [
     [
       groupPlanToChangeToCode,
       groupPlanToChangeToCode,
@@ -259,6 +276,7 @@ export function SubscriptionDashboardProvider({
       hasDisplayedSubscription,
       hasDisplayedSubscription,
       institutionMemberships,
       institutionMemberships,
       managedGroupSubscriptions,
       managedGroupSubscriptions,
+      memberGroupSubscriptions,
       managedInstitutions,
       managedInstitutions,
       managedPublishers,
       managedPublishers,
       updateManagedInstitution,
       updateManagedInstitution,
@@ -279,6 +297,8 @@ export function SubscriptionDashboardProvider({
       setShowCancellation,
       setShowCancellation,
       showChangePersonalPlan,
       showChangePersonalPlan,
       setShowChangePersonalPlan,
       setShowChangePersonalPlan,
+      leavingGroupId,
+      setLeavingGroupId,
     ]
     ]
   )
   )
 
 

+ 1 - 1
services/web/frontend/js/main/subscription-dashboard.js

@@ -335,7 +335,7 @@ App.controller(
           _csrf: window.csrfToken,
           _csrf: window.csrfToken,
         },
         },
       })
       })
-        .then(() => location.reload())
+        .then(() => window.location.reload())
         .catch(() => console.log('something went wrong changing plan'))
         .catch(() => console.log('something went wrong changing plan'))
     }
     }
 
 

+ 142 - 0
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('<GroupSubscriptionMemberships />', 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(
+      <SubscriptionDashboardProvider>
+        <GroupSubscriptionMemberships />
+      </SubscriptionDashboardProvider>
+    )
+
+    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(
+        <SubscriptionDashboardProvider>
+          <GroupSubscriptionMemberships />
+        </SubscriptionDashboardProvider>
+      )
+
+      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(
+      <SubscriptionDashboardProvider>
+        <GroupSubscriptionMemberships />
+      </SubscriptionDashboardProvider>
+    )
+    const elements = screen.queryAllByText('You are on our', {
+      exact: false,
+    })
+    expect(elements.length).to.equal(0)
+  })
+})

+ 1 - 0
services/web/types/subscription/dashboard/modal-ids.ts

@@ -2,3 +2,4 @@ export type SubscriptionDashModalIds =
   | 'change-to-plan'
   | 'change-to-plan'
   | 'change-to-group'
   | 'change-to-group'
   | 'keep-current-plan'
   | 'keep-current-plan'
+  | 'leave-group'

+ 10 - 2
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 { Nullable } from '../../utils'
 import { Plan } from '../plan'
 import { Plan } from '../plan'
 import { User } from '../../../types/user'
 import { User } from '../../../types/user'
@@ -24,7 +25,7 @@ export type Subscription = {
     additionalLicenses: number
     additionalLicenses: number
     totalLicenses: number
     totalLicenses: number
     nextPaymentDueAt: string
     nextPaymentDueAt: string
-    currency: string
+    currency: CurrencyCode
     state?: SubscriptionState
     state?: SubscriptionState
     trialEndsAtFormatted: Nullable<string>
     trialEndsAtFormatted: Nullable<string>
     trial_ends_at: Nullable<string>
     trial_ends_at: Nullable<string>
@@ -53,7 +54,8 @@ export type Subscription = {
 }
 }
 
 
 export type GroupSubscription = Subscription & {
 export type GroupSubscription = Subscription & {
-  teamName: string
+  teamName?: string
+  teamNotice?: string
 }
 }
 
 
 export type ManagedGroupSubscription = Omit<GroupSubscription, 'admin_id'> & {
 export type ManagedGroupSubscription = Omit<GroupSubscription, 'admin_id'> & {
@@ -61,3 +63,9 @@ export type ManagedGroupSubscription = Omit<GroupSubscription, 'admin_id'> & {
   planLevelName: string
   planLevelName: string
   admin_id: User
   admin_id: User
 }
 }
+
+export type MemberGroupSubscription = Omit<GroupSubscription, 'admin_id'> & {
+  userIsGroupManager: boolean
+  planLevelName: string
+  admin_id: User
+}