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

Merge pull request #11509 from overleaf/jel-react-personal-subscription-dash-pt-4

[web] Continue migration of personal subscription dash to React

GitOrigin-RevId: f2d913099d727725f04697003516c90616faf014
Jessica Lawshe 3 лет назад
Родитель
Сommit
c36b872ae3
20 измененных файлов с 664 добавлено и 113 удалено
  1. 6 4
      services/web/app/src/Features/Subscription/SubscriptionController.js
  2. 27 0
      services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
  3. 2 1
      services/web/app/views/subscriptions/dashboard-react.pug
  4. 8 0
      services/web/frontend/extracted-translations.json
  5. 6 7
      services/web/frontend/js/features/subscription/components/dashboard/institution-memberships.tsx
  6. 5 17
      services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx
  7. 7 10
      services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx
  8. 19 25
      services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx
  9. 104 5
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan.tsx
  10. 15 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/contact-support-to-change-group-plan.tsx
  11. 28 0
      services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-additional-licenses.tsx
  12. 5 14
      services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx
  13. 32 0
      services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx
  14. 26 3
      services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx
  15. 15 5
      services/web/test/frontend/features/subscription/components/dashboard/managed-group-subscriptions.test.tsx
  16. 30 8
      services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
  17. 36 14
      services/web/test/frontend/features/subscription/components/dashboard/states/active.test.tsx
  18. 71 0
      services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan.test.tsx
  19. 215 0
      services/web/test/frontend/features/subscription/fixtures/plans.tsx
  20. 7 0
      services/web/types/subscription/dashboard/subscription.ts

+ 6 - 4
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -311,9 +311,10 @@ async function _userSubscriptionReactPage(req, res) {
   const hasSubscription =
     await LimitationsManager.promises.userHasV1OrV2Subscription(user)
   const fromPlansPage = req.query.hasSubscription
-  const plans = SubscriptionViewModelBuilder.buildPlansList(
-    personalSubscription ? personalSubscription.plan : undefined
-  )
+  const plansData =
+    SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash(
+      personalSubscription?.plan
+    )
 
   AnalyticsManager.recordEventForSession(req.session, 'subscription-page-view')
 
@@ -327,7 +328,8 @@ async function _userSubscriptionReactPage(req, res) {
 
   const data = {
     title: 'your_subscription',
-    plans,
+    plans: plansData?.plans,
+    planCodesChangingAtTermEnd: plansData?.planCodesChangingAtTermEnd,
     groupPlans: GroupPlansData,
     user,
     hasSubscription,

+ 27 - 0
services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js

@@ -531,9 +531,36 @@ function buildGroupSubscriptionForView(groupSubscription) {
   }
 }
 
+function buildPlansListForSubscriptionDash(currentPlan) {
+  const allPlansData = buildPlansList(currentPlan)
+  const plans = []
+  // only list individual and visible plans for "change plans" UI
+  if (allPlansData.studentAccounts) {
+    plans.push(
+      ...allPlansData.studentAccounts.filter(plan => !plan.hideFromUsers)
+    )
+  }
+  if (allPlansData.individualMonthlyPlans) {
+    plans.push(
+      ...allPlansData.individualMonthlyPlans.filter(plan => !plan.hideFromUsers)
+    )
+  }
+  if (allPlansData.individualAnnualPlans) {
+    plans.push(
+      ...allPlansData.individualAnnualPlans.filter(plan => !plan.hideFromUsers)
+    )
+  }
+
+  return {
+    plans,
+    planCodesChangingAtTermEnd: allPlansData.planCodesChangingAtTermEnd,
+  }
+}
+
 module.exports = {
   buildUsersSubscriptionViewModel,
   buildPlansList,
+  buildPlansListForSubscriptionDash,
   getBestSubscription: callbackify(getBestSubscription),
   promises: {
     buildUsersSubscriptionViewModel: promisify(buildUsersSubscriptionViewModel),

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

@@ -9,8 +9,9 @@ block head-scripts
 block append meta
 	meta(name="ol-managedInstitutions", data-type="json", content=managedInstitutions)
 	meta(name="ol-managedGroupSubscriptions", data-type="json" content=managedGroupSubscriptions)
-	meta(name="ol-planCodesChangingAtTermEnd", data-type="json", content=plans.planCodesChangingAtTermEnd)
+	meta(name="ol-planCodesChangingAtTermEnd", data-type="json", content=planCodesChangingAtTermEnd)
 	meta(name="ol-currentInstitutionsWithLicence", data-type="json" content=currentInstitutionsWithLicence)
+	meta(name="ol-plans", data-type="json" content=plans)
 	if (personalSubscription && personalSubscription.recurly)
 		meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey)
 		meta(name="ol-subscription" data-type="json" content=personalSubscription)

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

@@ -92,6 +92,7 @@
   "change_plan": "",
   "change_primary_email_address_instructions": "",
   "change_project_owner": "",
+  "change_to_this_plan": "",
   "chat": "",
   "chat_error": "",
   "checking_dropbox_status": "",
@@ -128,6 +129,7 @@
   "connected_users": "",
   "contact_message_label": "",
   "contact_sales": "",
+  "contact_support_to_change_group_subscription": "",
   "contact_us": "",
   "continue_github_merge": "",
   "copy": "",
@@ -374,6 +376,7 @@
   "is_email_affiliated": "",
   "join_project": "",
   "joining": "",
+  "keep_current_plan": "",
   "keybindings": "",
   "labs_program_already_participating": "",
   "labs_program_benefits": "<0></0>",
@@ -450,6 +453,7 @@
   "mendeley_reference_loading_error_forbidden": "",
   "mendeley_sync_description": "",
   "menu": "",
+  "month": "",
   "more": "",
   "n_items": "",
   "n_items_plural": "",
@@ -541,6 +545,7 @@
   "premium_feature": "",
   "premium_plan_label": "",
   "press_shortcut_to_open_advanced_reference_search": "",
+  "price": "",
   "priority_support": "",
   "privacy_policy": "",
   "private": "",
@@ -825,6 +830,7 @@
   "word_count": "",
   "work_offline": "",
   "work_with_non_overleaf_users": "",
+  "year": "",
   "you_are_a_manager_and_member_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": "",
@@ -834,6 +840,8 @@
   "your_affiliation_is_confirmed": "",
   "your_browser_does_not_support_this_feature": "",
   "your_message_to_collaborators": "",
+  "your_new_plan": "",
+  "your_plan": "",
   "your_plan_is_changing_at_term_end": "",
   "your_projects": "",
   "your_subscription": "",

+ 6 - 7
services/web/frontend/js/features/subscription/components/dashboard/institution-memberships.tsx

@@ -1,15 +1,14 @@
 import { Trans } from 'react-i18next'
 import { Institution } from '../../../../../../types/institution'
+import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 import PremiumFeaturesLink from './premium-features-link'
 
-type InstitutionMembershipsProps = {
-  memberships?: Array<Institution>
-}
+function InstitutionMemberships() {
+  const { institutionMemberships } = useSubscriptionDashboardContext()
 
-function InstitutionMemberships({ memberships }: InstitutionMembershipsProps) {
   // memberships is undefined when data failed to load. If user has no memberships, then an empty array is returned
 
-  if (!memberships) {
+  if (!institutionMemberships) {
     return (
       <div className="alert alert-warning">
         <p>
@@ -24,7 +23,7 @@ function InstitutionMemberships({ memberships }: InstitutionMembershipsProps) {
   return (
     <>
       <div>
-        {memberships.map((institution: Institution) => (
+        {institutionMemberships.map((institution: Institution) => (
           <div key={`${institution.id}`}>
             <Trans
               i18nKey="you_are_on_x_plan_as_a_confirmed_member_of_institution_y"
@@ -44,7 +43,7 @@ function InstitutionMemberships({ memberships }: InstitutionMembershipsProps) {
             <hr />
           </div>
         ))}
-        {memberships.length > 0 && <PremiumFeaturesLink />}
+        {institutionMemberships.length > 0 && <PremiumFeaturesLink />}
       </div>
     </>
   )

+ 5 - 17
services/web/frontend/js/features/subscription/components/dashboard/managed-group-subscriptions.tsx

@@ -1,29 +1,17 @@
 import { Trans, useTranslation } from 'react-i18next'
-import { GroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
-import { User } from '../../../../../../types/user'
+import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 
-export type ManagedGroupSubscription = Omit<GroupSubscription, 'admin_id'> & {
-  userIsGroupMember: boolean
-  planLevelName: string
-  admin_id: User
-}
-
-type ManagedGroupSubscriptionsProps = {
-  subscriptions?: ManagedGroupSubscription[]
-}
-
-export default function ManagedGroupSubscriptions({
-  subscriptions,
-}: ManagedGroupSubscriptionsProps) {
+export default function ManagedGroupSubscriptions() {
   const { t } = useTranslation()
+  const { managedGroupSubscriptions } = useSubscriptionDashboardContext()
 
-  if (!subscriptions) {
+  if (!managedGroupSubscriptions) {
     return null
   }
 
   return (
     <>
-      {subscriptions.map(subscription => (
+      {managedGroupSubscriptions.map(subscription => (
         <div key={`managed-group-${subscription._id}`}>
           <p>
             {subscription.userIsGroupMember ? (

+ 7 - 10
services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx

@@ -47,13 +47,9 @@ function PersonalSubscriptionStates({
   }
 }
 
-function PersonalSubscription({
-  subscription,
-}: {
-  subscription?: Subscription
-}) {
+function PersonalSubscription() {
   const { t } = useTranslation()
-  const { recurlyLoadError, setRecurlyLoadError } =
+  const { personalSubscription, recurlyLoadError, setRecurlyLoadError } =
     useSubscriptionDashboardContext()
 
   useEffect(() => {
@@ -62,14 +58,15 @@ function PersonalSubscription({
     }
   })
 
-  if (!subscription) return null
+  if (!personalSubscription) return null
 
   return (
     <>
-      {subscription.recurly.account.has_past_due_invoice._ === 'true' && (
-        <PastDueSubscriptionAlert subscription={subscription} />
+      {personalSubscription.recurly.account.has_past_due_invoice._ ===
+        'true' && (
+        <PastDueSubscriptionAlert subscription={personalSubscription} />
       )}
-      <PersonalSubscriptionStates subscription={subscription} />
+      <PersonalSubscriptionStates subscription={personalSubscription} />
       {recurlyLoadError && (
         <div className="alert alert-warning" role="alert">
           <strong>{t('payment_provider_unreachable_error')}</strong>

+ 19 - 25
services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx

@@ -8,6 +8,8 @@ import { CancelSubscription } from './cancel-subscription'
 import { PendingPlanChange } from './pending-plan-change'
 import { TrialEnding } from './trial-ending'
 import { ChangePlan } from './change-plan'
+import { PendingAdditionalLicenses } from './pending-additional-licenses'
+import { ContactSupportToChangeGroupPlan } from './contact-support-to-change-group-plan'
 
 export function ActiveSubscription({
   subscription,
@@ -38,40 +40,32 @@ export function ActiveSubscription({
         )}
         {!subscription.pendingPlan &&
           subscription.recurly.additionalLicenses > 0 && (
-            <>
-              {' '}
-              <Trans
-                i18nKey="additional_licenses"
-                values={{
-                  additionalLicenses: subscription.recurly.additionalLicenses,
-                  totalLicenses: subscription.recurly.totalLicenses,
-                }}
-                components={[
-                  // eslint-disable-next-line react/jsx-key
-                  <strong />,
-                  // eslint-disable-next-line react/jsx-key
-                  <strong />,
-                ]}
-              />
-            </>
-          )}{' '}
+            <PendingAdditionalLicenses
+              additionalLicenses={subscription.recurly.additionalLicenses}
+              totalLicenses={subscription.recurly.totalLicenses}
+            />
+          )}
         {!recurlyLoadError &&
           !subscription.groupPlan &&
           subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
-            <button
-              className="btn-inline-link"
-              onClick={() => setShowChangePersonalPlan(true)}
-            >
-              {t('change_plan')}
-            </button>
+            <>
+              {' '}
+              <button
+                className="btn-inline-link"
+                onClick={() => setShowChangePersonalPlan(true)}
+              >
+                {t('change_plan')}
+              </button>
+            </>
           )}
       </p>
-      {/* && personalSubscription.pendingPlan.name != personalSubscription.plan.name */}
       {subscription.pendingPlan &&
         subscription.pendingPlan.name !== subscription.plan.name && (
           <p>{t('want_change_to_apply_before_plan_end')}</p>
         )}
-      {/* TODO: groupPlan */}
+      {(!subscription.pendingPlan ||
+        subscription.pendingPlan.name === subscription.plan.name) &&
+        subscription.plan.groupPlan && <ContactSupportToChangeGroupPlan />}
       {subscription.recurly.trial_ends_at &&
         subscription.recurly.trialEndsAtFormatted && (
           <TrialEnding

+ 104 - 5
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan.tsx

@@ -1,18 +1,117 @@
 import { useTranslation } from 'react-i18next'
+import { Plan } from '../../../../../../../../types/subscription/plan'
 import { useSubscriptionDashboardContext } from '../../../../context/subscription-dashboard-context'
 
+function ChangeToPlanButton({ plan }: { plan: Plan }) {
+  const { t } = useTranslation()
+  // for when the user selected to change a plan, but the plan change is still pending
+  return (
+    <form>
+      {/* todo: ng-model="plan_code" */}
+      <input type="hidden" name="plan_code" value={plan.planCode} />
+      {/* todo: handle submit changePlan */}
+      <input
+        type="submit"
+        value={t('change_to_this_plan')}
+        className="btn btn-primary"
+      />
+    </form>
+  )
+}
+
+function KeepCurrentPlanButton({ plan }: { plan: Plan }) {
+  const { t } = useTranslation()
+  // for when the user selected to change a plan, but the plan change is still pending
+  return (
+    <form>
+      {/* todo: ng-model="plan_code" */}
+      <input type="hidden" name="plan_code" value={plan.planCode} />
+      {/* todo: handle submit cancelPendingPlanChange */}
+      <input
+        type="submit"
+        value={t('keep_current_plan')}
+        className="btn btn-primary"
+      />
+    </form>
+  )
+}
+
+function ChangePlanButton({ plan }: { plan: Plan }) {
+  const { t } = useTranslation()
+  const { personalSubscription } = useSubscriptionDashboardContext()
+  const isCurrentPlanForUser =
+    personalSubscription?.planCode &&
+    plan.planCode === personalSubscription.planCode.split('_')[0]
+
+  if (isCurrentPlanForUser && personalSubscription.pendingPlan) {
+    return <KeepCurrentPlanButton plan={plan} />
+  } else if (isCurrentPlanForUser && !personalSubscription.pendingPlan) {
+    return (
+      <button className="btn btn-secondary disabled">{t('your_plan')}</button>
+    )
+  } else if (
+    personalSubscription?.pendingPlan?.planCode?.split('_')[0] === plan.planCode
+  ) {
+    return (
+      <button className="btn btn-secondary disabled">
+        {t('your_new_plan')}
+      </button>
+    )
+  } else {
+    return <ChangeToPlanButton plan={plan} />
+  }
+}
+
+function PlansRow({ plan }: { plan: Plan }) {
+  const { t } = useTranslation()
+
+  return (
+    <tr>
+      <td>
+        <strong>{plan.name}</strong>
+      </td>
+      <td>
+        {/* todo: {{ displayPrice }} */}/ {plan.annual ? t('year') : t('month')}
+      </td>
+      <td>
+        <ChangePlanButton plan={plan} />
+      </td>
+    </tr>
+  )
+}
+
+function PlansRows({ plans }: { plans: Array<Plan> }) {
+  return (
+    <>
+      {plans &&
+        plans.map(plan => (
+          <PlansRow key={`plans-row-${plan.planCode}`} plan={plan} />
+        ))}
+    </>
+  )
+}
+
 export function ChangePlan() {
   const { t } = useTranslation()
-  const { showChangePersonalPlan } = useSubscriptionDashboardContext()
+  const { plans, showChangePersonalPlan } = useSubscriptionDashboardContext()
 
-  if (!showChangePersonalPlan) return null
+  if (!showChangePersonalPlan || !plans) return null
 
   return (
     <>
       <h2>{t('change_plan')}</h2>
-      <p>
-        <strong>TODO: change subscription placeholder</strong>
-      </p>
+      <table className="table">
+        <thead>
+          <tr>
+            <th>{t('name')}</th>
+            <th>{t('price')}</th>
+            <th />
+          </tr>
+        </thead>
+        <tbody>
+          <PlansRows plans={plans} />
+        </tbody>
+      </table>
     </>
   )
 }

+ 15 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/contact-support-to-change-group-plan.tsx

@@ -0,0 +1,15 @@
+import { Trans } from 'react-i18next'
+
+export function ContactSupportToChangeGroupPlan() {
+  return (
+    <p>
+      <Trans
+        i18nKey="contact_support_to_change_group_subscription"
+        components={[
+          // eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
+          <a href="/contact" />,
+        ]}
+      />
+    </p>
+  )
+}

+ 28 - 0
services/web/frontend/js/features/subscription/components/dashboard/states/active/pending-additional-licenses.tsx

@@ -0,0 +1,28 @@
+import { Trans } from 'react-i18next'
+
+export function PendingAdditionalLicenses({
+  additionalLicenses,
+  totalLicenses,
+}: {
+  additionalLicenses: number
+  totalLicenses: number
+}) {
+  return (
+    <>
+      {' '}
+      <Trans
+        i18nKey="additional_licenses"
+        values={{
+          additionalLicenses,
+          totalLicenses,
+        }}
+        components={[
+          // eslint-disable-next-line react/jsx-key
+          <strong />,
+          // eslint-disable-next-line react/jsx-key
+          <strong />,
+        ]}
+      />
+    </>
+  )
+}

+ 5 - 14
services/web/frontend/js/features/subscription/components/dashboard/subscription-dashboard.tsx

@@ -1,20 +1,13 @@
 import { useTranslation } from 'react-i18next'
-import getMeta from '../../../../utils/meta'
 import InstitutionMemberships from './institution-memberships'
 import FreePlan from './free-plan'
 import PersonalSubscription from './personal-subscription'
 import ManagedGroupSubscriptions from './managed-group-subscriptions'
+import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
 
 function SubscriptionDashboard() {
   const { t } = useTranslation()
-  const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
-  const subscription = getMeta('ol-subscription')
-  const managedGroupSubscriptions = getMeta('ol-managedGroupSubscriptions')
-
-  const hasDisplayedSubscription =
-    institutionMemberships?.length > 0 ||
-    subscription ||
-    managedGroupSubscriptions?.length > 0
+  const { hasDisplayedSubscription } = useSubscriptionDashboardContext()
 
   return (
     <div className="container">
@@ -25,11 +18,9 @@ function SubscriptionDashboard() {
               <h1>{t('your_subscription')}</h1>
             </div>
 
-            <PersonalSubscription subscription={subscription} />
-            <ManagedGroupSubscriptions
-              subscriptions={managedGroupSubscriptions}
-            />
-            <InstitutionMemberships memberships={institutionMemberships} />
+            <PersonalSubscription />
+            <ManagedGroupSubscriptions />
+            <InstitutionMemberships />
             {!hasDisplayedSubscription && <FreePlan />}
           </div>
         </div>

+ 32 - 0
services/web/frontend/js/features/subscription/context/subscription-dashboard-context.tsx

@@ -1,6 +1,18 @@
 import { createContext, ReactNode, useContext, useMemo, useState } from 'react'
+import {
+  ManagedGroupSubscription,
+  Subscription,
+} from '../../../../../types/subscription/dashboard/subscription'
+import { Plan } from '../../../../../types/subscription/plan'
+import { Institution } from '../../../../../types/institution'
+import getMeta from '../../../utils/meta'
 
 type SubscriptionDashboardContextValue = {
+  hasDisplayedSubscription: boolean
+  institutionMemberships?: Array<Institution>
+  managedGroupSubscriptions: Array<ManagedGroupSubscription>
+  personalSubscription?: Subscription
+  plans: Array<Plan>
   recurlyLoadError: boolean
   setRecurlyLoadError: React.Dispatch<React.SetStateAction<boolean>>
   showCancellation: boolean
@@ -22,8 +34,23 @@ export function SubscriptionDashboardProvider({
   const [showCancellation, setShowCancellation] = useState(false)
   const [showChangePersonalPlan, setShowChangePersonalPlan] = useState(false)
 
+  const plans = getMeta('ol-plans')
+  const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
+  const personalSubscription = getMeta('ol-subscription')
+  const managedGroupSubscriptions = getMeta('ol-managedGroupSubscriptions')
+
+  const hasDisplayedSubscription =
+    institutionMemberships?.length > 0 ||
+    personalSubscription ||
+    managedGroupSubscriptions
+
   const value = useMemo<SubscriptionDashboardContextValue>(
     () => ({
+      hasDisplayedSubscription,
+      institutionMemberships,
+      managedGroupSubscriptions,
+      personalSubscription,
+      plans,
       recurlyLoadError,
       setRecurlyLoadError,
       showCancellation,
@@ -32,6 +59,11 @@ export function SubscriptionDashboardProvider({
       setShowChangePersonalPlan,
     }),
     [
+      hasDisplayedSubscription,
+      institutionMemberships,
+      managedGroupSubscriptions,
+      personalSubscription,
+      plans,
       recurlyLoadError,
       setRecurlyLoadError,
       showCancellation,

+ 26 - 3
services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx

@@ -1,6 +1,7 @@
 import { expect } from 'chai'
 import { render, screen } from '@testing-library/react'
 import InstitutionMemberships from '../../../../../../frontend/js/features/subscription/components/dashboard/institution-memberships'
+import { SubscriptionDashboardProvider } from '../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
 
 const memberships = [
   {
@@ -35,7 +36,16 @@ describe('<InstitutionMemberships />', function () {
   })
 
   it('renders all insitutions with license', function () {
-    render(<InstitutionMemberships memberships={memberships} />)
+    window.metaAttributesCache.set(
+      'ol-currentInstitutionsWithLicence',
+      memberships
+    )
+
+    render(
+      <SubscriptionDashboardProvider>
+        <InstitutionMemberships />
+      </SubscriptionDashboardProvider>
+    )
 
     const elements = screen.getAllByText('You are on our', {
       exact: false,
@@ -50,14 +60,27 @@ describe('<InstitutionMemberships />', function () {
   })
 
   it('renders error message when failed to check commons licenses', function () {
-    render(<InstitutionMemberships memberships={undefined} />)
+    render(
+      <SubscriptionDashboardProvider>
+        <InstitutionMemberships />
+      </SubscriptionDashboardProvider>
+    )
     screen.getByText(
       'Sorry, something went wrong. Subscription information related to institutional affiliations may not be displayed. Please try again later.'
     )
   })
 
   it('renders the "Get the most out of your" subscription text when a user has a subscription', function () {
-    render(<InstitutionMemberships memberships={memberships} />)
+    window.metaAttributesCache.set(
+      'ol-currentInstitutionsWithLicence',
+      memberships
+    )
+
+    render(
+      <SubscriptionDashboardProvider>
+        <InstitutionMemberships />
+      </SubscriptionDashboardProvider>
+    )
     screen.getByText('Get the most out of your', {
       exact: false,
     })

+ 15 - 5
services/web/test/frontend/features/subscription/components/dashboard/managed-group-subscriptions.test.tsx

@@ -1,12 +1,12 @@
 import { expect } from 'chai'
 import { render, screen } from '@testing-library/react'
-import ManagedGroupSubscriptions, {
-  ManagedGroupSubscription,
-} from '../../../../../../frontend/js/features/subscription/components/dashboard/managed-group-subscriptions'
 import {
   groupActiveSubscription,
   groupActiveSubscriptionWithPendingLicenseChange,
 } from '../../fixtures/subscriptions'
+import ManagedGroupSubscriptions from '../../../../../../frontend/js/features/subscription/components/dashboard/managed-group-subscriptions'
+import { ManagedGroupSubscription } from '../../../../../../types/subscription/dashboard/subscription'
+import { SubscriptionDashboardProvider } from '../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
 
 const managedGroupSubscriptions: ManagedGroupSubscription[] = [
   {
@@ -39,8 +39,14 @@ describe('<ManagedGroupSubscriptions />', function () {
   })
 
   it('renders all managed group subscriptions', function () {
+    window.metaAttributesCache.set(
+      'ol-managedGroupSubscriptions',
+      managedGroupSubscriptions
+    )
     render(
-      <ManagedGroupSubscriptions subscriptions={managedGroupSubscriptions} />
+      <SubscriptionDashboardProvider>
+        <ManagedGroupSubscriptions />
+      </SubscriptionDashboardProvider>
     )
 
     const elements = screen.getAllByText('You are a', {
@@ -85,7 +91,11 @@ describe('<ManagedGroupSubscriptions />', function () {
   })
 
   it('renders nothing when there are no group memberships', function () {
-    render(<ManagedGroupSubscriptions subscriptions={undefined} />)
+    render(
+      <SubscriptionDashboardProvider>
+        <ManagedGroupSubscriptions />
+      </SubscriptionDashboardProvider>
+    )
     const elements = screen.queryAllByText('You are a', {
       exact: false,
     })

+ 30 - 8
services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx

@@ -25,7 +25,7 @@ describe('<PersonalSubscription />', function () {
     it('returns empty container', function () {
       const { container } = render(
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={undefined} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
       )
       expect(container.firstChild).to.be.null
@@ -34,9 +34,13 @@ describe('<PersonalSubscription />', function () {
 
   describe('subscription states  ', function () {
     it('renders the active dash', function () {
+      window.metaAttributesCache.set(
+        'ol-subscription',
+        annualActiveSubscription
+      )
       render(
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={annualActiveSubscription} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
       )
 
@@ -44,9 +48,10 @@ describe('<PersonalSubscription />', function () {
     })
 
     it('renders the canceled dash', function () {
+      window.metaAttributesCache.set('ol-subscription', canceledSubscription)
       render(
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={canceledSubscription} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
       )
       screen.getByText(
@@ -69,9 +74,13 @@ describe('<PersonalSubscription />', function () {
     })
 
     it('renders the expired dash', function () {
+      window.metaAttributesCache.set(
+        'ol-subscription',
+        pastDueExpiredSubscription
+      )
       render(
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={pastDueExpiredSubscription} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
       )
       screen.getByText('Your subscription has expired.')
@@ -83,9 +92,10 @@ describe('<PersonalSubscription />', function () {
         JSON.parse(JSON.stringify(annualActiveSubscription))
       )
       withStateDeleted.recurly.state = undefined
+      window.metaAttributesCache.set('ol-subscription', withStateDeleted)
       render(
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={withStateDeleted} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
       )
       screen.getByText(
@@ -96,9 +106,13 @@ describe('<PersonalSubscription />', function () {
 
   describe('past due subscription', function () {
     it('renders error alert', function () {
+      window.metaAttributesCache.set(
+        'ol-subscription',
+        pastDueExpiredSubscription
+      )
       render(
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={pastDueExpiredSubscription} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
       )
       screen.getByRole('alert')
@@ -120,9 +134,13 @@ describe('<PersonalSubscription />', function () {
     it('shows an alert and hides "Change plan" option when Recurly did not load', function () {
       // @ts-ignore
       delete window.recurly
+      window.metaAttributesCache.set(
+        'ol-subscription',
+        annualActiveSubscription
+      )
       render(
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={annualActiveSubscription} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
       )
 
@@ -133,9 +151,13 @@ describe('<PersonalSubscription />', function () {
     })
 
     it('should not show an alert and should show "Change plan" option when Recurly did load', function () {
+      window.metaAttributesCache.set(
+        'ol-subscription',
+        annualActiveSubscription
+      )
       render(
         <SubscriptionDashboardProvider>
-          <PersonalSubscription subscription={annualActiveSubscription} />
+          <PersonalSubscription />
         </SubscriptionDashboardProvider>
       )
 

+ 36 - 14
services/web/test/frontend/features/subscription/components/dashboard/states/active.test.tsx

@@ -12,12 +12,14 @@ import {
   trialSubscription,
 } from '../../../fixtures/subscriptions'
 import sinon from 'sinon'
+import { plans } from '../../../fixtures/plans'
 
 describe('<ActiveSubscription />', function () {
   let sendMBSpy: sinon.SinonSpy
 
   beforeEach(function () {
     window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-plans', plans)
     sendMBSpy = sinon.spy(eventTracking, 'sendMB')
   })
 
@@ -77,11 +79,13 @@ describe('<ActiveSubscription />', function () {
     const button = screen.getByRole('button', { name: 'Change plan' })
     fireEvent.click(button)
 
-    // confirm main dash UI UI still shown
-    expectedInActiveSubscription(annualActiveSubscription)
+    // confirm main dash UI still shown
+    screen.getByText('You are currently subscribed to the', { exact: false })
 
-    // TODO: add change plan UI
-    screen.getByText('change subscription placeholder', { exact: false })
+    screen.getByRole('heading', { name: 'Change plan' })
+    expect(
+      screen.getAllByRole('button', { name: 'Change to this plan' }).length > 0
+    ).to.be.true
   })
 
   it('notes when user is changing plan at end of current plan term', function () {
@@ -103,17 +107,10 @@ describe('<ActiveSubscription />', function () {
     screen.getByText(
       'If you wish this change to apply before the end of your current billing period, please contact us.'
     )
-  })
-
-  it('does not show "Change plan" option for group plans', function () {
-    render(
-      <SubscriptionDashboardProvider>
-        <ActiveSubscription subscription={groupActiveSubscription} />
-      </SubscriptionDashboardProvider>
-    )
 
-    const changePlan = screen.queryByRole('button', { name: 'Change plan' })
-    expect(changePlan).to.be.null
+    expect(screen.queryByRole('link', { name: 'contact support' })).to.be.null
+    expect(screen.queryByText('if you wish to change your group subscription.'))
+      .to.be.null
   })
 
   it('does not show "Change plan" option when past due', function () {
@@ -234,4 +231,29 @@ describe('<ActiveSubscription />', function () {
 
     screen.getByText('We’d love you to stay')
   })
+
+  describe('group plans', function () {
+    it('does not show "Change plan" option for group plans', function () {
+      render(
+        <SubscriptionDashboardProvider>
+          <ActiveSubscription subscription={groupActiveSubscription} />
+        </SubscriptionDashboardProvider>
+      )
+
+      const changePlan = screen.queryByRole('button', { name: 'Change plan' })
+      expect(changePlan).to.be.null
+    })
+
+    it('shows contact support message for group plan change requests', function () {
+      render(
+        <SubscriptionDashboardProvider>
+          <ActiveSubscription subscription={groupActiveSubscription} />
+        </SubscriptionDashboardProvider>
+      )
+      screen.getByRole('link', { name: 'contact support' })
+      screen.getByText('if you wish to change your group subscription.', {
+        exact: false,
+      })
+    })
+  })
 })

+ 71 - 0
services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan.test.tsx

@@ -0,0 +1,71 @@
+import { expect } from 'chai'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { SubscriptionDashboardProvider } from '../../../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
+import { ChangePlan } from '../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/change-plan'
+import { plans } from '../../../../fixtures/plans'
+import {
+  annualActiveSubscription,
+  pendingSubscriptionChange,
+} from '../../../../fixtures/subscriptions'
+import { ActiveSubscription } from '../../../../../../../../frontend/js/features/subscription/components/dashboard/states/active/active'
+
+describe('<ChangePlan />', function () {
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+    window.metaAttributesCache.set('ol-plans', plans)
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  it('does not render the UI when showChangePersonalPlan is false', function () {
+    window.metaAttributesCache.delete('ol-plans')
+    const { container } = render(
+      <SubscriptionDashboardProvider>
+        <ChangePlan />
+      </SubscriptionDashboardProvider>
+    )
+
+    expect(container.firstChild).to.be.null
+  })
+
+  it('renders the table of plans', function () {
+    window.metaAttributesCache.set('ol-subscription', annualActiveSubscription)
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubscription subscription={annualActiveSubscription} />
+      </SubscriptionDashboardProvider>
+    )
+
+    const button = screen.getByRole('button', { name: 'Change plan' })
+    fireEvent.click(button)
+
+    const changeToPlanButtons = screen.queryAllByRole('button', {
+      name: 'Change to this plan',
+    })
+    expect(changeToPlanButtons.length).to.equal(plans.length - 1)
+    screen.getByRole('button', { name: 'Your plan' })
+
+    const annualPlans = plans.filter(plan => plan.annual)
+    expect(screen.getAllByText('/ year').length).to.equal(annualPlans.length)
+    expect(screen.getAllByText('/ month').length).to.equal(
+      plans.length - annualPlans.length
+    )
+  })
+
+  it('renders "Your new plan" and "Keep current plan" when there is a pending plan change', function () {
+    window.metaAttributesCache.set('ol-subscription', pendingSubscriptionChange)
+    render(
+      <SubscriptionDashboardProvider>
+        <ActiveSubscription subscription={pendingSubscriptionChange} />
+      </SubscriptionDashboardProvider>
+    )
+
+    const button = screen.getByRole('button', { name: 'Change plan' })
+    fireEvent.click(button)
+
+    screen.getByText('Your new plan')
+    screen.getByRole('button', { name: 'Keep my current plan' })
+  })
+})

+ 215 - 0
services/web/test/frontend/features/subscription/fixtures/plans.tsx

@@ -0,0 +1,215 @@
+import { Plan } from '../../../../../types/subscription/plan'
+
+const features = {
+  student: {
+    collaborators: 6,
+    dropbox: true,
+    versioning: true,
+    github: true,
+    templates: true,
+    references: true,
+    referencesSearch: true,
+    gitBridge: true,
+    zotero: true,
+    mendeley: true,
+    compileTimeout: 240,
+    compileGroup: 'priority',
+    trackChanges: true,
+    symbolPalette: true,
+  },
+  personal: {
+    collaborators: 1,
+    dropbox: true,
+    versioning: true,
+    github: true,
+    gitBridge: true,
+    templates: true,
+    references: true,
+    referencesSearch: true,
+    zotero: true,
+    mendeley: true,
+    compileTimeout: 240,
+    compileGroup: 'priority',
+    trackChanges: false,
+    symbolPalette: true,
+  },
+  collaborator: {
+    collaborators: 10,
+    dropbox: true,
+    versioning: true,
+    github: true,
+    templates: true,
+    references: true,
+    referencesSearch: true,
+    zotero: true,
+    gitBridge: true,
+    mendeley: true,
+    compileTimeout: 240,
+    compileGroup: 'priority',
+    trackChanges: true,
+    symbolPalette: true,
+  },
+  professional: {
+    collaborators: -1,
+    dropbox: true,
+    versioning: true,
+    github: true,
+    templates: true,
+    references: true,
+    referencesSearch: true,
+    zotero: true,
+    gitBridge: true,
+    mendeley: true,
+    compileTimeout: 240,
+    compileGroup: 'priority',
+    trackChanges: true,
+    symbolPalette: true,
+  },
+}
+
+const studentAccounts: Array<Plan> = [
+  {
+    planCode: 'student',
+    name: 'Student',
+    price_in_cents: 1000,
+    features: features.student,
+    featureDescription: [],
+  },
+  {
+    planCode: 'student-annual',
+    name: 'Student Annual',
+    price_in_cents: 9900,
+    annual: true,
+    features: features.student,
+    featureDescription: [],
+  },
+  {
+    planCode: 'student_free_trial',
+    name: 'Student',
+    price_in_cents: 800,
+    features: features.student,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+  {
+    planCode: 'student_free_trial_7_days',
+    name: 'Student',
+    price_in_cents: 1000,
+    features: features.student,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+]
+
+const individualMonthlyPlans: Array<Plan> = [
+  {
+    planCode: 'paid-personal',
+    name: 'Personal',
+    price_in_cents: 1500,
+    features: features.personal,
+    featureDescription: [],
+  },
+  {
+    planCode: 'paid-personal_free_trial_7_days',
+    name: 'Personal (Hidden)',
+    price_in_cents: 1500,
+    features: features.personal,
+    featureDescription: [],
+    hideFromUsers: true,
+  },
+  {
+    planCode: 'collaborator',
+    name: 'Standard (Collaborator)',
+    price_in_cents: 2300,
+    features: features.collaborator,
+    featureDescription: [],
+  },
+  {
+    planCode: 'professional',
+    name: 'Professional',
+    price_in_cents: 4500,
+    features: features.professional,
+    featureDescription: [],
+  },
+  {
+    planCode: 'collaborator_free_trial',
+    name: 'Standard (Collaborator) (Hidden)',
+    price_in_cents: 1900,
+    features: features.collaborator,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+  {
+    planCode: 'collaborator_free_trial_14_days',
+    name: 'Standard (Collaborator) (Hidden)',
+    price_in_cents: 1900,
+    features: features.collaborator,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+  {
+    planCode: 'collaborator_free_trial_7_days',
+    name: 'Standard (Collaborator) (Hidden)',
+    price_in_cents: 2300,
+    features: features.collaborator,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+  {
+    planCode: 'collaborator-annual_free_trial',
+    name: 'Standard (Collaborator) Annual (Hidden)',
+    price_in_cents: 18000,
+    features: features.collaborator,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+  {
+    planCode: 'professional_free_trial',
+    name: 'Professional (Hidden)',
+    price_in_cents: 3000,
+    features: features.professional,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+  {
+    planCode: 'professional_free_trial_7_days',
+    name: 'Professional (Hidden)',
+    price_in_cents: 4500,
+    features: features.professional,
+    hideFromUsers: true,
+    featureDescription: [],
+  },
+]
+
+const individualAnnualPlans: Array<Plan> = [
+  {
+    planCode: 'paid-personal-annual',
+    name: 'Personal Annual',
+    price_in_cents: 13900,
+    annual: true,
+    features: features.personal,
+    featureDescription: [],
+  },
+  {
+    planCode: 'collaborator-annual',
+    name: 'Standard (Collaborator) Annual',
+    price_in_cents: 21900,
+    annual: true,
+    features: features.collaborator,
+    featureDescription: [],
+  },
+  {
+    planCode: 'professional-annual',
+    name: 'Professional Annual',
+    price_in_cents: 42900,
+    annual: true,
+    features: features.professional,
+    featureDescription: [],
+  },
+]
+
+export const plans = [
+  ...studentAccounts,
+  ...individualMonthlyPlans,
+  ...individualAnnualPlans,
+]

+ 7 - 0
services/web/types/subscription/dashboard/subscription.ts

@@ -1,5 +1,6 @@
 import { Nullable } from '../../utils'
 import { Plan } from '../plan'
+import { User } from '../../../types/user'
 
 type SubscriptionState = 'active' | 'canceled' | 'expired'
 
@@ -54,3 +55,9 @@ export type Subscription = {
 export type GroupSubscription = Subscription & {
   teamName: string
 }
+
+export type ManagedGroupSubscription = Omit<GroupSubscription, 'admin_id'> & {
+  userIsGroupMember: boolean
+  planLevelName: string
+  admin_id: User
+}