From fd897483ed1026fad4e0e71d1fcab287f18e364f Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Mon, 14 Jul 2025 16:50:08 +0200 Subject: [PATCH 01/19] chore: create plan migration endpoint --- backend/data/src/main/kotlin/io/tolgee/constants/Message.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt index 4821097e03..867fb49da9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -307,8 +307,8 @@ enum class Message { SUGGESTION_CANT_BE_PLURAL, SUGGESTION_MUST_BE_PLURAL, DUPLICATE_SUGGESTION, - UNSUPPORTED_MEDIA_TYPE, + PLAN_MIGRATION_NOT_FOUND, ; val code: String From ea1ccfeb3893abddb6db3b07df0f8e3ab9c61f22 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Thu, 17 Jul 2025 13:06:18 +0200 Subject: [PATCH 02/19] feat: cloud plans migration controller + UI --- e2e/cypress/support/dataCyType.d.ts | 4 + .../src/component/common/FullWidthTooltip.tsx | 11 + webapp/src/component/layout/HeaderBar.tsx | 7 + webapp/src/constants/links.tsx | 11 + .../migrationForm/PlanMigrationForm.tsx | 132 +++++++++++ .../fields/PlanSelectorField.tsx | 24 ++ .../cloud/fields/CloudPlanSelector.tsx | 4 +- .../genericFields/GenericPlanSelector.tsx | 20 +- .../AdministrationPlanMigrationCreate.tsx | 69 ++++++ .../AdministrationPlanMigrationEdit.tsx | 93 ++++++++ .../AdministrationCloudPlansView.tsx | 21 +- .../component/Plan/PlanMigratingChip.tsx | 208 ++++++++++++++++++ webapp/src/eeSetup/eeModule.ee.tsx | 14 ++ 13 files changed, 608 insertions(+), 10 deletions(-) create mode 100644 webapp/src/component/common/FullWidthTooltip.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/fields/PlanSelectorField.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationCreate.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationEdit.tsx create mode 100644 webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 26c61b1fa7..0cc3bf3ffc 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -40,11 +40,13 @@ declare namespace DataCy { "administration-cloud-plan-field-select-existing-stripe-product" | "administration-cloud-plan-field-type" | "administration-cloud-plan-field-type-item" | + "administration-cloud-plans-create-migration" | "administration-cloud-plans-item" | "administration-cloud-plans-item-archive" | "administration-cloud-plans-item-archived-badge" | "administration-cloud-plans-item-delete" | "administration-cloud-plans-item-edit" | + "administration-cloud-plans-item-is-migrating-badge" | "administration-cloud-plans-item-public-badge" | "administration-create-custom-plan-button" | "administration-customize-plan-switch" | @@ -677,6 +679,7 @@ declare namespace DataCy { "signup-error-free-seat-limit" | "signup-error-plan-seat-limit" | "signup-error-seats-spending-limit" | + "source-plan-selector" | "spending-limit-dialog-close" | "spending-limit-exceeded-popover" | "sso-migration-info-text" | @@ -708,6 +711,7 @@ declare namespace DataCy { "suggestions-list" | "tag-autocomplete-input" | "tag-autocomplete-option" | + "target-plan-selector" | "task-date-picker" | "task-detail" | "task-detail-author" | diff --git a/webapp/src/component/common/FullWidthTooltip.tsx b/webapp/src/component/common/FullWidthTooltip.tsx new file mode 100644 index 0000000000..aa3e8f0b7c --- /dev/null +++ b/webapp/src/component/common/FullWidthTooltip.tsx @@ -0,0 +1,11 @@ +import { styled, Tooltip, tooltipClasses, TooltipProps } from '@mui/material'; + +export const FullWidthTooltip = styled( + ({ className, ...props }: TooltipProps) => ( + + ) +)({ + [`& .${tooltipClasses.tooltip}`]: { + maxWidth: 'none', + }, +}); diff --git a/webapp/src/component/layout/HeaderBar.tsx b/webapp/src/component/layout/HeaderBar.tsx index 621316ff6c..c1c4b19983 100644 --- a/webapp/src/component/layout/HeaderBar.tsx +++ b/webapp/src/component/layout/HeaderBar.tsx @@ -32,6 +32,7 @@ export type HeaderBarProps = { switcher?: ReactNode; maxWidth?: BaseViewWidth; initialSearch?: string; + customButtons?: ReactNode[]; }; export const HeaderBar: React.VFC = (props) => { @@ -81,6 +82,12 @@ export const HeaderBar: React.VFC = (props) => { {props.switcher} )} + {props.customButtons && + props.customButtons.map((button, index) => ( + + {button} + + ))} {props.addComponent ? props.addComponent : (props.onAdd || props.addLinkTo) && ( diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 940d36bb1f..24266cd067 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -63,6 +63,7 @@ export enum PARAMS { TRANSLATION_ID = 'translationId', PLAN_ID = 'planId', TA_ID = 'taId', + PLAN_MIGRATION_ID = 'migrationId', } export class LINKS { @@ -246,6 +247,16 @@ export class LINKS { 'create' ); + static ADMINISTRATION_BILLING_PLAN_MIGRATION_CREATE = Link.ofParent( + LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS, + 'create-migration' + ); + + static ADMINISTRATION_BILLING_PLAN_MIGRATION_EDIT = Link.ofParent( + LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS, + 'migration/' + p(PARAMS.PLAN_MIGRATION_ID) + ); + /** * Organizations */ diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm.tsx new file mode 100644 index 0000000000..5584d4ac91 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm.tsx @@ -0,0 +1,132 @@ +import { Form, Formik } from 'formik'; +import { Box, Typography } from '@mui/material'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import React from 'react'; +import { useTranslate } from '@tolgee/react'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { ArrowRightIcon } from '@mui/x-date-pickers'; +import { PlanSelectorField } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migrationForm/fields/PlanSelectorField'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { Switch } from 'tg.component/common/form/fields/Switch'; + +type MigrationModel = + components['schemas']['AdministrationCloudPlanMigrationModel']; + +type Props = { + migration?: MigrationModel; + onSubmit: (value: PlanMigrationFormData) => void; + loading: boolean | undefined; +}; + +const emptyDefaultValues: PlanMigrationFormData = { + enabled: true, + sourcePlanId: 0, + targetPlanId: 0, + monthlyOffsetDays: 14, + yearlyOffsetDays: 30, +}; + +export type PlanMigrationFormData = + components['schemas']['PlanMigrationRequest']; + +export const PlanMigrationForm = ({ migration, onSubmit, loading }: Props) => { + const { t } = useTranslate(); + const isUpdate = migration != null; + const defaultValues: PlanMigrationFormData = migration + ? { + enabled: migration.enabled, + sourcePlanId: migration.sourcePlan.id, + targetPlanId: migration.targetPlan.id, + monthlyOffsetDays: migration.monthlyOffsetDays, + yearlyOffsetDays: migration.yearlyOffsetDays, + } + : emptyDefaultValues; + + const [selectedSourcePlan, setSelectedSourcePlan] = React.useState( + defaultValues.sourcePlanId + ); + const [selectedTargetPlan, setSelectedTargetPlan] = React.useState( + defaultValues.targetPlanId + ); + + return ( + +
+ + + + + setSelectedSourcePlan(plan.id)} + hiddenPlans={[selectedTargetPlan]} + filterHasMigration={false} + {...(migration && { plans: [migration.sourcePlan] })} + /> + + setSelectedTargetPlan(plan.id)} + hiddenPlans={[selectedSourcePlan]} + /> + + + {t('administration_plan_migration_run_configuration')} + + + {t('global_days')}, + }} + required + /> + {t('global_days')}, + }} + required + /> + + + + {isUpdate ? t('global_form_save') : t('global_form_create')} + + + +
+ ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/fields/PlanSelectorField.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/fields/PlanSelectorField.tsx new file mode 100644 index 0000000000..c458543144 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/fields/PlanSelectorField.tsx @@ -0,0 +1,24 @@ +import { useFormikContext } from 'formik'; +import { CloudPlanSelector } from 'tg.ee.module/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector'; +import { PlanMigrationFormData } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm'; +import { GenericPlanSelector } from 'tg.ee.module/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector'; + +export const PlanSelectorField = ({ + name, + filterHasMigration, + ...props +}: { name: string; filterHasMigration?: boolean } & Omit< + GenericPlanSelector, + 'onChange' +>) => { + const { setFieldValue, values } = useFormikContext(); + + return ( + setFieldValue(name, value)} + /> + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector.tsx index a4c00208ed..061b09ab57 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector.tsx @@ -12,13 +12,15 @@ export const CloudPlanSelector: FC< organizationId?: number; selectProps?: React.ComponentProps[`SelectProps`]; filterPublic?: boolean; + filterHasMigration?: boolean; } -> = ({ organizationId, filterPublic, ...props }) => { +> = ({ organizationId, filterPublic, filterHasMigration, ...props }) => { const plansLoadable = useBillingApiQuery({ url: '/v2/administration/billing/cloud-plans', method: 'get', query: { filterPublic, + filterHasMigration, }, }); diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx index 2308ab2338..44ed204797 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx @@ -10,11 +10,12 @@ type GenericPlanType = { id: number; name: string }; export interface GenericPlanSelector { organizationId?: number; - onPlanChange?: (planId: T) => void; + onPlanChange?: (plan: T) => void; value?: number; onChange?: (value: number) => void; selectProps?: React.ComponentProps[`SelectProps`]; plans?: T[]; + hiddenPlans?: number[]; } export const GenericPlanSelector = ({ @@ -23,6 +24,7 @@ export const GenericPlanSelector = ({ selectProps, onPlanChange, plans, + hiddenPlans, }: GenericPlanSelector) => { if (!plans) { return ( @@ -32,13 +34,15 @@ export const GenericPlanSelector = ({ ); } - const selectItems = plans.map( - (plan) => - ({ - value: plan.id, - name: plan.name, - } satisfies SelectItem) - ); + const selectItems = plans + .filter((plan) => !hiddenPlans?.includes(plan.id)) + .map( + (plan) => + ({ + value: plan.id, + name: plan.name, + } satisfies SelectItem) + ); function handleChange(planId: number) { if (plans) { diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationCreate.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationCreate.tsx new file mode 100644 index 0000000000..16f075713a --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationCreate.tsx @@ -0,0 +1,69 @@ +import { Box, Typography } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; + +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; +import { LINKS } from 'tg.constants/links'; +import { + PlanMigrationForm, + PlanMigrationFormData, +} from 'tg.ee.module/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm'; +import { useBillingApiMutation } from 'tg.service/http/useQueryApi'; +import React from 'react'; +import { useMessage } from 'tg.hooks/useSuccessMessage'; +import { useHistory } from 'react-router-dom'; + +export const AdministrationPlanMigrationCreate = () => { + const { t } = useTranslate(); + const messaging = useMessage(); + const history = useHistory(); + + const submit = (values: PlanMigrationFormData) => { + createPlanMigrationLoadable.mutate( + { content: { 'application/json': values } }, + { + onSuccess: () => { + messaging.success( + + ); + history.push(LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS.build()); + }, + } + ); + }; + + const createPlanMigrationLoadable = useBillingApiMutation({ + url: '/v2/administration/billing/cloud-plans/migration', + method: 'post', + }); + + return ( + + + + + {t('administration_plan_migration_configure')} + + + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationEdit.tsx new file mode 100644 index 0000000000..47f7ad9807 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationEdit.tsx @@ -0,0 +1,93 @@ +import { Box, Typography } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; + +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { + PlanMigrationForm, + PlanMigrationFormData, +} from 'tg.ee.module/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm'; +import { + useBillingApiMutation, + useBillingApiQuery, +} from 'tg.service/http/useQueryApi'; +import React from 'react'; +import { useMessage } from 'tg.hooks/useSuccessMessage'; +import { useHistory, useRouteMatch } from 'react-router-dom'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; + +export const AdministrationPlanMigrationEdit = () => { + const { t } = useTranslate(); + const match = useRouteMatch(); + const messaging = useMessage(); + const history = useHistory(); + const migrationId = match.params[PARAMS.PLAN_MIGRATION_ID] as number; + + const migrationLoadable = useBillingApiQuery({ + url: '/v2/administration/billing/cloud-plans/migration/{migrationId}', + method: 'get', + path: { migrationId }, + }); + + const updatePlanMigrationLoadable = useBillingApiMutation({ + url: '/v2/administration/billing/cloud-plans/migration/{migrationId}', + method: 'put', + }); + + if (migrationLoadable.isLoading) { + return ; + } + + const migration = migrationLoadable.data!; + + const submit = (values: PlanMigrationFormData) => { + updatePlanMigrationLoadable.mutate( + { + path: { migrationId }, + content: { 'application/json': values }, + }, + { + onSuccess: () => { + messaging.success( + + ); + history.push(LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS.build()); + }, + } + ); + }; + + return ( + + + + + {t('administration_plan_migration_configure_existing')} + + + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/viewsCloud/AdministrationCloudPlansView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/viewsCloud/AdministrationCloudPlansView.tsx index c6cc238a3a..5eef37a5e8 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/viewsCloud/AdministrationCloudPlansView.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/viewsCloud/AdministrationCloudPlansView.tsx @@ -9,7 +9,7 @@ import { Paper, styled, } from '@mui/material'; -import { X } from '@untitled-ui/icons-react'; +import { Settings01, X } from '@untitled-ui/icons-react'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { LINKS, PARAMS } from 'tg.constants/links'; @@ -26,6 +26,7 @@ import { PlanSubscriptionCount } from 'tg.ee.module/billing/component/Plan/PlanS import { PlanListPriceInfo } from 'tg.ee.module/billing/component/Plan/PlanListPriceInfo'; import { PlanArchivedChip } from 'tg.ee.module/billing/component/Plan/PlanArchivedChip'; import clsx from 'clsx'; +import { PlanMigratingChip } from 'tg.ee.module/billing/component/Plan/PlanMigratingChip'; type CloudPlanModel = components['schemas']['CloudPlanModel']; @@ -112,6 +113,20 @@ export const AdministrationCloudPlansView = () => { hideChildrenOnLoading={false} addLinkTo={LINKS.ADMINISTRATION_BILLING_CLOUD_PLAN_CREATE.build()} onAdd={() => {}} + customButtons={[ + , + ]} > {plansLoadable.data?._embedded?.plans?.map((plan, i) => ( @@ -130,6 +145,10 @@ export const AdministrationCloudPlansView = () => { + diff --git a/webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx b/webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx new file mode 100644 index 0000000000..a7c347c85d --- /dev/null +++ b/webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx @@ -0,0 +1,208 @@ +import { Box, Button, Chip, styled, Typography } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import { useBillingApiQuery } from 'tg.service/http/useQueryApi'; +import { ArrowRight, Settings01 } from '@untitled-ui/icons-react'; +import { PricePrimary } from 'tg.ee.module/billing/component/Price/PricePrimary'; +import React, { useState } from 'react'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import { FullWidthTooltip } from 'tg.component/common/FullWidthTooltip'; +import { LINKS } from 'tg.constants/links'; +import { Link } from 'react-router-dom'; +import clsx from 'clsx'; + +const TooltipText = styled('div')` + white-space: nowrap; +`; + +const TooltipTitle = styled('div')` + font-weight: bold; + font-size: 14px; + line-height: 17px; +`; + +const MigrationDetailBox = styled(Box)` + &.inactive { + opacity: 0.5; + } +`; + +export const PlanMigratingChip = ({ + migrationId, + isEnabled, +}: { + migrationId?: number; + isEnabled?: boolean; +}) => { + if (!migrationId) { + return null; + } + const [opened, setOpened] = useState(false); + const infoLoadable = useBillingApiQuery({ + url: '/v2/administration/billing/cloud-plans/migration/{migrationId}', + method: 'get', + path: { migrationId: migrationId }, + options: { + enabled: !!migrationId && opened, + }, + }); + + const info = infoLoadable.data; + + const { t } = useTranslate(); + return ( + setOpened(true)} + title={ + infoLoadable.isLoading ? ( + + + + ) : info ? ( + + + + {t('administration_plan_migration_details')} + + {isEnabled ? ( + + ) : ( + + )} + + + + + + {t('administration_plan_migration_from')} + + {info?.sourcePlan.name} + {info?.targetPlan.prices && ( + + + + )} + + } + /> + + + + {t('administration_plan_migration_to')} + + {info?.targetPlan.name} + {info?.targetPlan.prices && ( + + + + )} + + } + /> + + + + {t('administration_plan_migration_timing')} + + + + }} + /> + + + }} + /> + + + + + + + + + ) : ( + + + {t('administration_plan_migration_not_found')} + + + ) + } + > + + ) : ( + + ) + } + /> + + ); +}; diff --git a/webapp/src/eeSetup/eeModule.ee.tsx b/webapp/src/eeSetup/eeModule.ee.tsx index b42153833a..b8760e8fa5 100644 --- a/webapp/src/eeSetup/eeModule.ee.tsx +++ b/webapp/src/eeSetup/eeModule.ee.tsx @@ -68,6 +68,8 @@ import { ProjectSettingsTab } from '../views/projects/project/ProjectSettingsVie import { OperationAssignTranslationLabel } from '../ee/batchOperations/OperationAssignTranslationLabel'; import { OperationUnassignTranslationLabel } from '../ee/batchOperations/OperationUnassignTranslationLabel'; import { ProjectSettingsLabels } from '../ee/translationLabels/ProjectSettingsLabels'; +import { AdministrationPlanMigrationCreate } from '../ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationCreate'; +import { AdministrationPlanMigrationEdit } from '../ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationEdit'; export { TaskReference } from '../ee/task/components/TaskReference'; export { GlobalLimitPopover } from '../ee/billing/limitPopover/GlobalLimitPopover'; @@ -139,6 +141,18 @@ export const routes = { > + + + + + + Date: Thu, 17 Jul 2025 16:10:46 +0200 Subject: [PATCH 03/19] feat: self-hosted migrations controller and UI --- webapp/src/constants/links.tsx | 14 ++- .../PlanMigrationForm.tsx | 19 +++- .../fields/PlanSelectorField.tsx | 19 ++-- .../components/migration/types.ts | 1 + .../fields/SelfHostedEePlanSelector.tsx | 5 +- ...dministrationCloudPlanMigrationCreate.tsx} | 8 +- .../AdministrationCloudPlanMigrationEdit.tsx} | 8 +- ...trationSelfHostedEePlanMigrationCreate.tsx | 70 ++++++++++++++ ...istrationSelfHostedEePlanMigrationEdit.tsx | 94 +++++++++++++++++++ .../AdministrationCloudPlansView.tsx | 2 +- .../AdministrationEePlansView.tsx | 22 ++++- .../component/Plan/PlanMigratingChip.tsx | 30 +++++- webapp/src/eeSetup/eeModule.ee.tsx | 26 +++-- 13 files changed, 283 insertions(+), 35 deletions(-) rename webapp/src/ee/billing/administration/subscriptionPlans/components/{migrationForm => migration}/PlanMigrationForm.tsx (87%) rename webapp/src/ee/billing/administration/subscriptionPlans/components/{migrationForm => migration}/fields/PlanSelectorField.tsx (55%) create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/migration/types.ts rename webapp/src/ee/billing/administration/subscriptionPlans/migration/{AdministrationPlanMigrationCreate.tsx => cloud/AdministrationCloudPlanMigrationCreate.tsx} (88%) rename webapp/src/ee/billing/administration/subscriptionPlans/migration/{AdministrationPlanMigrationEdit.tsx => cloud/AdministrationCloudPlanMigrationEdit.tsx} (91%) create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 24266cd067..9f0a3f5e6f 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -247,16 +247,26 @@ export class LINKS { 'create' ); - static ADMINISTRATION_BILLING_PLAN_MIGRATION_CREATE = Link.ofParent( + static ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_CREATE = Link.ofParent( LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS, 'create-migration' ); - static ADMINISTRATION_BILLING_PLAN_MIGRATION_EDIT = Link.ofParent( + static ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_EDIT = Link.ofParent( LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS, 'migration/' + p(PARAMS.PLAN_MIGRATION_ID) ); + static ADMINISTRATION_BILLING_EE_PLAN_MIGRATION_CREATE = Link.ofParent( + LINKS.ADMINISTRATION_BILLING_EE_PLANS, + 'create-migration' + ); + + static ADMINISTRATION_BILLING_EE_PLAN_MIGRATION_EDIT = Link.ofParent( + LINKS.ADMINISTRATION_BILLING_EE_PLANS, + 'migration/' + p(PARAMS.PLAN_MIGRATION_ID) + ); + /** * Organizations */ diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx similarity index 87% rename from webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm.tsx rename to webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx index 5584d4ac91..950654c905 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx @@ -5,16 +5,20 @@ import React from 'react'; import { useTranslate } from '@tolgee/react'; import { components } from 'tg.service/billingApiSchema.generated'; import { ArrowRightIcon } from '@mui/x-date-pickers'; -import { PlanSelectorField } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migrationForm/fields/PlanSelectorField'; +import { PlanSelectorField } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/fields/PlanSelectorField'; import { TextField } from 'tg.component/common/form/fields/TextField'; import { Switch } from 'tg.component/common/form/fields/Switch'; +import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; -type MigrationModel = +type CloudPlanMigrationModel = components['schemas']['AdministrationCloudPlanMigrationModel']; +type SelfHostedEePlanMigrationModel = + components['schemas']['AdministrationSelfHostedEePlanMigrationModel']; type Props = { - migration?: MigrationModel; + migration?: CloudPlanMigrationModel | SelfHostedEePlanMigrationModel; onSubmit: (value: PlanMigrationFormData) => void; + planType?: PlanType; loading: boolean | undefined; }; @@ -29,7 +33,12 @@ const emptyDefaultValues: PlanMigrationFormData = { export type PlanMigrationFormData = components['schemas']['PlanMigrationRequest']; -export const PlanMigrationForm = ({ migration, onSubmit, loading }: Props) => { +export const PlanMigrationForm = ({ + migration, + onSubmit, + loading, + planType = 'cloud', +}: Props) => { const { t } = useTranslate(); const isUpdate = migration != null; const defaultValues: PlanMigrationFormData = migration @@ -78,6 +87,7 @@ export const PlanMigrationForm = ({ migration, onSubmit, loading }: Props) => { onPlanChange={(plan) => setSelectedSourcePlan(plan.id)} hiddenPlans={[selectedTargetPlan]} filterHasMigration={false} + type={planType} {...(migration && { plans: [migration.sourcePlan] })} /> @@ -89,6 +99,7 @@ export const PlanMigrationForm = ({ migration, onSubmit, loading }: Props) => { }} data-cy="target-plan-selector" onPlanChange={(plan) => setSelectedTargetPlan(plan.id)} + type={planType} hiddenPlans={[selectedSourcePlan]} /> diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/fields/PlanSelectorField.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/fields/PlanSelectorField.tsx similarity index 55% rename from webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/fields/PlanSelectorField.tsx rename to webapp/src/ee/billing/administration/subscriptionPlans/components/migration/fields/PlanSelectorField.tsx index c458543144..addc6852ec 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migrationForm/fields/PlanSelectorField.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/fields/PlanSelectorField.tsx @@ -1,20 +1,27 @@ import { useFormikContext } from 'formik'; import { CloudPlanSelector } from 'tg.ee.module/billing/administration/subscriptionPlans/components/planForm/cloud/fields/CloudPlanSelector'; -import { PlanMigrationFormData } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm'; +import { PlanMigrationFormData } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; import { GenericPlanSelector } from 'tg.ee.module/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector'; +import { SelfHostedEePlanSelector } from 'tg.ee.module/billing/administration/subscriptionPlans/components/planForm/selfHostedEe/fields/SelfHostedEePlanSelector'; +import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; export const PlanSelectorField = ({ name, + type = 'cloud', filterHasMigration, ...props -}: { name: string; filterHasMigration?: boolean } & Omit< - GenericPlanSelector, - 'onChange' ->) => { +}: { + name: string; + type?: PlanType; + filterHasMigration?: boolean; +} & Omit, 'onChange'>) => { const { setFieldValue, values } = useFormikContext(); + const Selector = + type === 'cloud' ? CloudPlanSelector : SelfHostedEePlanSelector; + return ( - , 'plans' - > & { organizationId?: number } -> = ({ organizationId, ...props }) => { + > & { organizationId?: number; filterHasMigration?: boolean } +> = ({ organizationId, filterHasMigration, ...props }) => { const plansLoadable = useBillingApiQuery({ url: '/v2/administration/billing/self-hosted-ee-plans', method: 'get', query: { filterAssignableToOrganization: organizationId, + filterHasMigration, }, }); diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationCreate.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx similarity index 88% rename from webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationCreate.tsx rename to webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx index 16f075713a..19ab9fa034 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationCreate.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx @@ -7,13 +7,13 @@ import { LINKS } from 'tg.constants/links'; import { PlanMigrationForm, PlanMigrationFormData, -} from 'tg.ee.module/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm'; +} from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; import { useBillingApiMutation } from 'tg.service/http/useQueryApi'; import React from 'react'; import { useMessage } from 'tg.hooks/useSuccessMessage'; import { useHistory } from 'react-router-dom'; -export const AdministrationPlanMigrationCreate = () => { +export const AdministrationCloudPlanMigrationCreate = () => { const { t } = useTranslate(); const messaging = useMessage(); const history = useHistory(); @@ -24,7 +24,7 @@ export const AdministrationPlanMigrationCreate = () => { { onSuccess: () => { messaging.success( - + ); history.push(LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS.build()); }, @@ -50,7 +50,7 @@ export const AdministrationPlanMigrationCreate = () => { ], [ t('administration_plan_migration_configure'), - LINKS.ADMINISTRATION_BILLING_PLAN_MIGRATION_CREATE.build(), + LINKS.ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_CREATE.build(), ], ]} > diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx similarity index 91% rename from webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationEdit.tsx rename to webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx index 47f7ad9807..51445b2c15 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationEdit.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx @@ -7,7 +7,7 @@ import { LINKS, PARAMS } from 'tg.constants/links'; import { PlanMigrationForm, PlanMigrationFormData, -} from 'tg.ee.module/billing/administration/subscriptionPlans/components/migrationForm/PlanMigrationForm'; +} from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; import { useBillingApiMutation, useBillingApiQuery, @@ -17,7 +17,7 @@ import { useMessage } from 'tg.hooks/useSuccessMessage'; import { useHistory, useRouteMatch } from 'react-router-dom'; import { SpinnerProgress } from 'tg.component/SpinnerProgress'; -export const AdministrationPlanMigrationEdit = () => { +export const AdministrationCloudPlanMigrationEdit = () => { const { t } = useTranslate(); const match = useRouteMatch(); const messaging = useMessage(); @@ -50,7 +50,7 @@ export const AdministrationPlanMigrationEdit = () => { { onSuccess: () => { messaging.success( - + ); history.push(LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS.build()); }, @@ -71,7 +71,7 @@ export const AdministrationPlanMigrationEdit = () => { ], [ t('administration_plan_migration_configure_existing'), - LINKS.ADMINISTRATION_BILLING_PLAN_MIGRATION_EDIT.build({ + LINKS.ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_EDIT.build({ [PARAMS.PLAN_MIGRATION_ID]: migrationId, }), ], diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate.tsx new file mode 100644 index 0000000000..dda01ce802 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate.tsx @@ -0,0 +1,70 @@ +import { Box, Typography } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; + +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; +import { LINKS } from 'tg.constants/links'; +import { + PlanMigrationForm, + PlanMigrationFormData, +} from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; +import { useBillingApiMutation } from 'tg.service/http/useQueryApi'; +import React from 'react'; +import { useMessage } from 'tg.hooks/useSuccessMessage'; +import { useHistory } from 'react-router-dom'; + +export const AdministrationSelfHostedEePlanMigrationCreate = () => { + const { t } = useTranslate(); + const messaging = useMessage(); + const history = useHistory(); + + const submit = (values: PlanMigrationFormData) => { + createPlanMigrationLoadable.mutate( + { content: { 'application/json': values } }, + { + onSuccess: () => { + messaging.success( + + ); + history.push(LINKS.ADMINISTRATION_BILLING_EE_PLANS.build()); + }, + } + ); + }; + + const createPlanMigrationLoadable = useBillingApiMutation({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration', + method: 'post', + }); + + return ( + + + + + {t('administration_plan_migration_configure')} + + + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx new file mode 100644 index 0000000000..4f5cec315d --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx @@ -0,0 +1,94 @@ +import { Box, Typography } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; + +import { DashboardPage } from 'tg.component/layout/DashboardPage'; +import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { + PlanMigrationForm, + PlanMigrationFormData, +} from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; +import { + useBillingApiMutation, + useBillingApiQuery, +} from 'tg.service/http/useQueryApi'; +import React from 'react'; +import { useMessage } from 'tg.hooks/useSuccessMessage'; +import { useHistory, useRouteMatch } from 'react-router-dom'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; + +export const AdministrationSelfHostedEePlanMigrationEdit = () => { + const { t } = useTranslate(); + const match = useRouteMatch(); + const messaging = useMessage(); + const history = useHistory(); + const migrationId = match.params[PARAMS.PLAN_MIGRATION_ID] as number; + + const migrationLoadable = useBillingApiQuery({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}', + method: 'get', + path: { migrationId }, + }); + + const updatePlanMigrationLoadable = useBillingApiMutation({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}', + method: 'put', + }); + + if (migrationLoadable.isLoading) { + return ; + } + + const migration = migrationLoadable.data!; + + const submit = (values: PlanMigrationFormData) => { + updatePlanMigrationLoadable.mutate( + { + path: { migrationId }, + content: { 'application/json': values }, + }, + { + onSuccess: () => { + messaging.success( + + ); + history.push(LINKS.ADMINISTRATION_BILLING_EE_PLANS.build()); + }, + } + ); + }; + + return ( + + + + + {t('administration_plan_migration_configure_existing')} + + + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/viewsCloud/AdministrationCloudPlansView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/viewsCloud/AdministrationCloudPlansView.tsx index 5eef37a5e8..acd96df044 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/viewsCloud/AdministrationCloudPlansView.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/viewsCloud/AdministrationCloudPlansView.tsx @@ -121,7 +121,7 @@ export const AdministrationCloudPlansView = () => { startIcon={} component={Link} color="warning" - to={LINKS.ADMINISTRATION_BILLING_PLAN_MIGRATION_CREATE.build()} + to={LINKS.ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_CREATE.build()} data-cy="administration-cloud-plans-create-migration" > {t('administration_cloud_plan_create_migration')} diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/viewsSelfHostedEe/AdministrationEePlansView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/viewsSelfHostedEe/AdministrationEePlansView.tsx index 4469831ab2..1b45943667 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/viewsSelfHostedEe/AdministrationEePlansView.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/viewsSelfHostedEe/AdministrationEePlansView.tsx @@ -10,7 +10,7 @@ import { Paper, styled, } from '@mui/material'; -import { X } from '@untitled-ui/icons-react'; +import { Settings01, X } from '@untitled-ui/icons-react'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { LINKS, PARAMS } from 'tg.constants/links'; @@ -26,6 +26,7 @@ import { PlanSubscriptionCount } from 'tg.ee.module/billing/component/Plan/PlanS import { PlanListPriceInfo } from 'tg.ee.module/billing/component/Plan/PlanListPriceInfo'; import { PlanArchivedChip } from 'tg.ee.module/billing/component/Plan/PlanArchivedChip'; import clsx from 'clsx'; +import { PlanMigratingChip } from 'tg.ee.module/billing/component/Plan/PlanMigratingChip'; type SelfHostedEePlanAdministrationModel = components['schemas']['SelfHostedEePlanAdministrationModel']; @@ -113,6 +114,20 @@ export const AdministrationEePlansView = () => { hideChildrenOnLoading={false} addLinkTo={LINKS.ADMINISTRATION_BILLING_EE_PLAN_CREATE.build()} onAdd={() => {}} + customButtons={[ + , + ]} > {plansLoadable.data?._embedded?.plans?.map((plan, i) => ( @@ -137,6 +152,11 @@ export const AdministrationEePlansView = () => { label={t('administration_ee_plan_public_badge')} /> )} + diff --git a/webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx b/webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx index a7c347c85d..2fff260e1e 100644 --- a/webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx +++ b/webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx @@ -9,6 +9,7 @@ import { FullWidthTooltip } from 'tg.component/common/FullWidthTooltip'; import { LINKS } from 'tg.constants/links'; import { Link } from 'react-router-dom'; import clsx from 'clsx'; +import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; const TooltipText = styled('div')` white-space: nowrap; @@ -29,24 +30,43 @@ const MigrationDetailBox = styled(Box)` export const PlanMigratingChip = ({ migrationId, isEnabled, + planType = 'cloud', }: { migrationId?: number; isEnabled?: boolean; + planType?: PlanType; }) => { if (!migrationId) { return null; } const [opened, setOpened] = useState(false); - const infoLoadable = useBillingApiQuery({ + const infoCloudLoadable = useBillingApiQuery({ url: '/v2/administration/billing/cloud-plans/migration/{migrationId}', method: 'get', path: { migrationId: migrationId }, options: { - enabled: !!migrationId && opened, + enabled: planType == 'cloud' && !!migrationId && opened, }, }); - const info = infoLoadable.data; + const infoSelfHostedEeLoadable = useBillingApiQuery({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}', + method: 'get', + path: { migrationId: migrationId }, + options: { + enabled: planType == 'self-hosted' && !!migrationId && opened, + }, + }); + + const loadable = + planType == 'cloud' ? infoCloudLoadable : infoSelfHostedEeLoadable; + + const info = loadable.data; + + const configureLink = + planType == 'cloud' + ? LINKS.ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_EDIT + : LINKS.ADMINISTRATION_BILLING_EE_PLAN_MIGRATION_EDIT; const { t } = useTranslate(); return ( @@ -54,7 +74,7 @@ export const PlanMigratingChip = ({ sx={{ maxWidth: 'initial' }} onOpen={() => setOpened(true)} title={ - infoLoadable.isLoading ? ( + loadable.isLoading ? ( @@ -172,7 +192,7 @@ export const PlanMigratingChip = ({ startIcon={} component={Link} color="warning" - to={LINKS.ADMINISTRATION_BILLING_PLAN_MIGRATION_EDIT.build({ + to={configureLink.build({ migrationId: info.id, })} data-cy="administration-cloud-plans-create-migration" diff --git a/webapp/src/eeSetup/eeModule.ee.tsx b/webapp/src/eeSetup/eeModule.ee.tsx index b8760e8fa5..0489eed74b 100644 --- a/webapp/src/eeSetup/eeModule.ee.tsx +++ b/webapp/src/eeSetup/eeModule.ee.tsx @@ -68,8 +68,10 @@ import { ProjectSettingsTab } from '../views/projects/project/ProjectSettingsVie import { OperationAssignTranslationLabel } from '../ee/batchOperations/OperationAssignTranslationLabel'; import { OperationUnassignTranslationLabel } from '../ee/batchOperations/OperationUnassignTranslationLabel'; import { ProjectSettingsLabels } from '../ee/translationLabels/ProjectSettingsLabels'; -import { AdministrationPlanMigrationCreate } from '../ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationCreate'; -import { AdministrationPlanMigrationEdit } from '../ee/billing/administration/subscriptionPlans/migration/AdministrationPlanMigrationEdit'; +import { AdministrationCloudPlanMigrationCreate } from '../ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate'; +import { AdministrationCloudPlanMigrationEdit } from '../ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit'; +import { AdministrationSelfHostedEePlanMigrationCreate } from '../ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate'; +import { AdministrationSelfHostedEePlanMigrationEdit } from '../ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit'; export { TaskReference } from '../ee/task/components/TaskReference'; export { GlobalLimitPopover } from '../ee/billing/limitPopover/GlobalLimitPopover'; @@ -143,15 +145,15 @@ export const routes = { - + - + + + + + + + Date: Wed, 23 Jul 2025 16:40:57 +0200 Subject: [PATCH 04/19] chore: test plan migration --- .../io/tolgee/component/CurrentDateProvider.kt | 7 +++++++ .../io/tolgee/component/SchedulingManager.kt | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt index b7fd11c41f..fb14b5c56a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt @@ -16,6 +16,8 @@ import org.springframework.stereotype.Component import org.springframework.transaction.PlatformTransactionManager import java.sql.Timestamp import java.time.Duration +import java.time.LocalDate +import java.time.ZoneId.systemDefault import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.temporal.TemporalAccessor @@ -84,6 +86,11 @@ class CurrentDateProvider( return forcedDate ?: Date() } + val localDate: LocalDate + get() { + return date.toInstant().atZone(systemDefault()).toLocalDate() + } + override fun getNow(): Optional { return Optional.of(date.toInstant()) } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/SchedulingManager.kt b/backend/data/src/main/kotlin/io/tolgee/component/SchedulingManager.kt index d93dfd0c41..01fa4fe9cb 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/SchedulingManager.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/SchedulingManager.kt @@ -3,6 +3,7 @@ package io.tolgee.component import io.tolgee.util.Logging import io.tolgee.util.logger import jakarta.annotation.PreDestroy +import org.springframework.scheduling.support.CronTrigger import org.springframework.stereotype.Component import java.time.Duration import java.util.* @@ -46,6 +47,19 @@ class SchedulingManager( return id } + fun scheduleWithCron( + runnable: Runnable, + cron: String, + ): String { + val future = taskScheduler.schedule(runnable, CronTrigger(cron)) + if (future == null) { + throw IllegalStateException("Future from scheduler was null") + } + val id = UUID.randomUUID().toString() + scheduledTasks[id] = future + return id + } + @PreDestroy fun cancelAll() { Companion.cancelAll() From 23561c5e55be36d2da193f4994f9533b5dc3866a Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Wed, 30 Jul 2025 09:42:52 +0200 Subject: [PATCH 05/19] draft: migration history --- .../common/table/PaginatedHateoasTable.tsx | 28 ++++++-- .../migration/PlanMigrationForm.tsx | 3 +- .../AdministrationCloudPlanMigrationEdit.tsx | 64 ++++++++++++++++++- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/webapp/src/component/common/table/PaginatedHateoasTable.tsx b/webapp/src/component/common/table/PaginatedHateoasTable.tsx index 9d2529683c..f24e286619 100644 --- a/webapp/src/component/common/table/PaginatedHateoasTable.tsx +++ b/webapp/src/component/common/table/PaginatedHateoasTable.tsx @@ -1,4 +1,4 @@ -import React, { FC, JSXElementConstructor } from 'react'; +import React, { FC, JSXElementConstructor, ReactNode } from 'react'; import { HateoasListData, HateoasPaginatedData, @@ -8,7 +8,7 @@ import { PaginatedHateoasList, PaginatedHateoasListProps, } from '../list/PaginatedHateoasList'; -import { Table, TableBody } from '@mui/material'; +import { Table, TableBody, TableHead } from '@mui/material'; export type PaginatedHateoasTableProps< WrapperComponent extends @@ -19,7 +19,9 @@ export type PaginatedHateoasTableProps< > = Omit< PaginatedHateoasListProps, 'listComponent' ->; +> & { + tableHead?: ReactNode; +}; export const PaginatedHateoasTable = < WrapperComponent extends @@ -30,17 +32,31 @@ export const PaginatedHateoasTable = < >( props: PaginatedHateoasTableProps ) => { + const { tableHead, ...rest } = props; return ( ( + + )} + {...rest} /> ); }; -const PaginatedHateoasTableListComponent: FC = ({ children }) => { +interface PaginatedHateoasTableListComponentProps { + children: ReactNode; + tableHead?: ReactNode; +} + +const PaginatedHateoasTableListComponent: FC< + PaginatedHateoasTableListComponentProps +> = ({ children, tableHead }) => { return ( + {tableHead && {tableHead}} {children}
); diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx index 950654c905..28e1d24000 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx @@ -10,8 +10,7 @@ import { TextField } from 'tg.component/common/form/fields/TextField'; import { Switch } from 'tg.component/common/form/fields/Switch'; import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; -type CloudPlanMigrationModel = - components['schemas']['AdministrationCloudPlanMigrationModel']; +type CloudPlanMigrationModel = components['schemas']['CloudPlanMigrationModel']; type SelfHostedEePlanMigrationModel = components['schemas']['AdministrationSelfHostedEePlanMigrationModel']; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx index 51445b2c15..452ed21348 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx @@ -1,4 +1,4 @@ -import { Box, Typography } from '@mui/material'; +import { Box, Link, TableCell, TableRow, Typography } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; @@ -12,10 +12,12 @@ import { useBillingApiMutation, useBillingApiQuery, } from 'tg.service/http/useQueryApi'; -import React from 'react'; +import React, { useState } from 'react'; import { useMessage } from 'tg.hooks/useSuccessMessage'; import { useHistory, useRouteMatch } from 'react-router-dom'; import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import { EmptyState } from 'tg.component/common/EmptyState'; +import { PaginatedHateoasTable } from 'tg.component/common/table/PaginatedHateoasTable'; export const AdministrationCloudPlanMigrationEdit = () => { const { t } = useTranslate(); @@ -23,6 +25,7 @@ export const AdministrationCloudPlanMigrationEdit = () => { const messaging = useMessage(); const history = useHistory(); const migrationId = match.params[PARAMS.PLAN_MIGRATION_ID] as number; + const [subscriptionsPage, setSubscriptionsPage] = useState(0); const migrationLoadable = useBillingApiQuery({ url: '/v2/administration/billing/cloud-plans/migration/{migrationId}', @@ -30,6 +33,19 @@ export const AdministrationCloudPlanMigrationEdit = () => { path: { migrationId }, }); + const subscriptions = useBillingApiQuery({ + url: '/v2/administration/billing/cloud-plans/migration/{migrationId}/subscriptions', + method: 'get', + path: { migrationId }, + query: { + page: subscriptionsPage, + size: 10, + }, + options: { + keepPreviousData: true, + }, + }); + const updatePlanMigrationLoadable = useBillingApiMutation({ url: '/v2/administration/billing/cloud-plans/migration/{migrationId}', method: 'put', @@ -87,6 +103,50 @@ export const AdministrationCloudPlanMigrationEdit = () => { onSubmit={submit} loading={updatePlanMigrationLoadable.isLoading} /> + + + {t('administration_plan_migration_migrated_subscriptions')} + + + + {t('global_organization')} + {t('administration_plan_migration_from')} + {t('administration_plan_migration_to')} + + {t('administration_plan_migrated_subscription_status')} + + + } + renderItem={(item) => ( + + + + {item.organizationName} + {' '} + + {item.originPlan} + {item.plan} + {item.status} + + )} + emptyPlaceholder={ + + + + } + /> ); From 44299a864294ee1c589f3ec2a64481e66fe2e34e Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Thu, 31 Jul 2025 17:40:41 +0200 Subject: [PATCH 06/19] chore: option to delete selfhosted plan migration, handle customer updates when subscription scheduled, complete schedule on webhook event --- e2e/cypress/support/dataCyType.d.ts | 1 + .../migration/PlanMigrationForm.tsx | 27 ++++++++++++++++--- .../AdministrationCloudPlanMigrationEdit.tsx | 20 ++++++++++++++ ...istrationSelfHostedEePlanMigrationEdit.tsx | 20 ++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 0cc3bf3ffc..7f09aebb61 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -265,6 +265,7 @@ declare namespace DataCy { "create-task-submit" | "dashboard-projects-list-item" | "default-namespace-select" | + "delete-plan-migration-button" | "delete-user-button" | "developer-menu-content-delivery" | "developer-menu-storage" | diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx index 28e1d24000..c29fa31558 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx @@ -1,14 +1,15 @@ import { Form, Formik } from 'formik'; -import { Box, Typography } from '@mui/material'; +import { Box, Button, Typography } from '@mui/material'; import LoadingButton from 'tg.component/common/form/LoadingButton'; import React from 'react'; -import { useTranslate } from '@tolgee/react'; +import { T, useTranslate } from '@tolgee/react'; import { components } from 'tg.service/billingApiSchema.generated'; import { ArrowRightIcon } from '@mui/x-date-pickers'; import { PlanSelectorField } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/fields/PlanSelectorField'; import { TextField } from 'tg.component/common/form/fields/TextField'; import { Switch } from 'tg.component/common/form/fields/Switch'; import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; +import { confirmation } from 'tg.hooks/confirmation'; type CloudPlanMigrationModel = components['schemas']['CloudPlanMigrationModel']; type SelfHostedEePlanMigrationModel = @@ -17,6 +18,7 @@ type SelfHostedEePlanMigrationModel = type Props = { migration?: CloudPlanMigrationModel | SelfHostedEePlanMigrationModel; onSubmit: (value: PlanMigrationFormData) => void; + onDelete?: (id: number) => void; planType?: PlanType; loading: boolean | undefined; }; @@ -36,6 +38,7 @@ export const PlanMigrationForm = ({ migration, onSubmit, loading, + onDelete, planType = 'cloud', }: Props) => { const { t } = useTranslate(); @@ -125,7 +128,25 @@ export const PlanMigrationForm = ({ required /> - + + {migration && isUpdate && ( + + )} { method: 'put', }); + const deletePlanMigrationMutation = useBillingApiMutation({ + url: '/v2/administration/billing/cloud-plans/migration/{migrationId}', + method: 'delete', + }); + + const onDelete = (migrationId: number) => { + deletePlanMigrationMutation.mutate( + { path: { migrationId } }, + { + onSuccess: () => { + messaging.success( + + ); + history.push(LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS.build()); + }, + } + ); + }; + if (migrationLoadable.isLoading) { return ; } @@ -101,6 +120,7 @@ export const AdministrationCloudPlanMigrationEdit = () => { diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx index 4f5cec315d..eede03d961 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx @@ -35,6 +35,25 @@ export const AdministrationSelfHostedEePlanMigrationEdit = () => { method: 'put', }); + const deletePlanMigrationMutation = useBillingApiMutation({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}', + method: 'delete', + }); + + const onDelete = (migrationId: number) => { + deletePlanMigrationMutation.mutate( + { path: { migrationId } }, + { + onSuccess: () => { + messaging.success( + + ); + history.push(LINKS.ADMINISTRATION_BILLING_EE_PLANS.build()); + }, + } + ); + }; + if (migrationLoadable.isLoading) { return ; } @@ -85,6 +104,7 @@ export const AdministrationSelfHostedEePlanMigrationEdit = () => { From 506a022e8feb3202dd773c4669df3ba2fda2d0bf Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Fri, 1 Aug 2025 14:19:42 +0200 Subject: [PATCH 07/19] chore: real stripe test to schedule update and preview update --- .../src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt index fb14b5c56a..ec9cc2ff89 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/CurrentDateProvider.kt @@ -88,7 +88,7 @@ class CurrentDateProvider( val localDate: LocalDate get() { - return date.toInstant().atZone(systemDefault()).toLocalDate() + return (forcedDate ?: date).toInstant().atZone(systemDefault()).toLocalDate() } override fun getNow(): Optional { From b255f09889e5febed423b3c9ce298fbb007191ba Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Mon, 4 Aug 2025 16:38:30 +0200 Subject: [PATCH 08/19] feat: self-hosted plans - migrated subscriptions list --- .../kotlin/io/tolgee/constants/Message.kt | 1 + .../AdministrationCloudPlanMigrationEdit.tsx | 15 +++- .../migration/general/PlanMigrationStatus.tsx | 25 ++++++ ...istrationSelfHostedEePlanMigrationEdit.tsx | 77 ++++++++++++++++++- 4 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt index 867fb49da9..a0b05f650d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -309,6 +309,7 @@ enum class Message { DUPLICATE_SUGGESTION, UNSUPPORTED_MEDIA_TYPE, PLAN_MIGRATION_NOT_FOUND, + PLAN_HAS_MIGRATIONS, ; val code: String diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx index 0fc7c9e65a..6ac0cedcf3 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx @@ -18,12 +18,15 @@ import { useHistory, useRouteMatch } from 'react-router-dom'; import { SpinnerProgress } from 'tg.component/SpinnerProgress'; import { EmptyState } from 'tg.component/common/EmptyState'; import { PaginatedHateoasTable } from 'tg.component/common/table/PaginatedHateoasTable'; +import { useDateFormatter } from 'tg.hooks/useLocale'; +import { PlanMigrationStatus } from 'tg.ee.module/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus'; export const AdministrationCloudPlanMigrationEdit = () => { const { t } = useTranslate(); const match = useRouteMatch(); const messaging = useMessage(); const history = useHistory(); + const formatDate = useDateFormatter(); const migrationId = match.params[PARAMS.PLAN_MIGRATION_ID] as number; const [subscriptionsPage, setSubscriptionsPage] = useState(0); @@ -137,6 +140,7 @@ export const AdministrationCloudPlanMigrationEdit = () => { {t('global_organization')} {t('administration_plan_migration_from')} {t('administration_plan_migration_to')} + {t('administration_plan_migrated_at')} {t('administration_plan_migrated_subscription_status')} @@ -155,7 +159,16 @@ export const AdministrationCloudPlanMigrationEdit = () => { {item.originPlan} {item.plan} - {item.status} + + {formatDate(item.migratedAt, { + timeZone: 'UTC', + dateStyle: 'short', + timeStyle: 'short', + })} + + + + )} emptyPlaceholder={ diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx new file mode 100644 index 0000000000..b03b05e9c6 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx @@ -0,0 +1,25 @@ +import { components } from 'tg.service/billingApiSchema.generated'; +import { Chip } from '@mui/material'; +import { useTranslate } from '@tolgee/react'; + +type Status = components['schemas']['CloudPlanMigrationHistoryModel']['status']; + +type Props = { + status: Status; +}; + +const colors = { + COMPLETED: 'success', +}; + +const translates = { + COMPLETED: 'administration_plan_migration_status_completed', + SCHEDULED: 'administration_plan_migration_status_scheduled', +}; + +export const PlanMigrationStatus = ({ status }: Props) => { + const { t } = useTranslate(); + return ( + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx index eede03d961..967f68e652 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx @@ -1,4 +1,4 @@ -import { Box, Typography } from '@mui/material'; +import { Box, Link, TableCell, TableRow, Typography } from '@mui/material'; import { T, useTranslate } from '@tolgee/react'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; @@ -12,17 +12,23 @@ import { useBillingApiMutation, useBillingApiQuery, } from 'tg.service/http/useQueryApi'; -import React from 'react'; +import React, { useState } from 'react'; import { useMessage } from 'tg.hooks/useSuccessMessage'; import { useHistory, useRouteMatch } from 'react-router-dom'; import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import { PaginatedHateoasTable } from 'tg.component/common/table/PaginatedHateoasTable'; +import { PlanMigrationStatus } from 'tg.ee.module/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus'; +import { EmptyState } from 'tg.component/common/EmptyState'; +import { useDateFormatter } from 'tg.hooks/useLocale'; export const AdministrationSelfHostedEePlanMigrationEdit = () => { const { t } = useTranslate(); const match = useRouteMatch(); const messaging = useMessage(); const history = useHistory(); + const formatDate = useDateFormatter(); const migrationId = match.params[PARAMS.PLAN_MIGRATION_ID] as number; + const [subscriptionsPage, setSubscriptionsPage] = useState(0); const migrationLoadable = useBillingApiQuery({ url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}', @@ -30,6 +36,19 @@ export const AdministrationSelfHostedEePlanMigrationEdit = () => { path: { migrationId }, }); + const subscriptions = useBillingApiQuery({ + url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}/subscriptions', + method: 'get', + path: { migrationId }, + query: { + page: subscriptionsPage, + size: 10, + }, + options: { + keepPreviousData: true, + }, + }); + const updatePlanMigrationLoadable = useBillingApiMutation({ url: '/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}', method: 'put', @@ -108,6 +127,60 @@ export const AdministrationSelfHostedEePlanMigrationEdit = () => { loading={updatePlanMigrationLoadable.isLoading} planType="self-hosted" /> + + + {t('administration_plan_migration_migrated_subscriptions')} + + + + {t('global_organization')} + {t('administration_plan_migration_from')} + {t('administration_plan_migration_to')} + {t('administration_plan_migrated_at')} + + {t('administration_plan_migrated_subscription_status')} + + + } + renderItem={(item) => ( + + + + {item.organizationName} + {' '} + + {item.originPlan} + {item.plan} + + {formatDate(item.migratedAt, { + timeZone: 'UTC', + dateStyle: 'short', + timeStyle: 'short', + })} + + + + + + )} + emptyPlaceholder={ + + + + } + /> ); From d61a02f24fa5276f18d4a5a8f2401e4a4ec8146c Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 5 Aug 2025 11:53:26 +0200 Subject: [PATCH 09/19] feat: send plan change notice when migrated --- backend/data/src/main/resources/I18n_en.properties | 11 +++++++++++ .../main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/backend/data/src/main/resources/I18n_en.properties b/backend/data/src/main/resources/I18n_en.properties index 713a3522a8..af5ff7b5fd 100644 --- a/backend/data/src/main/resources/I18n_en.properties +++ b/backend/data/src/main/resources/I18n_en.properties @@ -113,3 +113,14 @@ notifications.email.security-settings-link=Check your security settings
\ +We’d like to let you know that starting {0}, your current plan will be automatically updated.
\ +Current plan: {1} (€{2}/mo)
\ +New plan: {3} (€{4}/mo)
\ +
\ +If you’d prefer to switch to a different plan, you can easily do so in
Subscriptions
\ +
\ +If you’re not happy with this change, please email us at {6} and we’ll try to find a solution together.
\ +Regards,
\ +Tolgee diff --git a/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt b/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt index e8421d0cdd..ab72d76836 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt @@ -66,6 +66,10 @@ class EmailTestUtil() { verify(javaMailSender).send(any()) } + fun verifyTimesEmailSent(num: Int) { + verify(javaMailSender, times(num)).send(any()) + } + val assertEmailTo: AbstractStringAssert<*> get() { @Suppress("CAST_NEVER_SUCCEEDS") From 1ed447b7b0b6a920d6be6ec1930a78e5a094f1ce Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Wed, 6 Aug 2025 16:40:53 +0200 Subject: [PATCH 10/19] chore: migration history refactored --- .../migration/PlanMigrationForm.tsx | 50 +++++++++++-------- .../AdministrationCloudPlanMigrationEdit.tsx | 2 +- .../migration/general/PlanMigrationStatus.tsx | 2 +- ...istrationSelfHostedEePlanMigrationEdit.tsx | 2 +- 4 files changed, 32 insertions(+), 24 deletions(-) diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx index c29fa31558..711f50e8bc 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx @@ -10,6 +10,7 @@ import { TextField } from 'tg.component/common/form/fields/TextField'; import { Switch } from 'tg.component/common/form/fields/Switch'; import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; import { confirmation } from 'tg.hooks/confirmation'; +import { LabelHint } from 'tg.component/common/LabelHint'; type CloudPlanMigrationModel = components['schemas']['CloudPlanMigrationModel']; type SelfHostedEePlanMigrationModel = @@ -105,9 +106,14 @@ export const PlanMigrationForm = ({ hiddenPlans={[selectedSourcePlan]} />
- - {t('administration_plan_migration_run_configuration')} - + + + + {t('administration_plan_migration_run_configuration')} + + + + - {migration && isUpdate && ( - - )} + + {migration && isUpdate && ( + + )} + { {item.originPlan} {item.plan} - {formatDate(item.migratedAt, { + {formatDate(item.scheduledAt, { timeZone: 'UTC', dateStyle: 'short', timeStyle: 'short', diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx index b03b05e9c6..f3c707586e 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx @@ -2,7 +2,7 @@ import { components } from 'tg.service/billingApiSchema.generated'; import { Chip } from '@mui/material'; import { useTranslate } from '@tolgee/react'; -type Status = components['schemas']['CloudPlanMigrationHistoryModel']['status']; +type Status = components['schemas']['PlanMigrationHistoryModel']['status']; type Props = { status: Status; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx index 967f68e652..8bb866baaf 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx @@ -161,7 +161,7 @@ export const AdministrationSelfHostedEePlanMigrationEdit = () => { {item.originPlan} {item.plan} - {formatDate(item.migratedAt, { + {formatDate(item.scheduledAt, { timeZone: 'UTC', dateStyle: 'short', timeStyle: 'short', From 0228ce68af36e62895e1074855fdb8216619b518 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Thu, 7 Aug 2025 10:02:49 +0200 Subject: [PATCH 11/19] chore: migrator cache fix --- backend/data/src/main/resources/I18n_en.properties | 10 ++++++---- webapp/src/constants/GlobalValidationSchema.tsx | 14 ++++++++++++++ .../components/migration/PlanMigrationForm.tsx | 4 ++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/backend/data/src/main/resources/I18n_en.properties b/backend/data/src/main/resources/I18n_en.properties index af5ff7b5fd..eda490fd29 100644 --- a/backend/data/src/main/resources/I18n_en.properties +++ b/backend/data/src/main/resources/I18n_en.properties @@ -114,13 +114,15 @@ notifications.email.mfa.MFA_ENABLED=Multi-factor authentication has been enabled notifications.email.mfa.MFA_DISABLED=Multi-factor authentication has been disabled for your account. notifications.email.password-changed=Password has been changed for your account. -notifications.email.plan-migration=Hello! 👋

\ +notifications.email.plan-migration-subject=Your 🐁 plan will be updated on {0} +notifications.email.plan-migration-body=Hello! 👋

\ We’d like to let you know that starting {0}, your current plan will be automatically updated.
\ Current plan: {1} (€{2}/mo)
\ New plan: {3} (€{4}/mo)
\
\ -If you’d prefer to switch to a different plan, you can easily do so in Subscriptions
\ -
\ -If you’re not happy with this change, please email us at {6} and we’ll try to find a solution together.
\ +If you’d prefer to switch to a different plan, you can easily do so in Subscriptions\ +

\ +If you’re not happy with this change, please email us at {6} and we’ll try to find a solution together.\ +

\ Regards,
\ Tolgee diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index cf44a5c4c8..f05f1c089b 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -39,6 +39,14 @@ Yup.setLocale({ /> ), }, + number: { + min: ({ min }) => ( + + ), + }, }); export class Validation { @@ -521,6 +529,12 @@ export class Validation { .required() .matches(/^#[0-9A-F]{6}$/i, t('validation_invalid_hex_color')), }); + + static readonly PLAN_MIRATION_FORM = () => + Yup.object().shape({ + monthlyOffsetDays: Yup.number().required().min(0), + yearlyOffsetDays: Yup.number().required().min(0), + }); } let GLOBAL_VALIDATION_DEBOUNCE_TIMER: any = undefined; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx index 711f50e8bc..6d076dcb34 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx @@ -11,6 +11,7 @@ import { Switch } from 'tg.component/common/form/fields/Switch'; import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; import { confirmation } from 'tg.hooks/confirmation'; import { LabelHint } from 'tg.component/common/LabelHint'; +import { Validation } from 'tg.constants/GlobalValidationSchema'; type CloudPlanMigrationModel = components['schemas']['CloudPlanMigrationModel']; type SelfHostedEePlanMigrationModel = @@ -66,6 +67,7 @@ export const PlanMigrationForm = ({ initialValues={defaultValues} enableReinitialize onSubmit={onSubmit} + validationSchema={Validation.PLAN_MIRATION_FORM} >
@@ -122,7 +124,6 @@ export const PlanMigrationForm = ({ InputProps={{ endAdornment: {t('global_days')}, }} - required /> {t('global_days')}, }} - required /> From a395bb3508294966d0d0dcf81a9321d149cd31a9 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Thu, 7 Aug 2025 16:56:28 +0200 Subject: [PATCH 12/19] chore: e2e tests - administration plan migrations --- e2e/cypress/support/dataCyType.d.ts | 6 ++++-- .../components/migration/PlanMigrationForm.tsx | 4 ++-- .../planForm/genericFields/GenericPlanSelector.tsx | 4 +++- .../viewsCloud/AdministrationCloudPlansView.tsx | 2 +- .../viewsSelfHostedEe/AdministrationEePlansView.tsx | 2 +- webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx | 6 +++--- webapp/src/translationTools/useErrorTranslation.ts | 2 ++ 7 files changed, 16 insertions(+), 10 deletions(-) diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index 7f09aebb61..7107b39b32 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -40,13 +40,11 @@ declare namespace DataCy { "administration-cloud-plan-field-select-existing-stripe-product" | "administration-cloud-plan-field-type" | "administration-cloud-plan-field-type-item" | - "administration-cloud-plans-create-migration" | "administration-cloud-plans-item" | "administration-cloud-plans-item-archive" | "administration-cloud-plans-item-archived-badge" | "administration-cloud-plans-item-delete" | "administration-cloud-plans-item-edit" | - "administration-cloud-plans-item-is-migrating-badge" | "administration-cloud-plans-item-public-badge" | "administration-create-custom-plan-button" | "administration-customize-plan-switch" | @@ -82,6 +80,9 @@ declare namespace DataCy { "administration-plan-field-stripe-product" | "administration-plan-field-stripe-product-name" | "administration-plan-selector" | + "administration-plans-create-migration" | + "administration-plans-edit-migration" | + "administration-plans-item-is-migrating-badge" | "administration-subscriptions-active-self-hosted-ee-plan" | "administration-subscriptions-assign-plan-save-button" | "administration-subscriptions-cloud-plan-name" | @@ -561,6 +562,7 @@ declare namespace DataCy { "permissions-menu-save" | "plan-limit-dialog-close" | "plan-limit-exceeded-popover" | + "plan-migration-tooltip-detail" | "plan_seat_limit_exceeded_while_accepting_invitation_message" | "project-ai-prompt-dialog-description-input" | "project-ai-prompt-dialog-save" | diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx index 6d076dcb34..058fb48824 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx @@ -88,7 +88,7 @@ export const PlanMigrationForm = ({ required: true, disabled: isUpdate, }} - data-cy="source-plan-selector" + dataCy="source-plan-selector" onPlanChange={(plan) => setSelectedSourcePlan(plan.id)} hiddenPlans={[selectedTargetPlan]} filterHasMigration={false} @@ -102,7 +102,7 @@ export const PlanMigrationForm = ({ label: t('administration_plan_migration_target_plan'), required: true, }} - data-cy="target-plan-selector" + dataCy="target-plan-selector" onPlanChange={(plan) => setSelectedTargetPlan(plan.id)} type={planType} hiddenPlans={[selectedSourcePlan]} diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx index 44ed204797..161f46d8d1 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx @@ -16,6 +16,7 @@ export interface GenericPlanSelector { selectProps?: React.ComponentProps[`SelectProps`]; plans?: T[]; hiddenPlans?: number[]; + dataCy?: string; } export const GenericPlanSelector = ({ @@ -25,6 +26,7 @@ export const GenericPlanSelector = ({ onPlanChange, plans, hiddenPlans, + dataCy = 'administration-plan-selector', }: GenericPlanSelector) => { if (!plans) { return ( @@ -60,7 +62,7 @@ export const GenericPlanSelector = ({ return ( { component={Link} color="warning" to={LINKS.ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_CREATE.build()} - data-cy="administration-cloud-plans-create-migration" + data-cy="administration-plans-create-migration" > {t('administration_cloud_plan_create_migration')} , diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/viewsSelfHostedEe/AdministrationEePlansView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/viewsSelfHostedEe/AdministrationEePlansView.tsx index 1b45943667..4e03045b8c 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/viewsSelfHostedEe/AdministrationEePlansView.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/viewsSelfHostedEe/AdministrationEePlansView.tsx @@ -123,7 +123,7 @@ export const AdministrationEePlansView = () => { component={Link} color="warning" to={LINKS.ADMINISTRATION_BILLING_EE_PLAN_MIGRATION_CREATE.build()} - data-cy="administration-cloud-plans-create-migration" + data-cy="administration-plans-create-migration" > {t('administration_cloud_plan_create_migration')} , diff --git a/webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx b/webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx index 2fff260e1e..c3c842564e 100644 --- a/webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx +++ b/webapp/src/ee/billing/component/Plan/PlanMigratingChip.tsx @@ -79,7 +79,7 @@ export const PlanMigratingChip = ({ ) : info ? ( - + {t('administration_plan_migration_details')} @@ -195,7 +195,7 @@ export const PlanMigratingChip = ({ to={configureLink.build({ migrationId: info.id, })} - data-cy="administration-cloud-plans-create-migration" + data-cy="administration-plans-edit-migration" > {t('global_configure')} @@ -211,7 +211,7 @@ export const PlanMigratingChip = ({ } > Date: Fri, 8 Aug 2025 15:25:58 +0200 Subject: [PATCH 13/19] chore: test fix --- .../migration/PlanMigrationForm.tsx | 52 ++++++++++++------- ...AdministrationCloudPlanMigrationCreate.tsx | 9 +++- ...trationSelfHostedEePlanMigrationCreate.tsx | 9 +++- 3 files changed, 46 insertions(+), 24 deletions(-) diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx index 058fb48824..6e98ab107f 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx @@ -19,23 +19,29 @@ type SelfHostedEePlanMigrationModel = type Props = { migration?: CloudPlanMigrationModel | SelfHostedEePlanMigrationModel; - onSubmit: (value: PlanMigrationFormData) => void; + onSubmit: ( + value: CreatePlanMigrationFormData | PlanMigrationFormData + ) => void; onDelete?: (id: number) => void; planType?: PlanType; loading: boolean | undefined; }; -const emptyDefaultValues: PlanMigrationFormData = { - enabled: true, - sourcePlanId: 0, - targetPlanId: 0, - monthlyOffsetDays: 14, - yearlyOffsetDays: 30, -}; +const emptyDefaultValues: CreatePlanMigrationFormData | PlanMigrationFormData = + { + enabled: true, + sourcePlanId: 0, + targetPlanId: 0, + monthlyOffsetDays: 14, + yearlyOffsetDays: 30, + }; export type PlanMigrationFormData = components['schemas']['PlanMigrationRequest']; +export type CreatePlanMigrationFormData = + components['schemas']['CreatePlanMigrationRequest']; + export const PlanMigrationForm = ({ migration, onSubmit, @@ -45,28 +51,34 @@ export const PlanMigrationForm = ({ }: Props) => { const { t } = useTranslate(); const isUpdate = migration != null; - const defaultValues: PlanMigrationFormData = migration - ? { - enabled: migration.enabled, - sourcePlanId: migration.sourcePlan.id, - targetPlanId: migration.targetPlan.id, - monthlyOffsetDays: migration.monthlyOffsetDays, - yearlyOffsetDays: migration.yearlyOffsetDays, - } - : emptyDefaultValues; + const defaultValues: CreatePlanMigrationFormData | PlanMigrationFormData = + migration + ? { + enabled: migration.enabled, + sourcePlanId: migration.sourcePlan.id, + targetPlanId: migration.targetPlan.id, + monthlyOffsetDays: migration.monthlyOffsetDays, + yearlyOffsetDays: migration.yearlyOffsetDays, + } + : emptyDefaultValues; const [selectedSourcePlan, setSelectedSourcePlan] = React.useState( - defaultValues.sourcePlanId + (defaultValues as CreatePlanMigrationFormData).sourcePlanId ); const [selectedTargetPlan, setSelectedTargetPlan] = React.useState( defaultValues.targetPlanId ); return ( - initialValues={defaultValues} enableReinitialize - onSubmit={onSubmit} + onSubmit={(values) => { + const formData = isUpdate + ? (values as PlanMigrationFormData) + : (values as CreatePlanMigrationFormData); + onSubmit(formData); + }} validationSchema={Validation.PLAN_MIRATION_FORM} > diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx index 19ab9fa034..a214652192 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx @@ -5,6 +5,7 @@ import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; import { LINKS } from 'tg.constants/links'; import { + CreatePlanMigrationFormData, PlanMigrationForm, PlanMigrationFormData, } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; @@ -18,9 +19,13 @@ export const AdministrationCloudPlanMigrationCreate = () => { const messaging = useMessage(); const history = useHistory(); - const submit = (values: PlanMigrationFormData) => { + const submit = ( + values: CreatePlanMigrationFormData | PlanMigrationFormData + ) => { createPlanMigrationLoadable.mutate( - { content: { 'application/json': values } }, + { + content: { 'application/json': values as CreatePlanMigrationFormData }, + }, { onSuccess: () => { messaging.success( diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate.tsx index dda01ce802..3377e97a04 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationCreate.tsx @@ -5,6 +5,7 @@ import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; import { LINKS } from 'tg.constants/links'; import { + CreatePlanMigrationFormData, PlanMigrationForm, PlanMigrationFormData, } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; @@ -18,9 +19,13 @@ export const AdministrationSelfHostedEePlanMigrationCreate = () => { const messaging = useMessage(); const history = useHistory(); - const submit = (values: PlanMigrationFormData) => { + const submit = ( + values: CreatePlanMigrationFormData | PlanMigrationFormData + ) => { createPlanMigrationLoadable.mutate( - { content: { 'application/json': values } }, + { + content: { 'application/json': values as CreatePlanMigrationFormData }, + }, { onSuccess: () => { messaging.success( From db9ccdcfff2de85c7f15d1e8d64fcd656b918e8a Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Fri, 8 Aug 2025 16:08:34 +0200 Subject: [PATCH 14/19] chore: migration form refactored --- .../migration/CreatePlanMigrationForm.tsx | 29 ++++++++++ .../migration/EditPlanMigrationForm.tsx | 31 ++++++++++ .../migration/PlanMigrationForm.tsx | 58 +++++++------------ ...AdministrationCloudPlanMigrationCreate.tsx | 4 +- .../AdministrationCloudPlanMigrationEdit.tsx | 8 +-- ...trationSelfHostedEePlanMigrationCreate.tsx | 4 +- ...istrationSelfHostedEePlanMigrationEdit.tsx | 8 +-- 7 files changed, 92 insertions(+), 50 deletions(-) create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm.tsx diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx new file mode 100644 index 0000000000..fcd397f9cf --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx @@ -0,0 +1,29 @@ +import { PlanMigrationForm } from './PlanMigrationForm'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; + +export type CreatePlanMigrationFormData = + components['schemas']['CreatePlanMigrationRequest']; + +const emptyDefaultValues: CreatePlanMigrationFormData = { + enabled: true, + sourcePlanId: 0, + targetPlanId: 0, + monthlyOffsetDays: 14, + yearlyOffsetDays: 30, +}; + +type Props = { + onSubmit: (values: CreatePlanMigrationFormData) => void; + loading?: boolean; + planType?: PlanType; +}; + +export const CreatePlanMigrationForm: React.FC = (props) => { + return ( + + defaultValues={emptyDefaultValues} + {...props} + /> + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm.tsx new file mode 100644 index 0000000000..ee7257ddb4 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm.tsx @@ -0,0 +1,31 @@ +import { PlanMigrationForm, PlanMigrationFormData } from './PlanMigrationForm'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; + +type CloudPlanMigrationModel = components['schemas']['CloudPlanMigrationModel']; +type SelfHostedEePlanMigrationModel = + components['schemas']['AdministrationSelfHostedEePlanMigrationModel']; + +type Props = { + onSubmit: (values: PlanMigrationFormData) => void; + loading?: boolean; + onDelete?: (id: number) => void; + migration: CloudPlanMigrationModel | SelfHostedEePlanMigrationModel; + planType?: PlanType; +}; + +export const EditPlanMigrationForm: React.FC = (props) => { + const { migration } = props; + const initialValues: PlanMigrationFormData = { + enabled: migration.enabled, + targetPlanId: migration.targetPlan.id, + monthlyOffsetDays: migration.monthlyOffsetDays, + yearlyOffsetDays: migration.yearlyOffsetDays, + }; + return ( + + defaultValues={initialValues} + {...props} + /> + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx index 6e98ab107f..135efe50a3 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx @@ -17,50 +17,33 @@ type CloudPlanMigrationModel = components['schemas']['CloudPlanMigrationModel']; type SelfHostedEePlanMigrationModel = components['schemas']['AdministrationSelfHostedEePlanMigrationModel']; -type Props = { - migration?: CloudPlanMigrationModel | SelfHostedEePlanMigrationModel; - onSubmit: ( - value: CreatePlanMigrationFormData | PlanMigrationFormData - ) => void; +type Props = { + defaultValues: T; + onSubmit: (value: T) => void; onDelete?: (id: number) => void; planType?: PlanType; - loading: boolean | undefined; + migration?: CloudPlanMigrationModel | SelfHostedEePlanMigrationModel; + loading?: boolean; }; -const emptyDefaultValues: CreatePlanMigrationFormData | PlanMigrationFormData = - { - enabled: true, - sourcePlanId: 0, - targetPlanId: 0, - monthlyOffsetDays: 14, - yearlyOffsetDays: 30, - }; - export type PlanMigrationFormData = components['schemas']['PlanMigrationRequest']; export type CreatePlanMigrationFormData = components['schemas']['CreatePlanMigrationRequest']; -export const PlanMigrationForm = ({ - migration, +export const PlanMigrationForm = < + T extends CreatePlanMigrationFormData | PlanMigrationFormData +>({ + defaultValues, onSubmit, loading, onDelete, + migration, planType = 'cloud', -}: Props) => { +}: Props) => { const { t } = useTranslate(); const isUpdate = migration != null; - const defaultValues: CreatePlanMigrationFormData | PlanMigrationFormData = - migration - ? { - enabled: migration.enabled, - sourcePlanId: migration.sourcePlan.id, - targetPlanId: migration.targetPlan.id, - monthlyOffsetDays: migration.monthlyOffsetDays, - yearlyOffsetDays: migration.yearlyOffsetDays, - } - : emptyDefaultValues; const [selectedSourcePlan, setSelectedSourcePlan] = React.useState( (defaultValues as CreatePlanMigrationFormData).sourcePlanId @@ -69,16 +52,19 @@ export const PlanMigrationForm = ({ defaultValues.targetPlanId ); + const initValues = { + ...defaultValues, + ...(isUpdate && + migration && { + sourcePlanId: migration.sourcePlan.id, + }), + }; + return ( - - initialValues={defaultValues} + + initialValues={initValues} enableReinitialize - onSubmit={(values) => { - const formData = isUpdate - ? (values as PlanMigrationFormData) - : (values as CreatePlanMigrationFormData); - onSubmit(formData); - }} + onSubmit={onSubmit} validationSchema={Validation.PLAN_MIRATION_FORM} > diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx index a214652192..8fb0cd6c43 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationCreate.tsx @@ -6,13 +6,13 @@ import { BaseAdministrationView } from 'tg.views/administration/components/BaseA import { LINKS } from 'tg.constants/links'; import { CreatePlanMigrationFormData, - PlanMigrationForm, PlanMigrationFormData, } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; import { useBillingApiMutation } from 'tg.service/http/useQueryApi'; import React from 'react'; import { useMessage } from 'tg.hooks/useSuccessMessage'; import { useHistory } from 'react-router-dom'; +import { CreatePlanMigrationForm } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm'; export const AdministrationCloudPlanMigrationCreate = () => { const { t } = useTranslate(); @@ -64,7 +64,7 @@ export const AdministrationCloudPlanMigrationCreate = () => { {t('administration_plan_migration_configure')} - diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx index 99882e40fa..daca9fd685 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx @@ -4,10 +4,7 @@ import { T, useTranslate } from '@tolgee/react'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; import { LINKS, PARAMS } from 'tg.constants/links'; -import { - PlanMigrationForm, - PlanMigrationFormData, -} from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; +import { PlanMigrationFormData } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm'; import { useBillingApiMutation, useBillingApiQuery, @@ -20,6 +17,7 @@ import { EmptyState } from 'tg.component/common/EmptyState'; import { PaginatedHateoasTable } from 'tg.component/common/table/PaginatedHateoasTable'; import { useDateFormatter } from 'tg.hooks/useLocale'; import { PlanMigrationStatus } from 'tg.ee.module/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus'; +import { EditPlanMigrationForm } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm'; export const AdministrationCloudPlanMigrationEdit = () => { const { t } = useTranslate(); @@ -120,7 +118,7 @@ export const AdministrationCloudPlanMigrationEdit = () => { {t('administration_plan_migration_configure_existing')} - { const { t } = useTranslate(); @@ -64,7 +64,7 @@ export const AdministrationSelfHostedEePlanMigrationCreate = () => { {t('administration_plan_migration_configure')} - { const { t } = useTranslate(); @@ -120,7 +118,7 @@ export const AdministrationSelfHostedEePlanMigrationEdit = () => { {t('administration_plan_migration_configure_existing')} - Date: Mon, 11 Aug 2025 13:02:09 +0200 Subject: [PATCH 15/19] feat: allow upgrade free plans too --- .../migration/CreatePlanMigrationForm.tsx | 9 ++-- .../migration/EditPlanMigrationForm.tsx | 1 + .../migration/PlanMigrationForm.tsx | 48 ++++++++++++++----- .../genericFields/GenericPlanSelector.tsx | 21 ++++++-- 4 files changed, 58 insertions(+), 21 deletions(-) diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx index fcd397f9cf..7b2c02aa51 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx @@ -1,10 +1,9 @@ -import { PlanMigrationForm } from './PlanMigrationForm'; -import { components } from 'tg.service/billingApiSchema.generated'; +import { + CreatePlanMigrationFormData, + PlanMigrationForm, +} from './PlanMigrationForm'; import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types'; -export type CreatePlanMigrationFormData = - components['schemas']['CreatePlanMigrationRequest']; - const emptyDefaultValues: CreatePlanMigrationFormData = { enabled: true, sourcePlanId: 0, diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm.tsx index ee7257ddb4..969804a9df 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/EditPlanMigrationForm.tsx @@ -18,6 +18,7 @@ export const EditPlanMigrationForm: React.FC = (props) => { const { migration } = props; const initialValues: PlanMigrationFormData = { enabled: migration.enabled, + sourcePlanFree: migration.sourcePlan.free, targetPlanId: migration.targetPlan.id, monthlyOffsetDays: migration.monthlyOffsetDays, yearlyOffsetDays: migration.yearlyOffsetDays, diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx index 135efe50a3..330b617bac 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx @@ -1,7 +1,7 @@ import { Form, Formik } from 'formik'; import { Box, Button, Typography } from '@mui/material'; import LoadingButton from 'tg.component/common/form/LoadingButton'; -import React from 'react'; +import React, { useState } from 'react'; import { T, useTranslate } from '@tolgee/react'; import { components } from 'tg.service/billingApiSchema.generated'; import { ArrowRightIcon } from '@mui/x-date-pickers'; @@ -27,11 +27,18 @@ type Props = { }; export type PlanMigrationFormData = - components['schemas']['PlanMigrationRequest']; + components['schemas']['PlanMigrationRequest'] & { + sourcePlanFree: boolean; + }; export type CreatePlanMigrationFormData = components['schemas']['CreatePlanMigrationRequest']; +type FormPlanType = { + id: number; + free?: boolean; +}; + export const PlanMigrationForm = < T extends CreatePlanMigrationFormData | PlanMigrationFormData >({ @@ -45,12 +52,20 @@ export const PlanMigrationForm = < const { t } = useTranslate(); const isUpdate = migration != null; - const [selectedSourcePlan, setSelectedSourcePlan] = React.useState( - (defaultValues as CreatePlanMigrationFormData).sourcePlanId - ); - const [selectedTargetPlan, setSelectedTargetPlan] = React.useState( - defaultValues.targetPlanId - ); + const defaultSourcePlan = migration + ? { + id: migration && migration.sourcePlan.id, + free: migration.sourcePlan.free, + } + : undefined; + + const [selectedSourcePlan, setSelectedSourcePlan] = useState< + FormPlanType | undefined + >(defaultSourcePlan); + + const [selectedTargetPlan, setSelectedTargetPlan] = useState({ + id: defaultValues.targetPlanId, + }); const initValues = { ...defaultValues, @@ -87,8 +102,12 @@ export const PlanMigrationForm = < disabled: isUpdate, }} dataCy="source-plan-selector" - onPlanChange={(plan) => setSelectedSourcePlan(plan.id)} - hiddenPlans={[selectedTargetPlan]} + onPlanChange={(plan) => { + setSelectedSourcePlan(plan); + }} + planProps={{ + hiddenIds: [selectedTargetPlan.id], + }} filterHasMigration={false} type={planType} {...(migration && { plans: [migration.sourcePlan] })} @@ -101,9 +120,14 @@ export const PlanMigrationForm = < required: true, }} dataCy="target-plan-selector" - onPlanChange={(plan) => setSelectedTargetPlan(plan.id)} + onPlanChange={(plan) => setSelectedTargetPlan(plan)} type={planType} - hiddenPlans={[selectedSourcePlan]} + planProps={ + selectedSourcePlan && { + hiddenIds: [selectedSourcePlan.id], + free: selectedSourcePlan.free, + } + } /> diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx index 161f46d8d1..89d4617314 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/genericFields/GenericPlanSelector.tsx @@ -6,7 +6,12 @@ import React from 'react'; import { T } from '@tolgee/react'; import { Box } from '@mui/material'; -type GenericPlanType = { id: number; name: string }; +export type GenericPlanType = { id: number; name: string; free: boolean }; + +type PlansProps = { + hiddenIds?: number[]; + free?: boolean; +}; export interface GenericPlanSelector { organizationId?: number; @@ -15,7 +20,7 @@ export interface GenericPlanSelector { onChange?: (value: number) => void; selectProps?: React.ComponentProps[`SelectProps`]; plans?: T[]; - hiddenPlans?: number[]; + planProps?: PlansProps; dataCy?: string; } @@ -25,7 +30,7 @@ export const GenericPlanSelector = ({ selectProps, onPlanChange, plans, - hiddenPlans, + planProps, dataCy = 'administration-plan-selector', }: GenericPlanSelector) => { if (!plans) { @@ -37,7 +42,15 @@ export const GenericPlanSelector = ({ } const selectItems = plans - .filter((plan) => !hiddenPlans?.includes(plan.id)) + .filter((plan) => { + if (planProps?.hiddenIds?.includes(plan.id)) { + return false; + } + if (planProps?.free !== undefined) { + return planProps.free === plan.free; + } + return true; + }) .map( (plan) => ({ From e079a9377b9d61028a9445e092938e789d39a2e3 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Mon, 18 Aug 2025 10:50:30 +0200 Subject: [PATCH 16/19] chore: release schedule on subscription update --- .../AdministrationCloudPlanMigrationEdit.tsx | 5 +++- .../migration/general/PlanMigrationStatus.tsx | 23 ++++++++++++++++--- ...istrationSelfHostedEePlanMigrationEdit.tsx | 5 +++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx index daca9fd685..913daf597b 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/cloud/AdministrationCloudPlanMigrationEdit.tsx @@ -165,7 +165,10 @@ export const AdministrationCloudPlanMigrationEdit = () => { })} - + )} diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx index f3c707586e..d466658ef0 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx @@ -1,11 +1,13 @@ import { components } from 'tg.service/billingApiSchema.generated'; -import { Chip } from '@mui/material'; +import { Chip, Tooltip } from '@mui/material'; import { useTranslate } from '@tolgee/react'; +import { useDateFormatter } from 'tg.hooks/useLocale'; type Status = components['schemas']['PlanMigrationHistoryModel']['status']; type Props = { status: Status; + date?: number; }; const colors = { @@ -17,9 +19,24 @@ const translates = { SCHEDULED: 'administration_plan_migration_status_scheduled', }; -export const PlanMigrationStatus = ({ status }: Props) => { +export const PlanMigrationStatus = ({ status, date }: Props) => { const { t } = useTranslate(); - return ( + const formatDate = useDateFormatter(); + + const chip = ( ); + return date ? ( + + {chip} + + ) : ( + chip + ); }; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx index e98cf3d755..5cef4f7392 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/selfhosted/AdministrationSelfHostedEePlanMigrationEdit.tsx @@ -166,7 +166,10 @@ export const AdministrationSelfHostedEePlanMigrationEdit = () => { })} - + )} From 848db96cda203b1396223da93c64661edbbba127 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Fri, 22 Aug 2025 15:23:44 +0200 Subject: [PATCH 17/19] chore: be tests fix --- .../src/service/billingApiSchema.generated.ts | 505 +++++++++++++++++- 1 file changed, 504 insertions(+), 1 deletion(-) diff --git a/webapp/src/service/billingApiSchema.generated.ts b/webapp/src/service/billingApiSchema.generated.ts index fd71b55494..980bc7b909 100644 --- a/webapp/src/service/billingApiSchema.generated.ts +++ b/webapp/src/service/billingApiSchema.generated.ts @@ -19,6 +19,17 @@ export interface paths { get: operations["getPlans_1"]; post: operations["create_2"]; }; + "/v2/administration/billing/cloud-plans/migration": { + post: operations["createPlanMigration_1"]; + }; + "/v2/administration/billing/cloud-plans/migration/{migrationId}": { + get: operations["getPlanMigration_1"]; + put: operations["updatePlanMigration_1"]; + delete: operations["deletePlanMigration_1"]; + }; + "/v2/administration/billing/cloud-plans/migration/{migrationId}/subscriptions": { + get: operations["getPlanMigrationSubscriptions_1"]; + }; "/v2/administration/billing/cloud-plans/{planId}": { get: operations["getPlan_1"]; put: operations["updatePlan_1"]; @@ -44,6 +55,17 @@ export interface paths { get: operations["getPlans"]; post: operations["create_1"]; }; + "/v2/administration/billing/self-hosted-ee-plans/migration": { + post: operations["createPlanMigration"]; + }; + "/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}": { + get: operations["getPlanMigration"]; + put: operations["updatePlanMigration"]; + delete: operations["deletePlanMigration"]; + }; + "/v2/administration/billing/self-hosted-ee-plans/migration/{migrationId}/subscriptions": { + get: operations["getPlanMigrationSubscriptions"]; + }; "/v2/administration/billing/self-hosted-ee-plans/{planId}": { get: operations["getPlan"]; put: operations["updatePlan"]; @@ -218,6 +240,7 @@ export interface paths { export interface components { schemas: { AdministrationCloudPlanModel: { + activeMigration?: boolean; /** Format: date-time */ archivedAt?: string; canEditPrices: boolean; @@ -255,6 +278,8 @@ export interface components { id: number; includedUsage: components["schemas"]["PlanIncludedUsageModel"]; metricType: "KEYS_SEATS" | "STRINGS"; + /** Format: int64 */ + migrationId?: number; name: string; nonCommercial: boolean; prices: components["schemas"]["PlanPricesModel"]; @@ -290,6 +315,19 @@ export interface components { /** Format: int64 */ trialEnd?: number; }; + AdministrationSelfHostedEePlanMigrationModel: { + enabled: boolean; + /** Format: int64 */ + id: number; + /** Format: int32 */ + monthlyOffsetDays: number; + sourcePlan: components["schemas"]["SelfHostedEePlanModel"]; + /** Format: int32 */ + subscriptionsCount?: number; + targetPlan: components["schemas"]["SelfHostedEePlanModel"]; + /** Format: int32 */ + yearlyOffsetDays: number; + }; AssignCloudPlanRequest: { customPlan?: components["schemas"]["CloudPlanRequest"]; /** Format: int64 */ @@ -338,6 +376,19 @@ export interface components { CancelLocalSubscriptionsRequest: { ids: components["schemas"]["SubscriptionId"][]; }; + CloudPlanMigrationModel: { + enabled: boolean; + /** Format: int64 */ + id: number; + /** Format: int32 */ + monthlyOffsetDays: number; + sourcePlan: components["schemas"]["CloudPlanModel"]; + /** Format: int32 */ + subscriptionsCount?: number; + targetPlan: components["schemas"]["CloudPlanModel"]; + /** Format: int32 */ + yearlyOffsetDays: number; + }; CloudPlanModel: { /** Format: date-time */ archivedAt?: string; @@ -492,6 +543,17 @@ export interface components { stripeProducts?: components["schemas"]["StripeProductModel"][]; }; }; + CreatePlanMigrationRequest: { + enabled: boolean; + /** Format: int32 */ + monthlyOffsetDays: number; + /** Format: int64 */ + sourcePlanId: number; + /** Format: int64 */ + targetPlanId: number; + /** Format: int32 */ + yearlyOffsetDays: number; + }; CreateTaskRequest: { assignees: number[]; description: string; @@ -846,7 +908,9 @@ export interface components { | "expect_no_conflict_failed" | "suggestion_cant_be_plural" | "suggestion_must_be_plural" - | "duplicate_suggestion"; + | "duplicate_suggestion" + | "plan_migration_not_found" + | "plan_has_migrations"; params?: unknown[]; }; ExampleItem: { @@ -971,6 +1035,12 @@ export interface components { }; page?: components["schemas"]["PageMetadata"]; }; + PagedModelPlanMigrationHistoryModel: { + _embedded?: { + planMigrationHistoryModelList?: components["schemas"]["PlanMigrationHistoryModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; PagedModelSimpleOrganizationModel: { _embedded?: { organizations?: components["schemas"]["SimpleOrganizationModel"][]; @@ -1080,6 +1150,26 @@ export interface components { /** Format: int64 */ translations: number; }; + PlanMigrationHistoryModel: { + /** Format: int64 */ + finalizedAt?: number; + organizationName: string; + organizationSlug: string; + originPlan: string; + plan: string; + /** Format: int64 */ + scheduledAt: number; + status: "COMPLETED" | "SCHEDULED"; + }; + PlanMigrationRequest: { + enabled: boolean; + /** Format: int32 */ + monthlyOffsetDays: number; + /** Format: int64 */ + targetPlanId: number; + /** Format: int32 */ + yearlyOffsetDays: number; + }; PlanPricesModel: { perSeat: number; perThousandKeys: number; @@ -1147,6 +1237,7 @@ export interface components { planId: number; }; SelfHostedEePlanAdministrationModel: { + activeMigration?: boolean; /** Format: date-time */ archivedAt?: string; canEditPrices: boolean; @@ -1184,6 +1275,8 @@ export interface components { id: number; includedUsage: components["schemas"]["PlanIncludedUsageModel"]; isPayAsYouGo: boolean; + /** Format: int64 */ + migrationId?: number; name: string; nonCommercial: boolean; prices: components["schemas"]["PlanPricesModel"]; @@ -1584,6 +1677,7 @@ export interface operations { filterAssignableToOrganization?: number; filterPlanIds?: number[]; filterPublic?: boolean; + filterHasMigration?: boolean; }; }; responses: { @@ -1658,6 +1752,210 @@ export interface operations { }; }; }; + createPlanMigration_1: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CloudPlanMigrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreatePlanMigrationRequest"]; + }; + }; + }; + getPlanMigration_1: { + parameters: { + path: { + migrationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CloudPlanMigrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + updatePlanMigration_1: { + parameters: { + path: { + migrationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["CloudPlanMigrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PlanMigrationRequest"]; + }; + }; + }; + deletePlanMigration_1: { + parameters: { + path: { + migrationId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + getPlanMigrationSubscriptions_1: { + parameters: { + path: { + migrationId: number; + }; + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelPlanMigrationHistoryModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; getPlan_1: { parameters: { path: { @@ -2013,6 +2311,7 @@ export interface operations { filterAssignableToOrganization?: number; filterPlanIds?: number[]; filterPublic?: boolean; + filterHasMigration?: boolean; }; }; responses: { @@ -2087,6 +2386,210 @@ export interface operations { }; }; }; + createPlanMigration: { + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["AdministrationSelfHostedEePlanMigrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreatePlanMigrationRequest"]; + }; + }; + }; + getPlanMigration: { + parameters: { + path: { + migrationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["AdministrationSelfHostedEePlanMigrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + updatePlanMigration: { + parameters: { + path: { + migrationId: number; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["AdministrationSelfHostedEePlanMigrationModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PlanMigrationRequest"]; + }; + }; + }; + deletePlanMigration: { + parameters: { + path: { + migrationId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; + getPlanMigrationSubscriptions: { + parameters: { + path: { + migrationId: number; + }; + query: { + /** Zero-based page index (0..N) */ + page?: number; + /** The size of the page to be returned */ + size?: number; + /** Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported. */ + sort?: string[]; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelPlanMigrationHistoryModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + }; getPlan: { parameters: { path: { From ac4e28c15c195b9f67775f4c8a37cb5e350281ce Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Fri, 22 Aug 2025 17:25:50 +0200 Subject: [PATCH 18/19] chore: check-translations fix --- .../migration/general/PlanMigrationStatus.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx index d466658ef0..0bb34e13d9 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/migration/general/PlanMigrationStatus.tsx @@ -14,18 +14,28 @@ const colors = { COMPLETED: 'success', }; -const translates = { - COMPLETED: 'administration_plan_migration_status_completed', - SCHEDULED: 'administration_plan_migration_status_scheduled', -}; - export const PlanMigrationStatus = ({ status, date }: Props) => { const { t } = useTranslate(); const formatDate = useDateFormatter(); + const getStatusLabel = (s: Status): string => { + switch (s) { + case 'COMPLETED': + return t('administration_plan_migration_status_completed'); + case 'SCHEDULED': + return t('administration_plan_migration_status_scheduled'); + default: + return String(s); + } + }; + const chip = ( - + ); + return date ? ( Date: Mon, 29 Sep 2025 15:06:36 +0200 Subject: [PATCH 19/19] chore: api schema updated --- webapp/src/service/apiSchema.generated.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index d3662e2f53..1aa7e54e42 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -2442,7 +2442,9 @@ export interface components { | "suggestion_cant_be_plural" | "suggestion_must_be_plural" | "duplicate_suggestion" - | "unsupported_media_type"; + | "unsupported_media_type" + | "plan_migration_not_found" + | "plan_has_migrations"; params?: unknown[]; }; ExistenceEntityDescription: { @@ -5514,7 +5516,9 @@ export interface components { | "suggestion_cant_be_plural" | "suggestion_must_be_plural" | "duplicate_suggestion" - | "unsupported_media_type"; + | "unsupported_media_type" + | "plan_migration_not_found" + | "plan_has_migrations"; params?: unknown[]; success: boolean; };