From e434079c983047d9a0043aefc7a092f41908f89b Mon Sep 17 00:00:00 2001 From: Alexandre Monjol Date: Fri, 13 Oct 2023 13:02:01 +0200 Subject: [PATCH] feat(plan) add details page --- .../e2e/10-resources/t40-create-plan.cy.ts | 6 +- cypress/e2e/10-resources/t50-edit-plan.cy.ts | 17 +- cypress/support/reusableConstants.ts | 4 +- ditto/base.json | 28 +- src/components/SkeletonDetailsPage.tsx | 6 + src/components/plans/DeletePlanDialog.tsx | 20 +- src/components/plans/PlanItem.tsx | 15 +- .../PlanDetailsChargeWrapperSwitch.tsx | 2 +- .../details/PlanDetailsChargesSection.tsx | 2 +- .../details/PlanDetailsFixedFeeAccordion.tsx | 6 +- .../plans/details/PlanSubscriptionList.tsx | 189 +++++++++++++ .../details/PlanSubscriptionListItem.tsx | 173 ++++++++++++ .../SubscriptionDetailsOverview.tsx | 8 +- .../SubscriptionInformations.tsx | 29 +- src/core/apolloClient/cache.ts | 4 + src/core/router/ObjectsRoutes.tsx | 15 +- src/generated/graphql.tsx | 126 ++++++++- src/hooks/plans/usePlanForm.tsx | 21 +- src/layouts/SideNavLayout.tsx | 5 + src/pages/CreatePlan.tsx | 23 +- src/pages/PlanDetails.tsx | 254 ++++++++++++++++++ src/pages/PlansList.tsx | 12 +- src/pages/SubscriptionDetails.tsx | 246 ++++++++++------- 23 files changed, 1047 insertions(+), 164 deletions(-) create mode 100644 src/components/plans/details/PlanSubscriptionList.tsx create mode 100644 src/components/plans/details/PlanSubscriptionListItem.tsx create mode 100644 src/pages/PlanDetails.tsx diff --git a/cypress/e2e/10-resources/t40-create-plan.cy.ts b/cypress/e2e/10-resources/t40-create-plan.cy.ts index 7c9cff503..dd124ab11 100644 --- a/cypress/e2e/10-resources/t40-create-plan.cy.ts +++ b/cypress/e2e/10-resources/t40-create-plan.cy.ts @@ -22,7 +22,7 @@ describe('Create plan', () => { cy.get('textarea[name="description"]').type('I am a description') cy.get('input[name="amountCents"]').type('30000') cy.get('[data-test="submit"]').click({ force: true }) - cy.url().should('be.equal', Cypress.config().baseUrl + '/plans') + cy.url().should('include', '/overview') cy.contains(planName).should('exist') }) @@ -138,7 +138,7 @@ describe('Create plan', () => { cy.get('[data-test="submit"]').should('not.be.disabled') cy.get('[data-test="submit"]').click({ force: true }) - cy.url().should('be.equal', Cypress.config().baseUrl + '/plans') + cy.url().should('include', '/overview') cy.contains(planWithChargesName).should('exist') }) @@ -183,7 +183,7 @@ describe('Create plan', () => { cy.get('input[name="properties.rate"]').should('have.value', '1') cy.get('[data-test="submit"]').click({ force: true }) - cy.url().should('be.equal', Cypress.config().baseUrl + '/plans') + cy.url().should('include', '/overview') cy.contains(planName).should('exist') }) }) diff --git a/cypress/e2e/10-resources/t50-edit-plan.cy.ts b/cypress/e2e/10-resources/t50-edit-plan.cy.ts index 2aa826a28..7cd7788a5 100644 --- a/cypress/e2e/10-resources/t50-edit-plan.cy.ts +++ b/cypress/e2e/10-resources/t50-edit-plan.cy.ts @@ -4,16 +4,22 @@ describe('Edit plan', () => { describe('when no data has changed', () => { it('should be able to close the form without warning dialog', () => { cy.visit('/plans') - cy.get(`[data-test="${planWithChargesName}"]`).click({ force: true }) + cy.get(`[data-test="${planWithChargesName}-wrapper"]`).within(() => { + cy.get('[data-test="plan-item-options"]').click({ force: true }) + }) + cy.get('[data-test="tab-internal-button-link-update-plan"]').click({ force: true }) cy.get('[data-test="close-create-plan-button"]').click({ force: true }) cy.get('[data-test="close-create-plan-button"]').should('not.exist') - cy.url().should('be.equal', Cypress.config().baseUrl + '/plans') + cy.url().should('include', '/overview') }) }) it('should be able to update all information of unused plan', () => { cy.visit('/plans') - cy.get(`[data-test="${planWithChargesName}"]`).click({ force: true }) + cy.get(`[data-test="${planWithChargesName}-wrapper"]`).within(() => { + cy.get('[data-test="plan-item-options"]').click({ force: true }) + }) + cy.get('[data-test="tab-internal-button-link-update-plan"]').click({ force: true }) cy.get('input[name="name"]').should('not.be.disabled') cy.get('input[name="code"]').should('not.be.disabled') cy.get('textarea[name="description"]', { timeout: 10000 }).should('not.be.disabled') @@ -48,7 +54,10 @@ describe('Edit plan', () => { it('should not be able to update all information of unused plan', () => { cy.visit('/plans') - cy.get(`[data-test="${planWithChargesName}"]`).click({ force: true }) + cy.get(`[data-test="${planWithChargesName}-wrapper"]`).within(() => { + cy.get('[data-test="plan-item-options"]').click({ force: true }) + }) + cy.get('[data-test="tab-internal-button-link-update-plan"]').click({ force: true }) cy.get('input[name="name"]').should('not.be.disabled') cy.get('input[name="code"]').should('be.disabled') cy.get('textarea[name="description"]', { timeout: 10000 }).should('not.be.disabled') diff --git a/cypress/support/reusableConstants.ts b/cypress/support/reusableConstants.ts index ded08a1a7..d3f6688df 100644 --- a/cypress/support/reusableConstants.ts +++ b/cypress/support/reusableConstants.ts @@ -1,7 +1,7 @@ export const planWithChargesName = `a plan with charges` export const customerName = 'George de la jungle' -export const userEmail = 'usertest1@lago.com' -export const userPassword = 'P@ssw0rd' +export const userEmail = 'usertest1@lago.comd' +export const userPassword = 'P@ssw0rdd' // Form variable export const TAX_TWENTY_CODE = 'twenty' diff --git a/ditto/base.json b/ditto/base.json index fc8e73264..8b1d98c69 100644 --- a/ditto/base.json +++ b/ditto/base.json @@ -237,7 +237,7 @@ "text_62b1edddbf5f461ab9712795": "Payment provider", "text_62b1edddbf5f461ab97127ad": "Connected", "text_62b1edddbf5f461ab971272b": "Connect to Stripe", - "text_62b1edddbf5f461ab9712739": "To connect to Stripe, please enter the API secret key generated from your Stripe account.", + "text_62b1edddbf5f461ab9712739": "To connect to Stripe, please enter the API secret key associated with your Stripe account.", "text_62b1edddbf5f461ab9712748": "API secret key", "text_62b1edddbf5f461ab9712756": "Type an API secret key", "text_62b1edddbf5f461ab971276d": "Cancel", @@ -592,7 +592,7 @@ "text_623b53fea66c76017eaebb76": "Please refresh the page or contact us if the error persists.", "text_623b53fea66c76017eaebb7a": "Refresh the page", "text_623c4a8c599213014cacc9de": "Count", - "text_6241cc759211e600ea57f4f1": "Returns the number of times the selected event was received", + "text_6241cc759211e600ea57f4f1": "Counts the number of events received for this metric", "text_6241ce41ae814301478358a2": "Snippet copied to clipboard", "text_6244277fe0975300fe3fb940": "Leaving deletes your choices", "text_6244277fe0975300fe3fb946": "By clicking ‘Leave’, the plan and data you’re creating will be deleted. Are you sure you want to leave?", @@ -601,11 +601,11 @@ "text_62694d9181be8d00a33f20f0": "Count unique", "text_62694d9181be8d00a33f20f8": "Max", "text_62694d9181be8d00a33f2100": "Sum", - "text_62694d9181be8d00a33f20f6": "Deduplicate events by a property to get a unique number", + "text_62694d9181be8d00a33f20f6": "Determines the number of unique values associated with the custom property", "text_62694d9181be8d00a33f20fe": "Property to aggregate", "text_62694d9181be8d00a33f2105": "Type the name of a property", - "text_62694d9181be8d00a33f20f2": "Calculate the maximum value of a property for a specific event", - "text_62694d9181be8d00a33f20ec": "Automatically sum a property during a period", + "text_62694d9181be8d00a33f20f2": "Only takes into account the highest value received", + "text_62694d9181be8d00a33f20ec": "Adds up all the values received for the defined property", "text_63971043c9668f1ba5221bac": "0|0.00|0.000", "text_644250cc64306c00c12fc2ca": "0.0000", "text_624d9adba93343010cd14c52": "A unique code for this Billable Metric", @@ -772,7 +772,17 @@ "text_65201c5a175a4b0238abf29e": "Start date", "text_65201c5a175a4b0238abf2a0": "End date", "text_65201c5a175a4b0238abf2a2": "Parent plan", + "text_6529666e71f6ce006d2bf011": "{{planName}} - subscription details", "text_652525609f420d00b83dd602": "This subscription is presently associated with a plan that has no overrides. Updating the fees will result in the creation of an overridden plan", + "text_65281f686a80b400c8e2f6ad": "{{planName}} - plan details", + "text_65281f686a80b400c8e2f6b3": "Edit plan", + "text_65281f686a80b400c8e2f6b6": "Duplicate plan", + "text_65281f686a80b400c8e2f6be": "Active subscriptions", + "text_65281f686a80b400c8e2f6c4": "Plan prices", + "text_65281f686a80b400c8e2f6d1": "Default", + "text_65281f686a80b400c8e2f6dd": "Overridden", + "text_65281f686a80b400c8e2f6c3": "No active subscription", + "text_65281f686a80b400c8e2f6c6": "Assign this plan to a customer to create an active subscription.", "text_645d071272418a14c1c76a5f": "Connect to Adyen", "text_645d071272418a14c1c76a6b": "To connect to Adyen, please enter the API key generated from your Adyen account and the Merchant Account.", "text_645d071272418a14c1c76ad8": "Connect to Adyen", @@ -1397,7 +1407,7 @@ "text_64e7b273b046851c46d782d6": "Remove max per transaction", "text_65018c8e5c6b626f030bcf8d": "Set an invoice display name", "text_65018c8e5c6b626f030bcf1e": "Set an invoice display name", - "text_65018c8e5c6b626f030bcf22": "The value defined here will be display on the next invoices generated by Lago.", + "text_65018c8e5c6b626f030bcf22": "The value defined here will be displayed on the next invoices generated by Lago.", "text_65018c8e5c6b626f030bcf26": "Invoice display name", "text_65018c8e5c6b626f030bcf2a": "Type a display name", "text_65018c8e5c6b626f030bcf32": "Edit display name", @@ -1556,7 +1566,7 @@ "text_637f813d31381b1ed90ab313": "API key", "text_637f813d31381b1ed90ab320": "API key is used for authentication in the Lago API.", "text_637f813d31381b1ed90ab326": "Copy ID", - "text_637f813d31381b1ed90ab332": "Used this organization ID to identify your account in other application.", + "text_637f813d31381b1ed90ab332": "Use this organization ID to identify your account in external applications.", "text_637f819eff19cd55a56d55e2": "These settings will be applied to all new invoices.", "text_637f819eff19cd55a56d55e4": "Edit", "text_637f819eff19cd55a56d55e6": "Tax rate", @@ -1794,10 +1804,10 @@ "text_644b9f17623605a945cafdbb": "Coupons", "text_637b4da08cd0118cd0c4486f": "{{amount}} remaining", "text_650062226a33c46e82050486": "Weighted sum", - "text_650062226a33c46e82050488": "Sum automatically property consumption over time in each period (e.g., GB/seconds)", + "text_650062226a33c46e82050488": "Calculates usage based on a custom property and the time elapsed between two events (e.g. GB-seconds)", "text_650062226a33c46e8205048e": "Units received will be assigned a per-second weight, indicating that the aggregation of usage will be calculated based on usage per second (e.g., GB/seconds, for instance).", "text_64f8823d75521b6faaee8549": "Latest", - "text_64f8823d75521b6faaee854b": "Record the value of the property from the latest received event", + "text_64f8823d75521b6faaee854b": "Only takes into account the value of the last event received", "text_633336532bdf72cb62dc0690": "Coupon successfully created", "text_633336532bdf72cb62dc0692": "Add-on successfully created", "text_633336532bdf72cb62dc0694": "Plan successfully created", diff --git a/src/components/SkeletonDetailsPage.tsx b/src/components/SkeletonDetailsPage.tsx index 8516586cc..5af38191d 100644 --- a/src/components/SkeletonDetailsPage.tsx +++ b/src/components/SkeletonDetailsPage.tsx @@ -60,3 +60,9 @@ const SkeletonBodySecond = styled.div` display: flex; gap: ${theme.spacing(4)}; ` + +export const LoadingSkeletonWrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${theme.spacing(12)}; +` diff --git a/src/components/plans/DeletePlanDialog.tsx b/src/components/plans/DeletePlanDialog.tsx index c985eb9ff..a777c4e8c 100644 --- a/src/components/plans/DeletePlanDialog.tsx +++ b/src/components/plans/DeletePlanDialog.tsx @@ -22,16 +22,26 @@ gql` } ` +type DeletePlanDialogProps = { + plan: DeletePlanDialogFragment + callback?: () => void +} + export interface DeletePlanDialogRef { - openDialog: (billableMetric: DeletePlanDialogFragment) => unknown + openDialog: ({ plan, callback }: DeletePlanDialogProps) => unknown closeDialog: () => unknown } export const DeletePlanDialog = forwardRef((_, ref) => { const { translate } = useInternationalization() const dialogRef = useRef(null) - const [plan, setPlan] = useState(undefined) - const { id = '', name = '', draftInvoicesCount = 0, activeSubscriptionsCount = 0 } = plan || {} + const [localData, setLocalData] = useState(undefined) + const { + id = '', + name = '', + draftInvoicesCount = 0, + activeSubscriptionsCount = 0, + } = localData?.plan || {} const [deletePlan] = useDeletePlanMutation({ onCompleted(data) { @@ -40,6 +50,8 @@ export const DeletePlanDialog = forwardRef((_, ref) => { message: translate('text_625fd165963a7b00c8f59879'), severity: 'success', }) + + localData?.callback && localData.callback() } }, update(cache, { data }) { @@ -56,7 +68,7 @@ export const DeletePlanDialog = forwardRef((_, ref) => { useImperativeHandle(ref, () => ({ openDialog: (data) => { - setPlan(data) + setLocalData(data) dialogRef.current?.openDialog() }, closeDialog: () => dialogRef.current?.closeDialog(), diff --git a/src/components/plans/PlanItem.tsx b/src/components/plans/PlanItem.tsx index bd19131bb..ae4d01262 100644 --- a/src/components/plans/PlanItem.tsx +++ b/src/components/plans/PlanItem.tsx @@ -14,11 +14,12 @@ import { Typography, } from '~/components/designSystem' import { updateDuplicatePlanVar } from '~/core/apolloClient/reactiveVars/duplicatePlanVar' -import { CREATE_PLAN_ROUTE, UPDATE_PLAN_ROUTE } from '~/core/router' +import { CREATE_PLAN_ROUTE, PLAN_DETAILS_ROUTE, UPDATE_PLAN_ROUTE } from '~/core/router' import { DeletePlanDialogFragmentDoc, PlanItemFragment } from '~/generated/graphql' import { useInternationalization } from '~/hooks/core/useInternationalization' import { ListKeyNavigationItemProps } from '~/hooks/ui/useListKeyNavigation' import { useOrganizationInfos } from '~/hooks/useOrganizationInfos' +import { PlanDetailsTabsOptionsEnum } from '~/pages/PlanDetails' import { BaseListItem, ItemContainer, @@ -57,10 +58,13 @@ export const PlanItem = memo(({ deleteDialogRef, navigationProps, plan }: PlanIt const { formatTimeOrgaTZ } = useOrganizationInfos() return ( - + @@ -94,7 +98,7 @@ export const PlanItem = memo(({ deleteDialogRef, navigationProps, plan }: PlanIt disableHoverListener={isOpen} title={translate('text_64fa1756d7ccc300a03a09f4')} > - + } + > + {({ closePopper }) => ( + + + + + + )} + + + + + + + + + {translate('text_65281f686a80b400c8e2f6ad', { planName: plan?.name })} + + + {plan?.code} + + + + + + + + + ), + }, + { + title: translate('text_6250304370f0f700a8fdc28d'), + link: generatePath(PLAN_DETAILS_ROUTE, { + planId: planId as string, + tab: PlanDetailsTabsOptionsEnum.subscriptions, + }), + routerState: { disableScrollTop: true }, + match: [ + generatePath(PLAN_DETAILS_ROUTE, { + planId: planId as string, + tab: PlanDetailsTabsOptionsEnum.subscriptions, + }), + ], + component: ( + + + + ), + }, + ]} + /> + + + + ) +} + +export default PlanDetails + +const ContentContainer = styled.div` + padding: 0 ${theme.spacing(12)} ${theme.spacing(20)}; + box-sizing: border-box; +` + +const TabContentWrapper = styled.div` + max-width: 672px; +` + +const HeaderInlineBreadcrumbBlock = styled.div` + display: flex; + align-items: center; + gap: ${theme.spacing(3)}; + + /* Prevent long name to not overflow in header */ + overflow: hidden; +` + +const PlanBlockWrapper = styled.div` + display: flex; + gap: ${theme.spacing(4)}; + align-items: center; + margin-bottom: ${theme.spacing(8)}; + padding: ${theme.spacing(8)} ${theme.spacing(12)} 0; +` + +const PlanBlockInfos = styled.div` + display: flex; + flex-direction: column; + gap: ${theme.spacing(1)}; + /* Used to hide text overflow */ + overflow: hidden; +` + +const PlanTitleLoadingWrapper = styled.div` + width: 200px; +` diff --git a/src/pages/PlansList.tsx b/src/pages/PlansList.tsx index 395fe8d74..a5b25d51d 100644 --- a/src/pages/PlansList.tsx +++ b/src/pages/PlansList.tsx @@ -8,7 +8,7 @@ import { GenericPlaceholder } from '~/components/GenericPlaceholder' import { DeletePlanDialog, DeletePlanDialogRef } from '~/components/plans/DeletePlanDialog' import { PlanItem, PlanItemSkeleton } from '~/components/plans/PlanItem' import { SearchInput } from '~/components/SearchInput' -import { CREATE_PLAN_ROUTE, UPDATE_PLAN_ROUTE } from '~/core/router' +import { CREATE_PLAN_ROUTE, PLAN_DETAILS_ROUTE } from '~/core/router' import { PlanItemFragmentDoc, usePlansLazyQuery } from '~/generated/graphql' import { useInternationalization } from '~/hooks/core/useInternationalization' import { useListKeysNavigation } from '~/hooks/ui/useListKeyNavigation' @@ -17,6 +17,8 @@ import EmptyImage from '~/public/images/maneki/empty.svg' import ErrorImage from '~/public/images/maneki/error.svg' import { ListContainer, ListHeader, PageHeader, theme } from '~/styles' +import { PlanDetailsTabsOptionsEnum } from './PlanDetails' + gql` query plans($page: Int, $limit: Int, $searchTerm: String) { plans(page: $page, limit: $limit, searchTerm: $searchTerm) { @@ -47,7 +49,13 @@ const PlansList = () => { const list = data?.plans?.collection || [] const { onKeyDown } = useListKeysNavigation({ getElmId: (i) => `plan-item-${i}`, - navigate: (id) => navigate(generatePath(UPDATE_PLAN_ROUTE, { planId: String(id) })), + navigate: (id) => + navigate( + generatePath(PLAN_DETAILS_ROUTE, { + planId: String(id), + tab: PlanDetailsTabsOptionsEnum.overview, + }) + ), }) let index = -1 diff --git a/src/pages/SubscriptionDetails.tsx b/src/pages/SubscriptionDetails.tsx index 17d6b84e2..30136a129 100644 --- a/src/pages/SubscriptionDetails.tsx +++ b/src/pages/SubscriptionDetails.tsx @@ -16,11 +16,14 @@ import { Skeleton, Typography, } from '~/components/designSystem' +import SkeletonDetailsPage, { LoadingSkeletonWrapper } from '~/components/SkeletonDetailsPage' import SubscriptionDetailsOverview from '~/components/subscriptions/SubscriptionDetailsOverview' import { addToast } from '~/core/apolloClient' import { CUSTOMER_DETAILS_ROUTE, CUSTOMER_SUBSCRIPTION_DETAILS_ROUTE, + PLAN_DETAILS_ROUTE, + PLAN_SUBSCRIPTION_DETAILS_ROUTE, UPDATE_SUBSCRIPTION, UPGRADE_DOWNGRADE_SUBSCRIPTION, } from '~/core/router' @@ -29,6 +32,8 @@ import { StatusTypeEnum, useGetSubscriptionForDetailsQuery } from '~/generated/g import { useInternationalization } from '~/hooks/core/useInternationalization' import { MenuPopper, PageHeader, theme } from '~/styles' +import { PlanDetailsTabsOptionsEnum } from './PlanDetails' + export enum CustomerSubscriptionDetailsTabsOptionsEnum { overview = 'overview', } @@ -45,13 +50,16 @@ gql` name code } + customer { + id + } } } ` const SubscriptionDetails = () => { const navigate = useNavigate() - const { customerId, subscriptionId } = useParams() + const { planId, customerId, subscriptionId } = useParams() const { translate } = useInternationalization() const terminateSubscriptionDialogRef = useRef(null) const { data: subscriptionResult, loading: isSubscriptionLoading } = @@ -69,93 +77,107 @@ const SubscriptionDetails = () => { icon="arrow-left" variant="quaternary" onClick={() => { - if (customerId) { + if (!!customerId) { navigate(generatePath(CUSTOMER_DETAILS_ROUTE, { id: customerId })) + } else if (!!planId) { + navigate( + generatePath(PLAN_DETAILS_ROUTE, { + planId: planId, + tab: PlanDetailsTabsOptionsEnum.subscriptions, + }) + ) } }} /> {isSubscriptionLoading ? ( - + + + ) : ( - {subscription?.plan.name} + {translate('text_6529666e71f6ce006d2bf011', { planName: subscription?.plan.name })} )} - - {translate('text_626162c62f790600f850b6fe')} - } - > - {({ closePopper }) => ( - - + } + > + {({ closePopper }) => ( + + + + - + - - - - )} - + closePopper() + }} + > + {translate('text_62d904b97e690a881f2b867c')} + + + )} + + )} @@ -163,41 +185,57 @@ const SubscriptionDetails = () => { - {subscription?.plan.name} + {translate('text_6529666e71f6ce006d2bf011', { planName: subscription?.plan.name })} {subscription?.plan.code} - + + + + + + + + ) : ( + - - - - - ), - }, - ]} - /> + routerState: { disableScrollTop: true }, + match: [ + generatePath(CUSTOMER_SUBSCRIPTION_DETAILS_ROUTE, { + customerId: subscription?.customer?.id as string, + subscriptionId: subscriptionId as string, + tab: CustomerSubscriptionDetailsTabsOptionsEnum.overview, + }), + generatePath(PLAN_SUBSCRIPTION_DETAILS_ROUTE, { + planId: planId || '', + subscriptionId: subscriptionId as string, + tab: CustomerSubscriptionDetailsTabsOptionsEnum.overview, + }), + ], + component: ( + + + + + + ), + }, + ]} + /> + )} @@ -239,3 +277,7 @@ const PlanBlockInfos = styled.div` /* Used to hide text overflow */ overflow: hidden; ` + +const TitleSkeletonWrapper = styled.div` + width: 200px; +`