Merge pull request #11694 from overleaf/ab-managed-institutions-react

[web] Migrate managed institutions to React dash

GitOrigin-RevId: 535dfafe42d88189bb20e7bb3beac233221ee6ba
This commit is contained in:
Davinder Singh 2023-02-14 10:21:25 +00:00 committed by Copybot
parent ba2d5db50c
commit c524fee690
10 changed files with 620 additions and 278 deletions

View file

@ -2,6 +2,8 @@ const async = require('async')
const { callbackify, promisify } = require('util')
const { ObjectId } = require('mongodb')
const Settings = require('@overleaf/settings')
const logger = require('@overleaf/logger')
const fetch = require('node-fetch')
const {
getInstitutionAffiliations,
getConfirmedInstitutionAffiliations,
@ -341,11 +343,32 @@ const notifyUser = (
callback
)
async function fetchV1Data(institution) {
const url = `${Settings.apis.v1.url}/universities/list/${institution.v1Id}`
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(Settings.apis.v1.timeout),
})
const data = await response.json()
institution.name = data?.name
institution.countryCode = data?.country_code
institution.departments = data?.departments
institution.portalSlug = data?.portal_slug
} catch (error) {
logger.err(
{ model: 'Institution', v1Id: institution.v1Id, error },
'[fetchV1DataError]'
)
}
}
InstitutionsManager.promises = {
checkInstitutionUsers,
clearInstitutionNotifications: promisify(
InstitutionsManager.clearInstitutionNotifications
),
fetchV1Data,
}
module.exports = InstitutionsManager

View file

@ -6,12 +6,13 @@ const SubscriptionLocator = require('./SubscriptionLocator')
const SubscriptionUpdater = require('./SubscriptionUpdater')
const V1SubscriptionManager = require('./V1SubscriptionManager')
const InstitutionsGetter = require('../Institutions/InstitutionsGetter')
const InstitutionsManager = require('../Institutions/InstitutionsManager')
const PublishersGetter = require('../Publishers/PublishersGetter')
const sanitizeHtml = require('sanitize-html')
const _ = require('underscore')
const async = require('async')
const SubscriptionHelper = require('./SubscriptionHelper')
const { callbackify, promisify } = require('../../util/promises')
const { callbackify } = require('../../util/promises')
const {
InvalidError,
NotFoundError,
@ -65,291 +66,283 @@ async function getRedirectToHostedPage(userId, pageType) {
].join('')
}
function buildUsersSubscriptionViewModel(user, callback) {
async.auto(
{
personalSubscription(cb) {
SubscriptionLocator.getUsersSubscription(user, cb)
},
recurlySubscription: [
'personalSubscription',
({ personalSubscription }, cb) => {
if (
personalSubscription == null ||
personalSubscription.recurlySubscription_id == null ||
personalSubscription.recurlySubscription_id === ''
) {
return cb(null, null)
}
RecurlyWrapper.getSubscription(
personalSubscription.recurlySubscription_id,
{ includeAccount: true },
cb
)
},
],
recurlyCoupons: [
'recurlySubscription',
({ recurlySubscription }, cb) => {
if (!recurlySubscription) {
return cb(null, null)
}
const accountId = recurlySubscription.account.account_code
RecurlyWrapper.getAccountActiveCoupons(accountId, cb)
},
],
plan: [
'personalSubscription',
({ personalSubscription }, cb) => {
if (personalSubscription == null) {
return cb()
}
const plan = PlansLocator.findLocalPlanInSettings(
personalSubscription.planCode
)
if (plan == null) {
return cb(
new Error(
`No plan found for planCode '${personalSubscription.planCode}'`
)
)
}
cb(null, plan)
},
],
memberGroupSubscriptions(cb) {
SubscriptionLocator.getMemberSubscriptions(user, cb)
},
managedGroupSubscriptions(cb) {
SubscriptionLocator.getManagedGroupSubscriptions(user, cb)
},
currentInstitutionsWithLicence(cb) {
InstitutionsGetter.getCurrentInstitutionsWithLicence(
user._id,
(error, institutions) => {
if (error instanceof V1ConnectionError) {
return cb(null, false)
}
cb(null, institutions)
}
)
},
managedInstitutions(cb) {
InstitutionsGetter.getManagedInstitutions(user._id, cb)
},
managedPublishers(cb) {
PublishersGetter.getManagedPublishers(user._id, cb)
},
v1SubscriptionStatus(cb) {
V1SubscriptionManager.getSubscriptionStatusFromV1(
user._id,
(error, status, v1Id) => {
if (error) {
return cb(error)
}
cb(null, status)
}
)
},
async function buildUsersSubscriptionViewModel(user) {
let {
personalSubscription,
memberGroupSubscriptions,
managedGroupSubscriptions,
currentInstitutionsWithLicence,
managedInstitutions,
managedPublishers,
v1SubscriptionStatus,
recurlySubscription,
recurlyCoupons,
plan,
} = await async.auto({
personalSubscription(cb) {
SubscriptionLocator.getUsersSubscription(user, cb)
},
(err, results) => {
if (err) {
return callback(err)
}
let {
personalSubscription,
memberGroupSubscriptions,
managedGroupSubscriptions,
currentInstitutionsWithLicence,
managedInstitutions,
managedPublishers,
v1SubscriptionStatus,
recurlySubscription,
recurlyCoupons,
plan,
} = results
if (memberGroupSubscriptions == null) {
memberGroupSubscriptions = []
}
if (managedGroupSubscriptions == null) {
managedGroupSubscriptions = []
}
if (managedInstitutions == null) {
managedInstitutions = []
}
if (v1SubscriptionStatus == null) {
v1SubscriptionStatus = {}
}
if (recurlyCoupons == null) {
recurlyCoupons = []
}
personalSubscription = serializeMongooseObject(personalSubscription)
memberGroupSubscriptions = memberGroupSubscriptions.map(
serializeMongooseObject
)
managedGroupSubscriptions = managedGroupSubscriptions.map(
serializeMongooseObject
)
if (plan != null) {
personalSubscription.plan = plan
}
// Subscription DB object contains a recurly property, used to cache trial info
// on the project-list. However, this can cause the wrong template to render,
// if we do not have any subscription data from Recurly (recurlySubscription)
// TODO: Delete this workaround once recurly cache property name migration rolled out.
if (personalSubscription) {
delete personalSubscription.recurly
}
if (personalSubscription && recurlySubscription) {
const tax = recurlySubscription.tax_in_cents || 0
// Some plans allow adding more seats than the base plan provides.
// This is recorded as a subscription add on.
// Note: tax_in_cents already includes the tax for any addon.
let addOnPrice = 0
let additionalLicenses = 0
recurlySubscription: [
'personalSubscription',
({ personalSubscription }, cb) => {
if (
plan.membersLimitAddOn &&
Array.isArray(recurlySubscription.subscription_add_ons)
personalSubscription == null ||
personalSubscription.recurlySubscription_id == null ||
personalSubscription.recurlySubscription_id === ''
) {
recurlySubscription.subscription_add_ons.forEach(addOn => {
if (addOn.add_on_code === plan.membersLimitAddOn) {
addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
additionalLicenses += addOn.quantity
}
})
return cb(null, null)
}
const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
personalSubscription.recurly = {
tax,
taxRate: recurlySubscription.tax_rate
? parseFloat(recurlySubscription.tax_rate._)
: 0,
billingDetailsLink: buildHostedLink('billing-details'),
accountManagementLink: buildHostedLink('account-management'),
additionalLicenses,
totalLicenses,
nextPaymentDueAt: SubscriptionFormatters.formatDate(
recurlySubscription.current_period_ends_at
),
currency: recurlySubscription.currency,
state: recurlySubscription.state,
trialEndsAtFormatted: SubscriptionFormatters.formatDate(
recurlySubscription.trial_ends_at
),
trial_ends_at: recurlySubscription.trial_ends_at,
activeCoupons: recurlyCoupons,
account: recurlySubscription.account,
RecurlyWrapper.getSubscription(
personalSubscription.recurlySubscription_id,
{ includeAccount: true },
cb
)
},
],
recurlyCoupons: [
'recurlySubscription',
({ recurlySubscription }, cb) => {
if (!recurlySubscription) {
return cb(null, null)
}
if (recurlySubscription.pending_subscription) {
const pendingPlan = PlansLocator.findLocalPlanInSettings(
recurlySubscription.pending_subscription.plan.plan_code
)
if (pendingPlan == null) {
return callback(
new Error(
`No plan found for planCode '${personalSubscription.planCode}'`
)
const accountId = recurlySubscription.account.account_code
RecurlyWrapper.getAccountActiveCoupons(accountId, cb)
},
],
plan: [
'personalSubscription',
({ personalSubscription }, cb) => {
if (personalSubscription == null) {
return cb()
}
const plan = PlansLocator.findLocalPlanInSettings(
personalSubscription.planCode
)
if (plan == null) {
return cb(
new Error(
`No plan found for planCode '${personalSubscription.planCode}'`
)
)
}
cb(null, plan)
},
],
memberGroupSubscriptions(cb) {
SubscriptionLocator.getMemberSubscriptions(user, cb)
},
managedGroupSubscriptions(cb) {
SubscriptionLocator.getManagedGroupSubscriptions(user, cb)
},
currentInstitutionsWithLicence(cb) {
InstitutionsGetter.getCurrentInstitutionsWithLicence(
user._id,
(error, institutions) => {
if (error instanceof V1ConnectionError) {
return cb(null, false)
}
let pendingAdditionalLicenses = 0
let pendingAddOnTax = 0
let pendingAddOnPrice = 0
if (recurlySubscription.pending_subscription.subscription_add_ons) {
if (
pendingPlan.membersLimitAddOn &&
Array.isArray(
recurlySubscription.pending_subscription.subscription_add_ons
)
) {
recurlySubscription.pending_subscription.subscription_add_ons.forEach(
addOn => {
if (addOn.add_on_code === pendingPlan.membersLimitAddOn) {
pendingAddOnPrice +=
addOn.quantity * addOn.unit_amount_in_cents
pendingAdditionalLicenses += addOn.quantity
}
}
)
}
// Need to calculate tax ourselves as we don't get tax amounts for pending subs
pendingAddOnTax =
personalSubscription.recurly.taxRate * pendingAddOnPrice
cb(null, institutions)
}
)
},
managedInstitutions(cb) {
InstitutionsGetter.getManagedInstitutions(user._id, cb)
},
managedPublishers(cb) {
PublishersGetter.getManagedPublishers(user._id, cb)
},
v1SubscriptionStatus(cb) {
V1SubscriptionManager.getSubscriptionStatusFromV1(
user._id,
(error, status, v1Id) => {
if (error) {
return cb(error)
}
const pendingSubscriptionTax =
personalSubscription.recurly.taxRate *
recurlySubscription.pending_subscription.unit_amount_in_cents
personalSubscription.recurly.displayPrice =
SubscriptionFormatters.formatPrice(
recurlySubscription.pending_subscription.unit_amount_in_cents +
pendingAddOnPrice +
pendingAddOnTax +
pendingSubscriptionTax,
recurlySubscription.currency
)
personalSubscription.recurly.currentPlanDisplayPrice =
SubscriptionFormatters.formatPrice(
recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
recurlySubscription.currency
)
const pendingTotalLicenses =
(pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
personalSubscription.recurly.pendingAdditionalLicenses =
pendingAdditionalLicenses
personalSubscription.recurly.pendingTotalLicenses =
pendingTotalLicenses
personalSubscription.pendingPlan = pendingPlan
} else {
personalSubscription.recurly.displayPrice =
SubscriptionFormatters.formatPrice(
recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
recurlySubscription.currency
)
cb(null, status)
}
}
)
},
})
for (const memberGroupSubscription of memberGroupSubscriptions) {
if (
memberGroupSubscription.manager_ids?.some(
id => id.toString() === user._id.toString()
)
) {
memberGroupSubscription.userIsGroupManager = true
}
if (memberGroupSubscription.teamNotice) {
memberGroupSubscription.teamNotice = sanitizeHtml(
memberGroupSubscription.teamNotice
)
}
buildGroupSubscriptionForView(memberGroupSubscription)
}
if (memberGroupSubscriptions == null) {
memberGroupSubscriptions = []
}
if (managedGroupSubscriptions == null) {
managedGroupSubscriptions = []
}
if (managedInstitutions == null) {
managedInstitutions = []
}
if (v1SubscriptionStatus == null) {
v1SubscriptionStatus = {}
}
if (recurlyCoupons == null) {
recurlyCoupons = []
}
for (const managedGroupSubscription of managedGroupSubscriptions) {
if (
managedGroupSubscription.member_ids?.some(
id => id.toString() === user._id.toString()
)
) {
managedGroupSubscription.userIsGroupMember = true
}
buildGroupSubscriptionForView(managedGroupSubscription)
}
personalSubscription = serializeMongooseObject(personalSubscription)
memberGroupSubscriptions = memberGroupSubscriptions.map(
serializeMongooseObject
)
managedGroupSubscriptions = managedGroupSubscriptions.map(
serializeMongooseObject
)
managedInstitutions = managedInstitutions.map(serializeMongooseObject)
await Promise.all(
managedInstitutions.map(InstitutionsManager.promises.fetchV1Data)
)
callback(null, {
personalSubscription,
managedGroupSubscriptions,
memberGroupSubscriptions,
currentInstitutionsWithLicence,
managedInstitutions,
managedPublishers,
v1SubscriptionStatus,
if (plan != null) {
personalSubscription.plan = plan
}
// Subscription DB object contains a recurly property, used to cache trial info
// on the project-list. However, this can cause the wrong template to render,
// if we do not have any subscription data from Recurly (recurlySubscription)
// TODO: Delete this workaround once recurly cache property name migration rolled out.
if (personalSubscription) {
delete personalSubscription.recurly
}
if (personalSubscription && recurlySubscription) {
const tax = recurlySubscription.tax_in_cents || 0
// Some plans allow adding more seats than the base plan provides.
// This is recorded as a subscription add on.
// Note: tax_in_cents already includes the tax for any addon.
let addOnPrice = 0
let additionalLicenses = 0
if (
plan.membersLimitAddOn &&
Array.isArray(recurlySubscription.subscription_add_ons)
) {
recurlySubscription.subscription_add_ons.forEach(addOn => {
if (addOn.add_on_code === plan.membersLimitAddOn) {
addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
additionalLicenses += addOn.quantity
}
})
}
)
const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
personalSubscription.recurly = {
tax,
taxRate: recurlySubscription.tax_rate
? parseFloat(recurlySubscription.tax_rate._)
: 0,
billingDetailsLink: buildHostedLink('billing-details'),
accountManagementLink: buildHostedLink('account-management'),
additionalLicenses,
totalLicenses,
nextPaymentDueAt: SubscriptionFormatters.formatDate(
recurlySubscription.current_period_ends_at
),
currency: recurlySubscription.currency,
state: recurlySubscription.state,
trialEndsAtFormatted: SubscriptionFormatters.formatDate(
recurlySubscription.trial_ends_at
),
trial_ends_at: recurlySubscription.trial_ends_at,
activeCoupons: recurlyCoupons,
account: recurlySubscription.account,
}
if (recurlySubscription.pending_subscription) {
const pendingPlan = PlansLocator.findLocalPlanInSettings(
recurlySubscription.pending_subscription.plan.plan_code
)
if (pendingPlan == null) {
throw new Error(
`No plan found for planCode '${personalSubscription.planCode}'`
)
}
let pendingAdditionalLicenses = 0
let pendingAddOnTax = 0
let pendingAddOnPrice = 0
if (recurlySubscription.pending_subscription.subscription_add_ons) {
if (
pendingPlan.membersLimitAddOn &&
Array.isArray(
recurlySubscription.pending_subscription.subscription_add_ons
)
) {
recurlySubscription.pending_subscription.subscription_add_ons.forEach(
addOn => {
if (addOn.add_on_code === pendingPlan.membersLimitAddOn) {
pendingAddOnPrice += addOn.quantity * addOn.unit_amount_in_cents
pendingAdditionalLicenses += addOn.quantity
}
}
)
}
// Need to calculate tax ourselves as we don't get tax amounts for pending subs
pendingAddOnTax =
personalSubscription.recurly.taxRate * pendingAddOnPrice
}
const pendingSubscriptionTax =
personalSubscription.recurly.taxRate *
recurlySubscription.pending_subscription.unit_amount_in_cents
personalSubscription.recurly.displayPrice =
SubscriptionFormatters.formatPrice(
recurlySubscription.pending_subscription.unit_amount_in_cents +
pendingAddOnPrice +
pendingAddOnTax +
pendingSubscriptionTax,
recurlySubscription.currency
)
personalSubscription.recurly.currentPlanDisplayPrice =
SubscriptionFormatters.formatPrice(
recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
recurlySubscription.currency
)
const pendingTotalLicenses =
(pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
personalSubscription.recurly.pendingAdditionalLicenses =
pendingAdditionalLicenses
personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses
personalSubscription.pendingPlan = pendingPlan
} else {
personalSubscription.recurly.displayPrice =
SubscriptionFormatters.formatPrice(
recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
recurlySubscription.currency
)
}
}
for (const memberGroupSubscription of memberGroupSubscriptions) {
if (
memberGroupSubscription.manager_ids?.some(
id => id.toString() === user._id.toString()
)
) {
memberGroupSubscription.userIsGroupManager = true
}
if (memberGroupSubscription.teamNotice) {
memberGroupSubscription.teamNotice = sanitizeHtml(
memberGroupSubscription.teamNotice
)
}
buildGroupSubscriptionForView(memberGroupSubscription)
}
for (const managedGroupSubscription of managedGroupSubscriptions) {
if (
managedGroupSubscription.member_ids?.some(
id => id.toString() === user._id.toString()
)
) {
managedGroupSubscription.userIsGroupMember = true
}
buildGroupSubscriptionForView(managedGroupSubscription)
}
return {
personalSubscription,
managedGroupSubscriptions,
memberGroupSubscriptions,
currentInstitutionsWithLicence,
managedInstitutions,
managedPublishers,
v1SubscriptionStatus,
}
}
/**
@ -558,12 +551,12 @@ function buildPlansListForSubscriptionDash(currentPlan) {
}
module.exports = {
buildUsersSubscriptionViewModel,
buildUsersSubscriptionViewModel: callbackify(buildUsersSubscriptionViewModel),
buildPlansList,
buildPlansListForSubscriptionDash,
getBestSubscription: callbackify(getBestSubscription),
promises: {
buildUsersSubscriptionViewModel: promisify(buildUsersSubscriptionViewModel),
buildUsersSubscriptionViewModel,
getRedirectToHostedPage,
getBestSubscription,
},

View file

@ -444,6 +444,7 @@
"manage_beta_program_membership": "",
"manage_files_from_your_dropbox_folder": "",
"manage_group_managers": "",
"manage_institution_managers": "",
"manage_labs_program_membership": "",
"manage_members": "",
"manage_newsletter": "",
@ -727,6 +728,7 @@
"subject": "",
"subject_to_additional_vat": "",
"submit_title": "",
"subscribe": "",
"subscription_admins_cannot_be_deleted": "",
"subscription_canceled_and_terminate_on_x": "",
"subscription_will_remain_active_until_end_of_billing_period_x": "",
@ -814,6 +816,7 @@
"unlink_reference": "",
"unlink_warning_reference": "",
"unlinking": "",
"unsubscribe": "",
"untrash": "",
"update": "",
"update_account_info": "",
@ -837,6 +840,7 @@
"vat": "",
"vat_number": "",
"view_all": "",
"view_hub": "",
"view_logs": "",
"view_metrics": "",
"view_pdf": "",
@ -856,6 +860,7 @@
"x_price_for_y_months": "",
"year": "",
"you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
"you_are_a_manager_of_commons_at_institution_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_can_now_log_in_sso": "",

View file

@ -0,0 +1,31 @@
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import ManagedInstitution from './managed_institution'
export type Institution = {
v1Id: number
managerIds: string[]
metricsEmail: {
optedOutUserIds: string[]
lastSent: Date
}
name: string
}
export default function ManagedInstitutions() {
const { managedInstitutions } = useSubscriptionDashboardContext()
if (!managedInstitutions) {
return null
}
return (
<>
{managedInstitutions.map(institution => (
<ManagedInstitution
institution={institution}
key={`managed-institution-${institution.v1Id}`}
/>
))}
</>
)
}

View file

@ -0,0 +1,94 @@
import { useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { postJSON } from '../../../../infrastructure/fetch-json'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import { Institution } from './managed-institutions'
type ManagedInstitutionProps = {
institution: Institution
}
export default function ManagedInstitution({
institution,
}: ManagedInstitutionProps) {
const { t } = useTranslation()
const [subscriptionChanging, setSubscriptionChanging] = useState(false)
const { updateManagedInstitution } = useSubscriptionDashboardContext()
const changeInstitutionalEmailSubscription = useCallback(
(e, institutionId: Institution['v1Id']) => {
const updateSubscription = async (institutionId: Institution['v1Id']) => {
setSubscriptionChanging(true)
try {
const data = await postJSON<string[]>(
`/institutions/${institutionId}/emailSubscription`
)
institution.metricsEmail.optedOutUserIds = data
updateManagedInstitution(institution)
} catch (error) {
console.error(error)
}
setSubscriptionChanging(false)
}
e.preventDefault()
updateSubscription(institutionId)
},
[institution, updateManagedInstitution]
)
return (
<div>
<p>
<Trans
i18nKey="you_are_a_manager_of_commons_at_institution_x"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{
institutionName: institution.name || '',
}}
/>
</p>
<p>
<a
className="btn btn-primary"
href={`/metrics/institutions/${institution.v1Id}`}
>
<i className="fa fa-fw fa-line-chart" /> {t('view_metrics')}
</a>
</p>
<p>
<a href={`/institutions/${institution.v1Id}/hub`}>
<i className="fa fa-fw fa-user-circle" /> {t('view_hub')}
</a>
</p>
<p>
<a href={`/manage/institutions/${institution.v1Id}/managers`}>
<i className="fa fa-fw fa-users" /> {t('manage_institution_managers')}
</a>
</p>
<div>
<p>
<span>Monthly metrics emails: </span>
{subscriptionChanging ? (
<i className="fa fa-spin fa-refresh" />
) : (
<button
className="btn-inline-link"
style={{ border: 0 }}
onClick={e =>
changeInstitutionalEmailSubscription(e, institution.v1Id)
}
>
{institution.metricsEmail.optedOutUserIds.includes(
window.user_id!
)
? t('subscribe')
: t('unsubscribe')}
</button>
)}
</p>
</div>
<hr />
</div>
)
}

View file

@ -3,6 +3,7 @@ import InstitutionMemberships from './institution-memberships'
import FreePlan from './free-plan'
import PersonalSubscription from './personal-subscription'
import ManagedGroupSubscriptions from './managed-group-subscriptions'
import ManagedInstitutions from './managed-institutions'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
function SubscriptionDashboard() {
@ -20,6 +21,7 @@ function SubscriptionDashboard() {
<PersonalSubscription />
<ManagedGroupSubscriptions />
<ManagedInstitutions />
<InstitutionMemberships />
{!hasDisplayedSubscription && <FreePlan />}
</div>

View file

@ -1,6 +1,7 @@
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
@ -11,6 +12,7 @@ import {
Subscription,
} from '../../../../../types/subscription/dashboard/subscription'
import { Plan } from '../../../../../types/subscription/plan'
import { Institution as ManagedInstitution } from '../components/dashboard/managed-institutions'
import { Institution } from '../../../../../types/institution'
import getMeta from '../../../utils/meta'
import { loadDisplayPriceWithTaxPromise } from '../util/recurly-pricing'
@ -18,10 +20,12 @@ import { isRecurlyLoaded } from '../util/is-recurly-loaded'
type SubscriptionDashboardContextValue = {
hasDisplayedSubscription: boolean
institutionMemberships?: Array<Institution>
managedGroupSubscriptions: Array<ManagedGroupSubscription>
institutionMemberships?: Institution[]
managedGroupSubscriptions: ManagedGroupSubscription[]
managedInstitutions: ManagedInstitution[]
updateManagedInstitution: (institution: ManagedInstitution) => void
personalSubscription?: Subscription
plans: Array<Plan>
plans: Plan[]
queryingIndividualPlansData: boolean
recurlyLoadError: boolean
setRecurlyLoadError: React.Dispatch<React.SetStateAction<boolean>>
@ -51,12 +55,16 @@ export function SubscriptionDashboardProvider({
const institutionMemberships = getMeta('ol-currentInstitutionsWithLicence')
const personalSubscription = getMeta('ol-subscription')
const managedGroupSubscriptions = getMeta('ol-managedGroupSubscriptions')
const [managedInstitutions, setManagedInstitutions] = useState<
ManagedInstitution[]
>(getMeta('ol-managedInstitutions'))
const recurlyApiKey = getMeta('ol-recurlyApiKey')
const hasDisplayedSubscription =
institutionMemberships?.length > 0 ||
personalSubscription ||
managedGroupSubscriptions?.length > 0
managedGroupSubscriptions?.length > 0 ||
managedInstitutions?.length > 0
useEffect(() => {
if (!isRecurlyLoaded()) {
@ -91,11 +99,26 @@ export function SubscriptionDashboardProvider({
}
}, [personalSubscription, plansWithoutDisplayPrice])
const updateManagedInstitution = useCallback(
(institution: ManagedInstitution) => {
setManagedInstitutions(institutions => {
return [
...(institutions || []).map(i =>
i.v1Id === institution.v1Id ? institution : i
),
]
})
},
[]
)
const value = useMemo<SubscriptionDashboardContextValue>(
() => ({
hasDisplayedSubscription,
institutionMemberships,
managedGroupSubscriptions,
managedInstitutions,
updateManagedInstitution,
personalSubscription,
plans,
queryingIndividualPlansData,
@ -110,6 +133,8 @@ export function SubscriptionDashboardProvider({
hasDisplayedSubscription,
institutionMemberships,
managedGroupSubscriptions,
managedInstitutions,
updateManagedInstitution,
personalSubscription,
plans,
queryingIndividualPlansData,

View file

@ -881,6 +881,7 @@
"manage_beta_program_membership": "Manage Beta Program Membership",
"manage_files_from_your_dropbox_folder": "Manage files from your Dropbox folder",
"manage_group_managers": "Manage group managers",
"manage_institution_managers": "Manage institution managers",
"manage_labs_program_membership": "Manage Labs Program Membership",
"manage_members": "Manage members",
"manage_newsletter": "Manage Your Newsletter Preferences",
@ -1603,6 +1604,7 @@
"vat_number": "VAT Number",
"view_all": "View All",
"view_collab_edits": "View collaborator edits ",
"view_hub": "View hub",
"view_in_template_gallery": "View it in the template gallery",
"view_logs": "View logs",
"view_metrics": "View metrics",

View file

@ -0,0 +1,161 @@
import { expect } from 'chai'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ManagedInstitutions, {
Institution,
} from '../../../../../../frontend/js/features/subscription/components/dashboard/managed-institutions'
import { SubscriptionDashboardProvider } from '../../../../../../frontend/js/features/subscription/context/subscription-dashboard-context'
import fetchMock from 'fetch-mock'
const userId = 'fff999fff999'
const institution1 = {
v1Id: 123,
managerIds: [],
metricsEmail: {
optedOutUserIds: [],
lastSent: new Date(),
},
name: 'Inst 1',
}
const institution2 = {
v1Id: 456,
managerIds: [],
metricsEmail: {
optedOutUserIds: [userId],
lastSent: new Date(),
},
name: 'Inst 2',
}
const managedInstitutions: Institution[] = [institution1, institution2]
describe('<ManagedInstitutions />', function () {
beforeEach(function () {
window.metaAttributesCache = new Map()
window.metaAttributesCache.set(
'ol-managedInstitutions',
managedInstitutions
)
window.user_id = userId
})
afterEach(function () {
window.metaAttributesCache = new Map()
delete window.user_id
fetchMock.reset()
})
it('renders all managed institutions', function () {
render(
<SubscriptionDashboardProvider>
<ManagedInstitutions />
</SubscriptionDashboardProvider>
)
const elements = screen.getAllByText('You are a', {
exact: false,
})
expect(elements.length).to.equal(2)
expect(elements[0].textContent).to.equal(
'You are a manager of the Overleaf Commons subscription at Inst 1'
)
expect(elements[1].textContent).to.equal(
'You are a manager of the Overleaf Commons subscription at Inst 2'
)
const viewMetricsLinks = screen.getAllByText('View metrics')
expect(viewMetricsLinks.length).to.equal(2)
expect(viewMetricsLinks[0].getAttribute('href')).to.equal(
'/metrics/institutions/123'
)
expect(viewMetricsLinks[1].getAttribute('href')).to.equal(
'/metrics/institutions/456'
)
const viewHubLinks = screen.getAllByText('View hub')
expect(viewHubLinks.length).to.equal(2)
expect(viewHubLinks[0].getAttribute('href')).to.equal(
'/institutions/123/hub'
)
expect(viewHubLinks[1].getAttribute('href')).to.equal(
'/institutions/456/hub'
)
const manageGroupManagersLinks = screen.getAllByText(
'Manage institution managers'
)
expect(manageGroupManagersLinks.length).to.equal(2)
expect(manageGroupManagersLinks[0].getAttribute('href')).to.equal(
'/manage/institutions/123/managers'
)
expect(manageGroupManagersLinks[1].getAttribute('href')).to.equal(
'/manage/institutions/456/managers'
)
const subscribeLinks = screen.getAllByText('Subscribe')
expect(subscribeLinks.length).to.equal(1)
const unsubscribeLinks = screen.getAllByText('Unsubscribe')
expect(unsubscribeLinks.length).to.equal(1)
})
it('clicking unsubscribe should unsubscribe from metrics emails', async function () {
window.metaAttributesCache.set('ol-managedInstitutions', [institution1])
const unsubscribeUrl = '/institutions/123/emailSubscription'
fetchMock.post(unsubscribeUrl, {
status: 204,
body: [userId],
})
render(
<SubscriptionDashboardProvider>
<ManagedInstitutions />
</SubscriptionDashboardProvider>
)
const unsubscribeLink = screen.getByText('Unsubscribe')
await fireEvent.click(unsubscribeLink)
await waitFor(() => expect(fetchMock.called(unsubscribeUrl)).to.be.true)
await waitFor(() => {
expect(screen.getByText('Subscribe')).to.exist
})
})
it('clicking subscribe should subscribe to metrics emails', async function () {
window.metaAttributesCache.set('ol-managedInstitutions', [institution2])
const subscribeUrl = '/institutions/456/emailSubscription'
fetchMock.post(subscribeUrl, {
status: 204,
body: [],
})
render(
<SubscriptionDashboardProvider>
<ManagedInstitutions />
</SubscriptionDashboardProvider>
)
const subscribeLink = screen.getByText('Subscribe')
await fireEvent.click(subscribeLink)
await waitFor(() => expect(fetchMock.called(subscribeUrl)).to.be.true)
await waitFor(() => {
expect(screen.getByText('Unsubscribe')).to.exist
})
})
it('renders nothing when there are no institutions', function () {
window.metaAttributesCache.set('ol-managedInstitutions', undefined)
render(
<SubscriptionDashboardProvider>
<ManagedInstitutions />
</SubscriptionDashboardProvider>
)
const elements = screen.queryAllByText('You are a', {
exact: false,
})
expect(elements.length).to.equal(0)
})
})

View file

@ -82,6 +82,11 @@ describe('SubscriptionViewModelBuilder', function () {
getCurrentInstitutionsWithLicence: sinon.stub().resolves(),
},
}
this.InstitutionsManager = {
promises: {
fetchV1Data: sinon.stub().resolves(),
},
}
this.RecurlyWrapper = {
promises: {
getSubscription: sinon.stub().resolves(),
@ -100,6 +105,7 @@ describe('SubscriptionViewModelBuilder', function () {
'@overleaf/settings': this.Settings,
'./SubscriptionLocator': this.SubscriptionLocator,
'../Institutions/InstitutionsGetter': this.InstitutionsGetter,
'../Institutions/InstitutionsManager': this.InstitutionsManager,
'./RecurlyWrapper': this.RecurlyWrapper,
'./SubscriptionUpdater': this.SubscriptionUpdater,
'./PlansLocator': this.PlansLocator,