1
0
Fork 0
mirror of https://github.com/overleaf/overleaf.git synced 2025-04-11 10:44:06 +00:00

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

[web] Migrate group subscription memberships to React dash

GitOrigin-RevId: d5ff3ae4e5d8c422530502af22edda6c24c9a593
This commit is contained in:
Alexandre Bourdin 2023-02-22 13:10:02 +01:00 committed by Copybot
parent f4a9b7bf60
commit f86eeac522
11 changed files with 346 additions and 11 deletions

View file

@ -8,6 +8,7 @@ block head-scripts
block append meta
meta(name="ol-managedGroupSubscriptions", data-type="json" content=managedGroupSubscriptions)
meta(name="ol-memberGroupSubscriptions", data-type="json" content=memberGroupSubscriptions)
meta(name="ol-managedInstitutions", data-type="json", content=managedInstitutions)
meta(name="ol-managedPublishers", data-type="json" content=managedPublishers)
meta(name="ol-planCodesChangingAtTermEnd", data-type="json", content=planCodesChangingAtTermEnd)

View file

@ -410,6 +410,8 @@
"learn_more": "",
"learn_more_about_link_sharing": "",
"leave": "",
"leave_group": "",
"leave_now": "",
"leave_projects": "",
"let_us_know": "",
"license_for_educational_purposes": "",
@ -759,6 +761,7 @@
"sure_you_want_to_cancel_plan_change": "",
"sure_you_want_to_change_plan": "",
"sure_you_want_to_delete": "",
"sure_you_want_to_leave_group": "",
"switch_to_editor": "",
"switch_to_pdf": "",
"symbol_palette": "",
@ -909,6 +912,7 @@
"you_are_a_manager_of_publisher_x": "",
"you_are_a_manager_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
"you_are_on_x_plan_as_a_confirmed_member_of_institution_y": "",
"you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
"you_can_now_log_in_sso": "",
"you_dont_have_any_repositories": "",
"you_have_added_x_of_group_size_y": "",

View file

@ -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>
)
}

View file

@ -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 />
</>
)
}

View file

@ -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>
)
}

View file

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

View file

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

View file

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

View file

@ -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)
})
})

View file

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

View file

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