From 5c4913cde82f177fca517e4de6bed46a5fa9dde2 Mon Sep 17 00:00:00 2001 From: Jan Cizmar Date: Tue, 17 Dec 2024 08:23:15 +0100 Subject: [PATCH 01/46] feat: Free trial & administration subscriptions view --- .../component/common/form/StandardForm.tsx | 5 +- .../common/form/fields/TextField.tsx | 3 +- webapp/src/constants/links.tsx | 5 + .../AdministrationCloudPlansView.tsx | 10 +- .../AdministrationSubscriptions.tsx | 95 +++++++++ .../AdministrationSubscriptionsCloudPlan.tsx | 195 ++++++++++++++++++ .../billing/component/Plan/PlanPublicChip.tsx | 15 ++ webapp/src/eeSetup/eeModule.ee.tsx | 10 + .../src/service/billingApiSchema.generated.ts | 81 +++++++- 9 files changed, 406 insertions(+), 13 deletions(-) create mode 100644 webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptions.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsCloudPlan.tsx create mode 100644 webapp/src/ee/billing/component/Plan/PlanPublicChip.tsx diff --git a/webapp/src/component/common/form/StandardForm.tsx b/webapp/src/component/common/form/StandardForm.tsx index a332260123..f40835758d 100644 --- a/webapp/src/component/common/form/StandardForm.tsx +++ b/webapp/src/component/common/form/StandardForm.tsx @@ -1,8 +1,8 @@ -import { default as React, ReactNode } from 'react'; +import { default as React, FC, ReactNode } from 'react'; import { Box, Button, SxProps } from '@mui/material'; import { SpinnerProgress } from 'tg.component/SpinnerProgress'; import { T } from '@tolgee/react'; -import { Form, Formik, FormikProps } from 'formik'; +import { Form, Formik, FormikProps, useField } from 'formik'; import { FormikHelpers, FormikValues } from 'formik/dist/types'; import { useHistory } from 'react-router-dom'; import { ObjectSchema } from 'yup'; @@ -10,6 +10,7 @@ import { ObjectSchema } from 'yup'; import LoadingButton from './LoadingButton'; import { ResourceErrorComponent } from './ResourceErrorComponent'; import { ApiError } from 'tg.service/http/ApiError'; +import { DatePicker } from '@mui/x-date-pickers'; export interface LoadableType { loading?: boolean; diff --git a/webapp/src/component/common/form/fields/TextField.tsx b/webapp/src/component/common/form/fields/TextField.tsx index fbf33b4861..a6d45bb3ad 100644 --- a/webapp/src/component/common/form/fields/TextField.tsx +++ b/webapp/src/component/common/form/fields/TextField.tsx @@ -13,8 +13,7 @@ export const TextField: FunctionComponent = (props) => { const [field, meta] = useField(props.name); const [oldValue, setOldValue] = useState(field.value); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { onValueChange, ...otherProps } = props; + const { onValueChange: _, ...otherProps } = props; useEffect(() => { if (typeof props.onValueChange === 'function' && oldValue !== field.value) { diff --git a/webapp/src/constants/links.tsx b/webapp/src/constants/links.tsx index 410e66bf06..383d1d0492 100644 --- a/webapp/src/constants/links.tsx +++ b/webapp/src/constants/links.tsx @@ -219,6 +219,11 @@ export class LINKS { 'ee-plans' ); + static ADMINISTRATION_BILLING_SUBSCRIPTIONS = Link.ofParent( + LINKS.ADMINISTRATION, + 'subscriptions' + ); + static ADMINISTRATION_BILLING_EE_PLAN_EDIT = Link.ofParent( LINKS.ADMINISTRATION_BILLING_EE_PLANS, p(PARAMS.PLAN_ID) diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlansView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlansView.tsx index 449131a1f1..9f920b3c42 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlansView.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlansView.tsx @@ -3,7 +3,6 @@ import { T, useTranslate } from '@tolgee/react'; import { Box, Button, - Chip, IconButton, ListItem, ListItemText, @@ -21,6 +20,7 @@ import { BaseAdministrationView } from 'tg.views/administration/components/BaseA import { useMessage } from 'tg.hooks/useSuccessMessage'; import { confirmation } from 'tg.hooks/confirmation'; import { components } from 'tg.service/billingApiSchema.generated'; +import { PlanPublicChip } from '../../component/Plan/PlanPublicChip'; type CloudPlanModel = components['schemas']['CloudPlanModel']; @@ -86,13 +86,7 @@ export const AdministrationCloudPlansView = () => { > {plan.name} - {plan.public && ( - - )} + + + + ); +}; + +const AssignTrialDialog: FC<{ + open: boolean; + handleClose: () => void; +}> = ({ handleClose, open }) => { + function handleSave(value) { + console.log(value); + } + + const currentDaPlus2weeks = new Date(Date.now() + 1000 * 60 * 60 * 24 * 14); + + return ( + + {(formikProps) => ( +
+ + + + + + + + + + + + + + + +
+ )} +
+ ); +}; + +type DateTimePickerFieldProps = PropsOf; + +export const DateTimePickerField: FC< + { name: string } & DateTimePickerFieldProps +> = ({ name, onChange, ...otherProps }) => { + const [field, _, helpers] = useField(name); + return ( + helpers.setValue(value)} + value={field.value} + /> + ); +}; + +export const PlanSelector = () => { + const plansLoadable = useBillingApiQuery({ + url: '/v2/administration/billing/cloud-plans', + method: 'get', + }); + + const [field, meta, helpers] = useField('planId'); + + if (plansLoadable.isLoading) { + return null; + } + + function onChange(val) { + helpers.setValue(val); + } + + const plans = plansLoadable?.data?._embedded?.plans ?? []; + const selectItems = plans + .filter((p) => !p.free) + .map( + (plan) => + ({ + value: plan.id, + name: plan.name, + } satisfies SelectItem) + ); + + return ( + + ); +}; diff --git a/webapp/src/ee/billing/component/Plan/PlanPublicChip.tsx b/webapp/src/ee/billing/component/Plan/PlanPublicChip.tsx new file mode 100644 index 0000000000..750a69b00f --- /dev/null +++ b/webapp/src/ee/billing/component/Plan/PlanPublicChip.tsx @@ -0,0 +1,15 @@ +import { Chip } from '@mui/material'; +import { T } from '@tolgee/react'; + +export function PlanPublicChip({ isPublic }: { isPublic?: boolean }) { + if (!isPublic) { + return null; + } + return ( + } + /> + ); +} diff --git a/webapp/src/eeSetup/eeModule.ee.tsx b/webapp/src/eeSetup/eeModule.ee.tsx index 0a6e8e4a10..143cc9f385 100644 --- a/webapp/src/eeSetup/eeModule.ee.tsx +++ b/webapp/src/eeSetup/eeModule.ee.tsx @@ -65,6 +65,7 @@ import { addAdministrationMenuItems } from '../views/administration/components/B import { SsoLoginView } from '../ee/security/Sso/SsoLoginView'; import { OperationOrderTranslation } from '../views/projects/translations/BatchOperations/OperationOrderTranslation'; import { BillingMenuItemsProps } from './EeModuleType'; +import { AdministrationSubscriptions } from '../ee/billing/administration/subscriptions/AdministrationSubscriptions'; export const billingMenuItems = [ BillingMenuItem, @@ -106,6 +107,9 @@ export const routes = { > + + + { label: t('administration_ee_license'), condition: () => true, }, + { + id: 'subscriptions', + link: LINKS.ADMINISTRATION_BILLING_SUBSCRIPTIONS, + label: t('administration_subscriptions'), + condition: () => config.billing.enabled, + }, { id: 'translation_agencies', link: LINKS.ADMINISTRATION_EE_TA, diff --git a/webapp/src/service/billingApiSchema.generated.ts b/webapp/src/service/billingApiSchema.generated.ts index 959386de06..c34979954a 100644 --- a/webapp/src/service/billingApiSchema.generated.ts +++ b/webapp/src/service/billingApiSchema.generated.ts @@ -130,6 +130,9 @@ export interface paths { "/v2/administration/billing/self-hosted-ee-plans/{planId}/organizations": { get: operations["getPlanOrganizations"]; }; + "/v2/administration/billing/organizations": { + get: operations["getOrganizations"]; + }; "/v2/administration/billing/features": { get: operations["getAllFeatures"]; }; @@ -651,6 +654,16 @@ export interface components { stripeProductId: string; forOrganizationIds: number[]; }; + AutoAssignOrganizationDto: { + /** Format: int64 */ + organizationId: number; + /** + * Format: int64 + * @description Trial end in milliseconds since epoch + * @example 1630000000000 + */ + trialEnd?: number; + }; CloudPlanRequest: { name: string; free: boolean; @@ -687,7 +700,7 @@ export interface components { /** Format: date-time */ usableUntil?: string; forOrganizationIds: number[]; - autoAssignOrganizationIds: number[]; + autoAssignOrganizations: components["schemas"]["AutoAssignOrganizationDto"][]; }; CloudPlanAdministrationModel: { /** Format: int64 */ @@ -1002,6 +1015,17 @@ export interface components { basePermissions: components["schemas"]["PermissionModel"]; avatar?: components["schemas"]["Avatar"]; }; + OrganizationWithSubscriptionsModel: { + organization: components["schemas"]["SimpleOrganizationModel"]; + cloudSubscription?: components["schemas"]["CloudSubscriptionModel"]; + selfHostedSubscriptions: components["schemas"]["SelfHostedEeSubscriptionModel"][]; + }; + PagedModelOrganizationWithSubscriptionsModel: { + _embedded?: { + organizations?: components["schemas"]["OrganizationWithSubscriptionsModel"][]; + }; + page?: components["schemas"]["PageMetadata"]; + }; CollectionModelCloudPlanAdministrationModel: { _embedded?: { plans?: components["schemas"]["CloudPlanAdministrationModel"][]; @@ -3312,6 +3336,61 @@ export interface operations { }; }; }; + getOrganizations: { + parameters: { + 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[]; + search?: string; + withCloudPlanId?: number; + hasSelfHostedSubscription?: boolean; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": components["schemas"]["PagedModelOrganizationWithSubscriptionsModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + }; getAllFeatures: { responses: { /** OK */ From 8e48ef399ad47c10ee743621393cf57af526b69e Mon Sep 17 00:00:00 2001 From: Jan Cizmar Date: Sat, 21 Dec 2024 16:24:02 +0000 Subject: [PATCH 02/46] feat: Free trial assigment & end handling --- .../kotlin/io/tolgee/constants/Message.kt | 1 + .../form/fields/DateTimePickerField.tsx | 33 ++++ .../common/form/fields/useFieldError.ts | 18 ++ .../AdministrationSubscriptions.tsx | 2 +- .../AdministrationSubscriptionsCloudPlan.tsx | 172 +----------------- ...nSubscriptionsCloudSubscriptionPopover.tsx | 42 +++++ .../subscriptions/AssignCloudTrialDialog.tsx | 119 ++++++++++++ .../subscriptions/CloudPlanSelector.tsx | 52 ++++++ .../src/service/billingApiSchema.generated.ts | 83 +++++++-- 9 files changed, 344 insertions(+), 178 deletions(-) create mode 100644 webapp/src/component/common/form/fields/DateTimePickerField.tsx create mode 100644 webapp/src/component/common/form/fields/useFieldError.ts create mode 100644 webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsCloudSubscriptionPopover.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptions/AssignCloudTrialDialog.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptions/CloudPlanSelector.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 d9d5de1885..6bb360fcce 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -257,6 +257,7 @@ enum class Message { CANNOT_SET_SSO_PROVIDER_MISSING_FIELDS, NAMESPACES_CANNOT_BE_DISABLED_WHEN_NAMESPACE_EXISTS, NAMESPACE_CANNOT_BE_USED_WHEN_FEATURE_IS_DISABLED, + DATE_HAS_TO_BE_IN_THE_FUTURE, ; val code: String diff --git a/webapp/src/component/common/form/fields/DateTimePickerField.tsx b/webapp/src/component/common/form/fields/DateTimePickerField.tsx new file mode 100644 index 0000000000..ebed0c0769 --- /dev/null +++ b/webapp/src/component/common/form/fields/DateTimePickerField.tsx @@ -0,0 +1,33 @@ +import React, { ComponentProps, FC, ReactNode, ReactPropTypes } from 'react'; +import { useField } from 'formik'; +import { useFieldError } from './useFieldError'; +import { FormControl, FormHelperText } from '@mui/material'; +import { DateTimePicker } from '@mui/x-date-pickers'; +import { PropsOf } from '@emotion/react'; + +type DateTimePickerFieldProps = PropsOf; + +type FormControlProps = ComponentProps; +type DateTimePickerProps = PropsOf; + +export const DateTimePickerField: FC< + { + name: string; + className?: string; + formControlProps?: FormControlProps; + dateTimePickerProps: DateTimePickerProps; + } & DateTimePickerFieldProps +> = ({ name, label, formControlProps, dateTimePickerProps }) => { + const [field, _, helpers] = useField(name); + const { error, helperText } = useFieldError({ fieldName: name }); + return ( + + helpers.setValue(value)} + value={field.value} + /> + {error && {helperText}} + + ); +}; diff --git a/webapp/src/component/common/form/fields/useFieldError.ts b/webapp/src/component/common/form/fields/useFieldError.ts new file mode 100644 index 0000000000..1fa56ea380 --- /dev/null +++ b/webapp/src/component/common/form/fields/useFieldError.ts @@ -0,0 +1,18 @@ +import { useField } from 'formik'; +import { ReactNode } from 'react'; + +export const useFieldError = ({ + fieldName, + customHelperText, +}: { + fieldName: string; + customHelperText?: ReactNode; +}) => { + const [_, meta] = useField(fieldName); + + return { + helperText: (meta.touched && meta.error) || customHelperText, + error: Boolean(meta.touched && meta.error), + errorTextWhenTouched: (meta.touched && meta.error) || undefined, + }; +}; diff --git a/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptions.tsx b/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptions.tsx index 8245e389ec..54a12b9b3e 100644 --- a/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptions.tsx +++ b/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptions.tsx @@ -53,7 +53,7 @@ export const AdministrationSubscriptions = () => { = ({ item }) => { <> setDialogOpen(true)} /> @@ -48,148 +27,11 @@ export const AdministrationSubscriptionsCloudPlan: FC = ({ item }) => { > {item.cloudSubscription?.plan.name} - setDialogOpen(false)} - > + > ); }; - -const Popover: FC< - Props & { - onOpenAssignTrialDialog: () => void; - } -> = ({ item, onOpenAssignTrialDialog }) => { - const formatDate = useDateFormatter(); - - return ( - - - - {item.cloudSubscription?.plan.name} - - - - - - - {item.cloudSubscription?.currentBillingPeriod} - - - {item.cloudSubscription?.currentPeriodEnd && - formatDate(item.cloudSubscription?.currentPeriodEnd)} - - - - - ); -}; - -const AssignTrialDialog: FC<{ - open: boolean; - handleClose: () => void; -}> = ({ handleClose, open }) => { - function handleSave(value) { - console.log(value); - } - - const currentDaPlus2weeks = new Date(Date.now() + 1000 * 60 * 60 * 24 * 14); - - return ( - - {(formikProps) => ( -
- - - - - - - - - - - - - - - -
- )} -
- ); -}; - -type DateTimePickerFieldProps = PropsOf; - -export const DateTimePickerField: FC< - { name: string } & DateTimePickerFieldProps -> = ({ name, onChange, ...otherProps }) => { - const [field, _, helpers] = useField(name); - return ( - helpers.setValue(value)} - value={field.value} - /> - ); -}; - -export const PlanSelector = () => { - const plansLoadable = useBillingApiQuery({ - url: '/v2/administration/billing/cloud-plans', - method: 'get', - }); - - const [field, meta, helpers] = useField('planId'); - - if (plansLoadable.isLoading) { - return null; - } - - function onChange(val) { - helpers.setValue(val); - } - - const plans = plansLoadable?.data?._embedded?.plans ?? []; - const selectItems = plans - .filter((p) => !p.free) - .map( - (plan) => - ({ - value: plan.id, - name: plan.name, - } satisfies SelectItem) - ); - - return ( - - ); -}; diff --git a/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsCloudSubscriptionPopover.tsx b/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsCloudSubscriptionPopover.tsx new file mode 100644 index 0000000000..f0a6fd994d --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsCloudSubscriptionPopover.tsx @@ -0,0 +1,42 @@ +import React, { FC } from 'react'; +import { useDateFormatter } from 'tg.hooks/useLocale'; +import { Box, Button, Typography } from '@mui/material'; +import { PlanPublicChip } from '../../component/Plan/PlanPublicChip'; +import { T } from '@tolgee/react'; +import { components } from 'tg.service/billingApiSchema.generated'; + +type Props = { + item: components['schemas']['OrganizationWithSubscriptionsModel']; + onOpenAssignTrialDialog: () => void; +}; + +export const AdministrationSubscriptionsCloudSubscriptionPopover: FC = ({ + item, + onOpenAssignTrialDialog, +}) => { + const formatDate = useDateFormatter(); + + return ( + + + + {item.cloudSubscription?.plan.name} + + + + + + + {item.cloudSubscription?.currentBillingPeriod} + + + {item.cloudSubscription?.currentPeriodEnd && + formatDate(item.cloudSubscription?.currentPeriodEnd)} + + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptions/AssignCloudTrialDialog.tsx b/webapp/src/ee/billing/administration/subscriptions/AssignCloudTrialDialog.tsx new file mode 100644 index 0000000000..8c93d9536b --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptions/AssignCloudTrialDialog.tsx @@ -0,0 +1,119 @@ +import React, { FC } from 'react'; +import { T, useTranslate } from '@tolgee/react'; +import { Form, Formik } from 'formik'; +import * as Yup from 'yup'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@mui/material'; +import { DateTimePickerField } from 'tg.component/common/form/fields/DateTimePickerField'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; + +import { PlanSelectorField } from './CloudPlanSelector'; +import { useBillingApiMutation } from 'tg.service/http/useQueryApi'; +import { useMessage } from 'tg.hooks/useSuccessMessage'; + +export const AssignCloudTrialDialog: FC<{ + open: boolean; + handleClose: () => void; + organizationId: number; +}> = ({ handleClose, open, organizationId }) => { + const assignMutation = useBillingApiMutation({ + url: '/v2/administration/organizations/{organizationId}/billing/assign-cloud-plan', + method: 'put', + invalidatePrefix: '/v2/administration/billing', + }); + + const { t } = useTranslate(); + + const messaging = useMessage(); + + function handleSave(value: ValuesType) { + assignMutation.mutate( + { + path: { organizationId }, + content: { + 'application/json': { + planId: value.planId!, + trialEnd: value.trialEnd.getTime(), + }, + }, + }, + { + onSuccess() { + handleClose(); + messaging.success( + + ); + }, + } + ); + } + + const currentDaPlus2weeks = new Date(Date.now() + 1000 * 60 * 60 * 24 * 14); + + return ( + + {(formikProps) => ( +
+ + + + + + + ), + }} + name="trialEnd" + /> + + + + + + + + + +
+ )} +
+ ); +}; + +type ValuesType = { + planId: number | undefined; + trialEnd: Date; +}; diff --git a/webapp/src/ee/billing/administration/subscriptions/CloudPlanSelector.tsx b/webapp/src/ee/billing/administration/subscriptions/CloudPlanSelector.tsx new file mode 100644 index 0000000000..2f863a6698 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptions/CloudPlanSelector.tsx @@ -0,0 +1,52 @@ +import { useBillingApiQuery } from 'tg.service/http/useQueryApi'; +import { useField } from 'formik'; +import { useFieldError } from 'tg.component/common/form/fields/useFieldError'; +import { + SearchSelect, + SelectItem, +} from 'tg.component/searchSelect/SearchSelect'; +import React from 'react'; + +export const PlanSelectorField = ({ + organizationId, +}: { + organizationId: number; +}) => { + const plansLoadable = useBillingApiQuery({ + url: '/v2/administration/billing/cloud-plans', + method: 'get', + query: { + filterAssignableToOrganization: organizationId, + }, + }); + + const fieldName = 'planId'; + const [field, _, helpers] = useField(fieldName); + const { errorTextWhenTouched } = useFieldError({ fieldName }); + + if (plansLoadable.isLoading) { + return null; + } + + function onChange(val) { + helpers.setValue(val); + } + + const plans = plansLoadable?.data?._embedded?.plans ?? []; + const selectItems = plans.map( + (plan) => + ({ + value: plan.id, + name: plan.name, + } satisfies SelectItem) + ); + + return ( + + ); +}; diff --git a/webapp/src/service/billingApiSchema.generated.ts b/webapp/src/service/billingApiSchema.generated.ts index c34979954a..68b9d70f30 100644 --- a/webapp/src/service/billingApiSchema.generated.ts +++ b/webapp/src/service/billingApiSchema.generated.ts @@ -20,6 +20,10 @@ export interface paths { /** When applied, current subscription will be cancelled at the period end. */ put: operations["cancelSubscription"]; }; + "/v2/administration/organizations/{organizationId}/billing/assign-cloud-plan": { + /** Assigns a private free plan or trial plan to an organization. */ + put: operations["assignPlan"]; + }; "/v2/administration/billing/translation-agency/{agencyId}": { get: operations["get_1"]; put: operations["update"]; @@ -395,7 +399,8 @@ export interface components { | "native_authentication_disabled" | "invitation_organization_mismatch" | "user_is_managed_by_organization" - | "cannot_set_sso_provider_missing_fields"; + | "cannot_set_sso_provider_missing_fields" + | "date_has_to_be_in_the_future"; params?: { [key: string]: unknown }[]; }; ErrorResponseBody: { @@ -547,6 +552,12 @@ export interface components { prorationDate: number; endingBalance: number; }; + AssignPlanRequest: { + /** Format: int64 */ + trialEnd?: number; + /** Format: int64 */ + planId: number; + }; UpdateTranslationAgencyRequest: { name: string; description: string; @@ -654,16 +665,6 @@ export interface components { stripeProductId: string; forOrganizationIds: number[]; }; - AutoAssignOrganizationDto: { - /** Format: int64 */ - organizationId: number; - /** - * Format: int64 - * @description Trial end in milliseconds since epoch - * @example 1630000000000 - */ - trialEnd?: number; - }; CloudPlanRequest: { name: string; free: boolean; @@ -700,7 +701,6 @@ export interface components { /** Format: date-time */ usableUntil?: string; forOrganizationIds: number[]; - autoAssignOrganizations: components["schemas"]["AutoAssignOrganizationDto"][]; }; CloudPlanAdministrationModel: { /** Format: int64 */ @@ -1273,6 +1273,55 @@ export interface operations { }; }; }; + /** Assigns a private free plan or trial plan to an organization. */ + assignPlan: { + parameters: { + path: { + organizationId: number; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": + | components["schemas"]["ErrorResponseTyped"] + | components["schemas"]["ErrorResponseBody"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AssignPlanRequest"]; + }; + }; + }; get_1: { parameters: { path: { @@ -2338,6 +2387,16 @@ export interface operations { }; }; getPlans_2: { + parameters: { + query: { + /** + * Can be + * - private free, visible for organization + * - or paid (Assignable as trial) + */ + filterAssignableToOrganization?: number; + }; + }; responses: { /** OK */ 200: { From 8a8358d83b95fdde47a5bad7218a0ba1e919032f Mon Sep 17 00:00:00 2001 From: Jan Cizmar Date: Sat, 21 Dec 2024 17:09:28 +0000 Subject: [PATCH 03/46] feat: Server task runner (draft) --- .../kotlin/io/tolgee/batch/BatchJobService.kt | 4 +- .../kotlin/io/tolgee/batch/ChunkProcessor.kt | 5 ++ .../io/tolgee/batch/data/BatchJobType.kt | 24 ++++----- .../data/ScheduledServerTaskTargetItem.kt | 5 ++ .../ScheduledServerTaskChunkProcessor.kt | 51 +++++++++++++++++++ .../batch/serverTasks/ServerTaskRunner.kt | 8 +++ 6 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 backend/data/src/main/kotlin/io/tolgee/batch/data/ScheduledServerTaskTargetItem.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/batch/processors/ScheduledServerTaskChunkProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/batch/serverTasks/ServerTaskRunner.kt diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt index c156e3ae77..320bff313d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt @@ -118,7 +118,7 @@ class BatchJobService( entityManager.flushAndClear() - val executions = storeExecutions(chunked, job) + val executions = storeExecutions(chunked = chunked, job = job, executeAfter = processor.getExecuteAfter(request)) applicationContext.publishEvent(OnBatchJobCreated(job, executions)) @@ -128,12 +128,14 @@ class BatchJobService( private fun storeExecutions( chunked: List>, job: BatchJob, + executeAfter: Date?, ): List { val executions = List(chunked.size) { chunkNumber -> BatchJobChunkExecution().apply { batchJob = job this.chunkNumber = chunkNumber + this.executeAfter = executeAfter } } diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessor.kt index 97a7fd3629..ba60f5fa4f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessor.kt @@ -2,6 +2,7 @@ package io.tolgee.batch import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.tolgee.batch.data.BatchJobDto +import java.util.* import kotlin.coroutines.CoroutineContext interface ChunkProcessor { @@ -14,6 +15,10 @@ interface ChunkProcessor { fun getTarget(data: RequestType): List + fun getExecuteAfter(data: RequestType): Date? { + return null + } + fun getParams(data: RequestType): ParamsType fun getParams(job: BatchJobDto): ParamsType { diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt b/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt index dc590158bd..d680181102 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt @@ -2,27 +2,22 @@ package io.tolgee.batch.data import io.tolgee.activity.data.ActivityType import io.tolgee.batch.ChunkProcessor -import io.tolgee.batch.processors.AutoTranslateChunkProcessor -import io.tolgee.batch.processors.AutomationChunkProcessor -import io.tolgee.batch.processors.ClearTranslationsChunkProcessor -import io.tolgee.batch.processors.CopyTranslationsChunkProcessor -import io.tolgee.batch.processors.DeleteKeysChunkProcessor -import io.tolgee.batch.processors.MachineTranslationChunkProcessor -import io.tolgee.batch.processors.PreTranslationByTmChunkProcessor -import io.tolgee.batch.processors.SetKeysNamespaceChunkProcessor -import io.tolgee.batch.processors.SetTranslationsStateChunkProcessor -import io.tolgee.batch.processors.TagKeysChunkProcessor -import io.tolgee.batch.processors.UntagKeysChunkProcessor +import io.tolgee.batch.processors.* import kotlin.reflect.KClass enum class BatchJobType( - val activityType: ActivityType, + val activityType: ActivityType? = null, /** * 0 means no chunking */ val maxRetries: Int, val processor: KClass>, val defaultRetryWaitTimeInMs: Int = 2000, + + /** + * Whether run of this job type should be exclusive for a project + * So only one job can run at a time for a project + */ val exclusive: Boolean = true, ) { PRE_TRANSLATE_BT_TM( @@ -81,4 +76,9 @@ enum class BatchJobType( processor = AutomationChunkProcessor::class, exclusive = false, ), + SCHEDULED_SERVER_TASK( + // we can always handle the retries in the exception thrown by the processor + maxRetries = 0, + processor = ScheduledServerTaskChunkProcessor::class, + ), } diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/data/ScheduledServerTaskTargetItem.kt b/backend/data/src/main/kotlin/io/tolgee/batch/data/ScheduledServerTaskTargetItem.kt new file mode 100644 index 0000000000..545dff7824 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/batch/data/ScheduledServerTaskTargetItem.kt @@ -0,0 +1,5 @@ +package io.tolgee.batch.data + +import java.util.* + +data class ScheduledServerTaskTargetItem(val jobBean: String, val executeAfter: Date, val data: Any) diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/processors/ScheduledServerTaskChunkProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/processors/ScheduledServerTaskChunkProcessor.kt new file mode 100644 index 0000000000..04428b9901 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/batch/processors/ScheduledServerTaskChunkProcessor.kt @@ -0,0 +1,51 @@ +package io.tolgee.batch.processors + +import io.tolgee.batch.ChunkProcessor +import io.tolgee.batch.data.BatchJobDto +import io.tolgee.batch.data.ScheduledServerTaskTargetItem +import io.tolgee.batch.serverTasks.ServerTaskRunner +import org.springframework.context.ApplicationContext +import org.springframework.stereotype.Component +import java.util.* +import kotlin.coroutines.CoroutineContext + +@Component +class ScheduledServerTaskChunkProcessor( + private val applicationContext: ApplicationContext +) : ChunkProcessor { + override fun process( + job: BatchJobDto, + chunk: List, + coroutineContext: CoroutineContext, + onProgress: (Int) -> Unit, + ) { + chunk.forEach { + val bean = applicationContext.getBean(it.jobBean) + if (bean !is ServerTaskRunner) { + throw RuntimeException("Bean ${it.jobBean} is not instance of ServerTaskRunner") + } + + bean.execute(it.data) + } + } + + override fun getTarget(data: ScheduledServerTaskTargetItem): List { + return listOf(data) + } + + override fun getExecuteAfter(data: ScheduledServerTaskTargetItem): Date? { + return data.executeAfter + } + + override fun getParamsType(): Class { + return ScheduledServerTaskTargetItem::class.java + } + + override fun getTargetItemType(): Class { + return ScheduledServerTaskTargetItem::class.java + } + + override fun getParams(data: ScheduledServerTaskTargetItem): ScheduledServerTaskTargetItem { + return data + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/serverTasks/ServerTaskRunner.kt b/backend/data/src/main/kotlin/io/tolgee/batch/serverTasks/ServerTaskRunner.kt new file mode 100644 index 0000000000..beca76945b --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/batch/serverTasks/ServerTaskRunner.kt @@ -0,0 +1,8 @@ +package io.tolgee.batch.serverTasks + +import java.util.* + +interface ServerTaskRunner { + fun plan(executeAt: Date, data: Any) + fun execute(data: Any) +} From 92778c1c4e7bd74aed48c84f2c1ca4b86b821007 Mon Sep 17 00:00:00 2001 From: Jan Cizmar Date: Sat, 28 Dec 2024 14:19:49 +0100 Subject: [PATCH 04/46] chore: Fix trial plan assigment --- backend/data/src/main/kotlin/io/tolgee/constants/Message.kt | 1 - 1 file changed, 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 6bb360fcce..ad92625eec 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -238,7 +238,6 @@ enum class Message { CANNOT_SUBSCRIBE_TO_FREE_PLAN, PLAN_AUTO_ASSIGNMENT_ONLY_FOR_FREE_PLANS, PLAN_AUTO_ASSIGNMENT_ONLY_FOR_PRIVATE_PLANS, - PLAN_AUTO_ASSIGNMENT_ORGANIZATION_IDS_NOT_IN_FOR_ORGANIZATION_IDS, TASK_NOT_FOUND, TASK_NOT_FINISHED, TASK_NOT_OPEN, From 61b6a79935b3a28dae33458f22084597f101cde2 Mon Sep 17 00:00:00 2001 From: Jan Cizmar Date: Sat, 28 Dec 2024 18:17:50 +0100 Subject: [PATCH 05/46] feat: All types of free trial with notice --- .../io/tolgee/batch/BatchJobTestUtil.kt | 2 +- .../batch/BatchJobProjectLockingManager.kt | 13 +++-- .../kotlin/io/tolgee/batch/BatchJobService.kt | 8 +-- .../io/tolgee/batch/BatchOperationParams.kt | 2 +- .../kotlin/io/tolgee/batch/ChunkProcessor.kt | 2 +- .../batch/cleaning/ScheduledJobCleaner.kt | 2 +- .../io/tolgee/batch/data/BatchJobDto.kt | 4 +- .../io/tolgee/batch/data/BatchJobType.kt | 7 ++- .../data/ScheduledServerTaskTargetItem.kt | 5 -- .../batch/data/TrialExpirationNoticeItem.kt | 9 ++++ .../batch/events/OnBatchJobStatusUpdated.kt | 2 +- .../processors/AutoTranslateChunkProcessor.kt | 6 ++- .../MachineTranslationChunkProcessor.kt | 3 +- .../PreTranslationByTmChunkProcessor.kt | 2 +- .../ScheduledServerTaskChunkProcessor.kt | 51 ------------------- .../batch/processors/TagKeysChunkProcessor.kt | 7 ++- .../TrialExpirationNoticeProcessor.kt | 5 ++ .../processors/UntagKeysChunkProcessor.kt | 3 +- .../tolgee/component/FrontendUrlProvider.kt | 23 ++++++++- .../component/email/TolgeeEmailSender.kt | 2 +- .../kotlin/io/tolgee/dtos/misc/EmailParams.kt | 1 + .../kotlin/io/tolgee/model/batch/BatchJob.kt | 4 +- .../repository/OrganizationRoleRepository.kt | 11 ++++ .../organization/OrganizationRoleService.kt | 5 ++ .../io/tolgee/fixtures/EmailTestUtil.kt | 10 ++-- 25 files changed, 97 insertions(+), 92 deletions(-) delete mode 100644 backend/data/src/main/kotlin/io/tolgee/batch/data/ScheduledServerTaskTargetItem.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/batch/data/TrialExpirationNoticeItem.kt delete mode 100644 backend/data/src/main/kotlin/io/tolgee/batch/processors/ScheduledServerTaskChunkProcessor.kt create mode 100644 backend/data/src/main/kotlin/io/tolgee/batch/processors/TrialExpirationNoticeProcessor.kt diff --git a/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt b/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt index 337655bbff..04ab0c60dd 100644 --- a/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt +++ b/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt @@ -400,7 +400,7 @@ class BatchJobTestUtil( waitForNotThrowing { // the project was unlocked before job2 acquired the job verify(batchJobProjectLockingManager, times(1)).unlockJobForProject( - ArgumentMatchers.eq(job.project.id), + ArgumentMatchers.eq(job.project?.id), ArgumentMatchers.eq(job.id), ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobProjectLockingManager.kt b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobProjectLockingManager.kt index 271e777388..17cc45e02f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobProjectLockingManager.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobProjectLockingManager.kt @@ -47,9 +47,10 @@ class BatchJobProjectLockingManager( } fun unlockJobForProject( - projectId: Long, + projectId: Long?, jobId: Long, ) { + projectId ?: return getMap().compute(projectId) { _, lockedJobId -> logger.debug("Unlocking job: $jobId for project $projectId") if (lockedJobId == jobId) { @@ -69,8 +70,9 @@ class BatchJobProjectLockingManager( } private fun tryLockWithRedisson(batchJobDto: BatchJobDto): Boolean { + val projectId = batchJobDto.projectId ?: return true val computed = - getRedissonProjectLocks().compute(batchJobDto.projectId) { _, value -> + getRedissonProjectLocks().compute(projectId) { _, value -> computeFnBody(batchJobDto, value) } return computed == batchJobDto.id @@ -84,8 +86,9 @@ class BatchJobProjectLockingManager( } private fun tryLockLocal(toLock: BatchJobDto): Boolean { + val projectId = toLock.projectId ?: return true val computed = - localProjectLocks.compute(toLock.projectId) { _, value -> + localProjectLocks.compute(projectId) { _, value -> val newLocked = computeFnBody(toLock, value) logger.debug("While trying to lock ${toLock.id} for project ${toLock.projectId} new lock value is $newLocked") newLocked @@ -97,6 +100,8 @@ class BatchJobProjectLockingManager( toLock: BatchJobDto, currentValue: Long?, ): Long { + val projectId = toLock.projectId + ?: throw IllegalStateException("Project id is required. Locking for project should not happen for non-project jobs.") // nothing is locked if (currentValue == 0L) { logger.debug("Locking job ${toLock.id} for project ${toLock.projectId}, nothing is locked") @@ -107,7 +112,7 @@ class BatchJobProjectLockingManager( if (currentValue == null) { logger.debug("Getting initial locked state from DB state") // we have to find out from database if there is any running job for the project - val initial = getInitialJobId(toLock.projectId) + val initial = getInitialJobId(projectId) logger.debug("Initial locked job $initial for project ${toLock.projectId}") if (initial == null) { logger.debug("No job found, locking ${toLock.id}") diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt index 320bff313d..c2bb1b853f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt @@ -67,8 +67,8 @@ class BatchJobService( @Transactional fun startJob( request: Any, - project: Project, - author: UserAccount?, + project: Project? = null, + author: UserAccount? = null, type: BatchJobType, isHidden: Boolean = false, debounceDuration: Duration? = null, @@ -79,7 +79,7 @@ class BatchJobService( val params = BatchOperationParams( - projectId = project.id, + projectId = project?.id, type = type, request = request, target = target, @@ -100,7 +100,7 @@ class BatchJobService( this.author = author this.target = target this.totalItems = target.size - this.chunkSize = processor.getChunkSize(projectId = project.id, request = request) + this.chunkSize = processor.getChunkSize(projectId = project?.id, request = request) this.jobCharacter = processor.getJobCharacter() this.maxPerJobConcurrency = processor.getMaxPerJobConcurrency() this.type = type diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/BatchOperationParams.kt b/backend/data/src/main/kotlin/io/tolgee/batch/BatchOperationParams.kt index c33588d07f..5ffe599051 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/BatchOperationParams.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/BatchOperationParams.kt @@ -4,7 +4,7 @@ import io.tolgee.batch.data.BatchJobType class BatchOperationParams( val type: BatchJobType, - val projectId: Long, + val projectId: Long?, val target: List, val request: Any?, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessor.kt index ba60f5fa4f..61f0961c41 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/ChunkProcessor.kt @@ -35,7 +35,7 @@ interface ChunkProcessor { fun getChunkSize( request: RequestType, - projectId: Long, + projectId: Long?, ): Int { return 0 } diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/cleaning/ScheduledJobCleaner.kt b/backend/data/src/main/kotlin/io/tolgee/batch/cleaning/ScheduledJobCleaner.kt index 1d07ed76f3..62728c15a5 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/cleaning/ScheduledJobCleaner.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/cleaning/ScheduledJobCleaner.kt @@ -55,7 +55,7 @@ class ScheduledJobCleaner( val lockedJobIds = lockingManager.getLockedJobIds() + batchJobStateProvider.getCachedJobIds() batchJobService.getJobsCompletedBefore(lockedJobIds, currentDateProvider.date.addSeconds(-10)) .forEach { - unlockAndRemoveState(it.project.id, it.id) + unlockAndRemoveState(it.project?.id, it.id) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobDto.kt b/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobDto.kt index 43ca0544ee..a96b153e5c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobDto.kt @@ -7,7 +7,7 @@ import io.tolgee.model.batch.IBatchJob class BatchJobDto( override var id: Long, - val projectId: Long, + val projectId: Long?, val authorId: Long?, val target: List, val totalItems: Int, @@ -31,7 +31,7 @@ class BatchJobDto( fun fromEntity(entity: BatchJob): BatchJobDto { return BatchJobDto( id = entity.id, - projectId = entity.project.id, + projectId = entity.project?.id, authorId = entity.author?.id, target = entity.target, totalItems = entity.totalItems, diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt b/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt index d680181102..37bca88c24 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/data/BatchJobType.kt @@ -76,9 +76,8 @@ enum class BatchJobType( processor = AutomationChunkProcessor::class, exclusive = false, ), - SCHEDULED_SERVER_TASK( - // we can always handle the retries in the exception thrown by the processor - maxRetries = 0, - processor = ScheduledServerTaskChunkProcessor::class, + BILLING_TRIAL_EXPIRATION_NOTICE( + maxRetries = 3, + processor = TrialExpirationNoticeProcessor::class, ), } diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/data/ScheduledServerTaskTargetItem.kt b/backend/data/src/main/kotlin/io/tolgee/batch/data/ScheduledServerTaskTargetItem.kt deleted file mode 100644 index 545dff7824..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/batch/data/ScheduledServerTaskTargetItem.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.tolgee.batch.data - -import java.util.* - -data class ScheduledServerTaskTargetItem(val jobBean: String, val executeAfter: Date, val data: Any) diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/data/TrialExpirationNoticeItem.kt b/backend/data/src/main/kotlin/io/tolgee/batch/data/TrialExpirationNoticeItem.kt new file mode 100644 index 0000000000..28f8ff7c9b --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/batch/data/TrialExpirationNoticeItem.kt @@ -0,0 +1,9 @@ +package io.tolgee.batch.data + +import java.util.* + +data class TrialExpirationNoticeItem( + val trialEnd: Date, + val organizationId: Long, + val daysBefore: Int, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobStatusUpdated.kt b/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobStatusUpdated.kt index 1ff05fde54..fce09b896e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobStatusUpdated.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/events/OnBatchJobStatusUpdated.kt @@ -4,6 +4,6 @@ import io.tolgee.model.batch.BatchJobStatus class OnBatchJobStatusUpdated( val jobId: Long, - val projectId: Long, + val projectId: Long?, val status: BatchJobStatus, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/processors/AutoTranslateChunkProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/processors/AutoTranslateChunkProcessor.kt index 72c124f048..dfa216ab94 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/processors/AutoTranslateChunkProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/processors/AutoTranslateChunkProcessor.kt @@ -26,9 +26,10 @@ class AutoTranslateChunkProcessor( coroutineContext: CoroutineContext, onProgress: (Int) -> Unit, ) { + val projectId = job.projectId ?: throw IllegalArgumentException("Project id is required") genericAutoTranslationChunkProcessor.iterateCatching(chunk, coroutineContext) { item -> val (keyId, languageId) = item - autoTranslationService.softAutoTranslate(job.projectId, keyId, languageId) + autoTranslationService.softAutoTranslate(projectId, keyId, languageId) } } @@ -50,8 +51,9 @@ class AutoTranslateChunkProcessor( override fun getChunkSize( request: AutoTranslationRequest, - projectId: Long, + projectId: Long?, ): Int { + projectId ?: throw IllegalArgumentException("Project id is required") val languageIds = request.target.map { it.languageId }.distinct() val project = projectService.getDto(projectId) val services = mtServiceConfigService.getPrimaryServices(languageIds, project.id).values.toSet() diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/processors/MachineTranslationChunkProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/processors/MachineTranslationChunkProcessor.kt index 2453f283f8..11aadfa15d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/processors/MachineTranslationChunkProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/processors/MachineTranslationChunkProcessor.kt @@ -57,8 +57,9 @@ class MachineTranslationChunkProcessor( override fun getChunkSize( request: MachineTranslationRequest, - projectId: Long, + projectId: Long?, ): Int { + projectId ?: throw IllegalArgumentException("Project id is required") val languageIds = request.targetLanguageIds val services = mtServiceConfigService.getPrimaryServices(languageIds, projectId).values.toSet() if (services.map { it?.serviceType }.contains(MtServiceType.TOLGEE)) { diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/processors/PreTranslationByTmChunkProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/processors/PreTranslationByTmChunkProcessor.kt index 34b9755528..3b2b279bff 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/processors/PreTranslationByTmChunkProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/processors/PreTranslationByTmChunkProcessor.kt @@ -54,7 +54,7 @@ class PreTranslationByTmChunkProcessor( override fun getChunkSize( request: PreTranslationByTmRequest, - projectId: Long, + projectId: Long?, ): Int { return 10 } diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/processors/ScheduledServerTaskChunkProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/processors/ScheduledServerTaskChunkProcessor.kt deleted file mode 100644 index 04428b9901..0000000000 --- a/backend/data/src/main/kotlin/io/tolgee/batch/processors/ScheduledServerTaskChunkProcessor.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.tolgee.batch.processors - -import io.tolgee.batch.ChunkProcessor -import io.tolgee.batch.data.BatchJobDto -import io.tolgee.batch.data.ScheduledServerTaskTargetItem -import io.tolgee.batch.serverTasks.ServerTaskRunner -import org.springframework.context.ApplicationContext -import org.springframework.stereotype.Component -import java.util.* -import kotlin.coroutines.CoroutineContext - -@Component -class ScheduledServerTaskChunkProcessor( - private val applicationContext: ApplicationContext -) : ChunkProcessor { - override fun process( - job: BatchJobDto, - chunk: List, - coroutineContext: CoroutineContext, - onProgress: (Int) -> Unit, - ) { - chunk.forEach { - val bean = applicationContext.getBean(it.jobBean) - if (bean !is ServerTaskRunner) { - throw RuntimeException("Bean ${it.jobBean} is not instance of ServerTaskRunner") - } - - bean.execute(it.data) - } - } - - override fun getTarget(data: ScheduledServerTaskTargetItem): List { - return listOf(data) - } - - override fun getExecuteAfter(data: ScheduledServerTaskTargetItem): Date? { - return data.executeAfter - } - - override fun getParamsType(): Class { - return ScheduledServerTaskTargetItem::class.java - } - - override fun getTargetItemType(): Class { - return ScheduledServerTaskTargetItem::class.java - } - - override fun getParams(data: ScheduledServerTaskTargetItem): ScheduledServerTaskTargetItem { - return data - } -} diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/processors/TagKeysChunkProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/processors/TagKeysChunkProcessor.kt index a4ccf5ac39..1d27e179d8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/processors/TagKeysChunkProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/processors/TagKeysChunkProcessor.kt @@ -23,10 +23,13 @@ class TagKeysChunkProcessor( ) { val subChunked = chunk.chunked(100) as List> var progress: Int = 0 - var params = getParams(job) + val params = getParams(job) + + val projectId = job.projectId ?: throw IllegalArgumentException("Project id is required") + subChunked.forEach { subChunk -> coroutineContext.ensureActive() - tagService.tagKeysById(job.projectId, subChunk.associateWith { params.tags }) + tagService.tagKeysById(projectId, subChunk.associateWith { params.tags }) entityManager.flush() progress += subChunk.size onProgress.invoke(progress) diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/processors/TrialExpirationNoticeProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/processors/TrialExpirationNoticeProcessor.kt new file mode 100644 index 0000000000..fca3b95a70 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/batch/processors/TrialExpirationNoticeProcessor.kt @@ -0,0 +1,5 @@ +package io.tolgee.batch.processors + +import io.tolgee.batch.ChunkProcessor + +interface TrialExpirationNoticeProcessor: ChunkProcessor diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/processors/UntagKeysChunkProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/batch/processors/UntagKeysChunkProcessor.kt index 093e406230..c8e235d215 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/processors/UntagKeysChunkProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/processors/UntagKeysChunkProcessor.kt @@ -25,9 +25,10 @@ class UntagKeysChunkProcessor( val subChunked = chunk.chunked(100) as List> var progress = 0 val params = getParams(job) + val projectId = job.projectId ?: throw IllegalArgumentException("Project id is required") subChunked.forEach { subChunk -> coroutineContext.ensureActive() - tagService.untagKeys(job.projectId, subChunk.associateWith { params.tags }) + tagService.untagKeys(projectId, subChunk.associateWith { params.tags }) entityManager.flush() progress += subChunk.size onProgress.invoke(progress) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/FrontendUrlProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/FrontendUrlProvider.kt index ad142a557f..a42e50438a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/FrontendUrlProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/FrontendUrlProvider.kt @@ -10,20 +10,39 @@ class FrontendUrlProvider( ) { val url: String get() { - if (!tolgeeProperties.frontEndUrl.isNullOrBlank()) { - return tolgeeProperties.frontEndUrl!! + val frontEndUrlFromProperties = tolgeeProperties.frontEndUrl + if (!frontEndUrlFromProperties.isNullOrBlank()) { + return frontEndUrlFromProperties } + return getFromServerRequest() + } + + private fun getFromServerRequest(): String { + try { val builder = ServletUriComponentsBuilder.fromCurrentRequestUri() builder.replacePath("") builder.replaceQuery("") return builder.build().toUriString() + } catch (e: IllegalStateException) { + if (e.message?.contains("No current ServletRequestAttributes") == true) { + throw IllegalStateException( + "Trying to find frontend url, but there is no current request. " + + "You will have to specify frontend url in application properties." + ) + } + throw e } + } fun getSubscriptionsUrl(organizationSlug: String): String { return "${this.url}/organizations/$organizationSlug/subscriptions" } + fun getInvoicesUrl(organizationSlug: String): String { + return "${this.url}/organizations/$organizationSlug/invoices" + } + fun getSelfHostedSubscriptionsUrl(organizationSlug: String): String { return "${this.url}/organizations/$organizationSlug/subscriptions/self-hosted-ee" } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt index cc61780c6e..1d603e2fa3 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt @@ -15,7 +15,7 @@ class TolgeeEmailSender( fun sendEmail(params: EmailParams) { validateProps() val helper = mimeMessageHelperFactory.create() - helper.setFrom(tolgeeProperties.smtp.from!!) + helper.setFrom(params.from ?: tolgeeProperties.smtp.from!!) helper.setTo(params.to) params.replyTo?.let { helper.setReplyTo(it) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailParams.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailParams.kt index 7c3d23ec37..6e751c3e16 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailParams.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailParams.kt @@ -2,6 +2,7 @@ package io.tolgee.dtos.misc class EmailParams( var to: String, + var from: String? = null, var bcc: Array? = null, var text: String, var subject: String, diff --git a/backend/data/src/main/kotlin/io/tolgee/model/batch/BatchJob.kt b/backend/data/src/main/kotlin/io/tolgee/model/batch/BatchJob.kt index e68aa5c371..73dfc530a6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/batch/BatchJob.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/batch/BatchJob.kt @@ -30,8 +30,8 @@ import java.util.* ], ) class BatchJob : StandardAuditModel(), IBatchJob { - @ManyToOne(fetch = FetchType.LAZY) - lateinit var project: Project + @ManyToOne(fetch = FetchType.LAZY, optional = true) + var project: Project? = null @ManyToOne(fetch = FetchType.LAZY) var author: UserAccount? = null diff --git a/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRoleRepository.kt b/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRoleRepository.kt index eb2146e186..33020447a0 100644 --- a/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRoleRepository.kt +++ b/backend/data/src/main/kotlin/io/tolgee/repository/OrganizationRoleRepository.kt @@ -2,9 +2,11 @@ package io.tolgee.repository import io.tolgee.model.Organization import io.tolgee.model.OrganizationRole +import io.tolgee.model.UserAccount import io.tolgee.model.enums.OrganizationRoleType import org.springframework.context.annotation.Lazy import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @Repository @@ -24,4 +26,13 @@ interface OrganizationRoleRepository : JpaRepository { ): Long fun deleteByOrganization(organization: Organization) + + @Query( + """ + select or.user from OrganizationRole or + where or.organization = :organization + and or.type = io.tolgee.model.enums.OrganizationRoleType.OWNER + """ + ) + fun getOwners(organization: Organization): List } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt index 332779c9d9..651de66d9f 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/organization/OrganizationRoleService.kt @@ -340,4 +340,9 @@ class OrganizationRoleService( val cache = cacheManager.getCache(Caches.ORGANIZATION_ROLES) cache?.evict(arrayListOf(organizationId, userId)) } + + @Transactional + fun getOwners(organization: Organization): List { + return organizationRoleRepository.getOwners(organization) + } } 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 dbf763ab9d..3bebe2e0ed 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt @@ -6,11 +6,7 @@ import jakarta.mail.internet.MimeMessage import jakarta.mail.internet.MimeMultipart import org.assertj.core.api.AbstractStringAssert import org.mockito.Mockito -import org.mockito.kotlin.KArgumentCaptor -import org.mockito.kotlin.any -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever +import org.mockito.kotlin.* import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.mail.javamail.JavaMailSender @@ -41,6 +37,9 @@ class EmailTestUtil() { val firstMessageContent: String get() = messageContents.first() + val singleEmailContent + get() = messageContents.single() + val messageContents: List get() = messageArgumentCaptor.allValues.map { @@ -72,4 +71,5 @@ class EmailTestUtil() { fun findEmail(to: String): MimeMessage? { return messageArgumentCaptor.allValues.find { it.getHeader("To")[0] == to } } + } From 803880abfcdff0bb524a344fbb2ecc4ee1451d5e Mon Sep 17 00:00:00 2001 From: Jan Cizmar Date: Sat, 28 Dec 2024 18:52:27 +0100 Subject: [PATCH 06/46] fix: Make scheduling working correctly --- .../data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt index c2bb1b853f..0aae10b16e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/batch/BatchJobService.kt @@ -151,8 +151,8 @@ class BatchJobService( jdbcTemplate.batchUpdate( """ insert into tolgee_batch_job_chunk_execution - (id, batch_job_id, chunk_number, status, created_at, updated_at, success_targets) - values (?, ?, ?, ?, ?, ?, ?) + (id, batch_job_id, chunk_number, status, created_at, updated_at, success_targets, execute_after) + values (?, ?, ?, ?, ?, ?, ?, ?) """, executions, 100, @@ -172,6 +172,7 @@ class BatchJobService( value = objectMapper.writeValueAsString(execution.successTargets) }, ) + ps.setTimestamp(8, execution.executeAfter?.time?.let { Timestamp(it) }) } } From ada8b3005027c51b2611dbbadbbcb2f4dfdd4523 Mon Sep 17 00:00:00 2001 From: Jan Cizmar Date: Mon, 30 Dec 2024 16:25:35 +0100 Subject: [PATCH 07/46] feat: Cloud billing, trials and admin > working --- .../kotlin/io/tolgee/constants/Message.kt | 3 + e2e/cypress/support/dataCyType.d.ts | 2 +- webapp/package.json | 2 +- .../src/constants/GlobalValidationSchema.tsx | 1 + .../AdministrationCloudPlanCreateView.tsx | 65 +--- .../AdministrationCloudPlanEditView.tsx | 79 +--- .../AdministrationCloudPlansView.tsx | 1 + .../AdministrationEePlanCreateView.tsx | 2 +- .../AdministrationEePlanEditView.tsx | 2 +- .../components/AssignSwitchCheckbox.tsx | 46 --- .../components/CloudPlanForm.tsx | 362 ------------------ .../components/planForm/CloudPlanForm.tsx | 49 +++ .../components/planForm/CloudPlanFormBase.tsx | 68 ++++ .../{ => planForm}/CloudPlanOrganizations.tsx | 18 +- .../planForm/CloudPlanSaveButton.tsx | 30 ++ .../planForm/CreateCloudPlanForm.tsx | 95 +++++ .../CreatingPlanForOrganizationAlert.tsx | 27 ++ .../components/planForm/EditCloudPlanForm.tsx | 122 ++++++ .../components/{ => planForm}/EePlanForm.tsx | 0 .../{ => planForm}/EePlanOrganizations.tsx | 0 .../planForm/fields/CloudPlanFields.tsx | 189 +++++++++ .../fields/CloudPlanPricesAndLimits.tsx | 114 ++++++ .../fields/PlanNonCommercialSwitch.tsx | 28 ++ .../PlanOrganizationsMultiselectField.tsx | 32 ++ .../planForm/fields/PlanPublicSwitchField.tsx | 45 +++ .../planForm/fields/PlanSelectorField.tsx | 93 +++++ .../planForm/fields/PlanTemplateSelector.tsx | 41 ++ .../planForm/getCloudPlanInitialValues.ts | 50 +++ ...nSubscriptionsCloudSubscriptionPopover.tsx | 42 -- ...sx => AdministrationSubscriptionsView.tsx} | 20 +- .../subscriptions/CloudPlanSelector.tsx | 52 --- .../AdministrationSubscriptionsCloudPlan.tsx | 16 +- .../AdministrationSubscriptionsListItem.tsx | 32 ++ .../AssignCloudTrialDialog.tsx | 66 +++- .../OrganizationCloudCustomPlans.tsx | 70 ++++ .../SubscriptionCloudPlanPopover.tsx | 112 ++++++ .../SubscriptionsCloudEditPlanButton.tsx | 44 +++ .../developer/webhook/WebhookEditDialog.tsx | 1 - webapp/src/eeSetup/eeModule.ee.tsx | 4 +- webapp/src/fixtures/errorFIxtures.ts | 1 - webapp/src/index.tsx | 6 +- .../src/service/billingApiSchema.generated.ts | 160 ++++---- .../developer/contentDelivery/CdList.tsx | 1 - 43 files changed, 1437 insertions(+), 756 deletions(-) delete mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/AssignSwitchCheckbox.tsx delete mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/CloudPlanForm.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanForm.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanFormBase.tsx rename webapp/src/ee/billing/administration/subscriptionPlans/components/{ => planForm}/CloudPlanOrganizations.tsx (89%) create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanSaveButton.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CreateCloudPlanForm.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CreatingPlanForOrganizationAlert.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/EditCloudPlanForm.tsx rename webapp/src/ee/billing/administration/subscriptionPlans/components/{ => planForm}/EePlanForm.tsx (100%) rename webapp/src/ee/billing/administration/subscriptionPlans/components/{ => planForm}/EePlanOrganizations.tsx (100%) create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanFields.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanPricesAndLimits.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanNonCommercialSwitch.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanOrganizationsMultiselectField.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanPublicSwitchField.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanSelectorField.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanTemplateSelector.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/getCloudPlanInitialValues.ts delete mode 100644 webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsCloudSubscriptionPopover.tsx rename webapp/src/ee/billing/administration/subscriptions/{AdministrationSubscriptions.tsx => AdministrationSubscriptionsView.tsx} (73%) delete mode 100644 webapp/src/ee/billing/administration/subscriptions/CloudPlanSelector.tsx rename webapp/src/ee/billing/administration/subscriptions/{ => components}/AdministrationSubscriptionsCloudPlan.tsx (68%) create mode 100644 webapp/src/ee/billing/administration/subscriptions/components/AdministrationSubscriptionsListItem.tsx rename webapp/src/ee/billing/administration/subscriptions/{ => components}/AssignCloudTrialDialog.tsx (56%) create mode 100644 webapp/src/ee/billing/administration/subscriptions/components/OrganizationCloudCustomPlans.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptions/components/SubscriptionCloudPlanPopover.tsx create mode 100644 webapp/src/ee/billing/administration/subscriptions/components/SubscriptionsCloudEditPlanButton.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 ad92625eec..78508330f9 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -257,6 +257,9 @@ enum class Message { NAMESPACES_CANNOT_BE_DISABLED_WHEN_NAMESPACE_EXISTS, NAMESPACE_CANNOT_BE_USED_WHEN_FEATURE_IS_DISABLED, DATE_HAS_TO_BE_IN_THE_FUTURE, + CUSTOM_PLAN_AND_PLAN_ID_CANNOT_BE_SET_TOGETHER, + SPECIFY_PLAN_ID_OR_CUSTOM_PLAN, + CUSTOM_PLANS_HAS_TO_BE_PRIVATE, ; val code: String diff --git a/e2e/cypress/support/dataCyType.d.ts b/e2e/cypress/support/dataCyType.d.ts index dad4a6f992..92b4a125fb 100644 --- a/e2e/cypress/support/dataCyType.d.ts +++ b/e2e/cypress/support/dataCyType.d.ts @@ -25,12 +25,12 @@ declare namespace DataCy { "administration-cloud-plan-field-stripe-product" | "administration-cloud-plan-field-type" | "administration-cloud-plan-field-type-item" | - "administration-cloud-plan-organization-assign-switch" | "administration-cloud-plan-submit-button" | "administration-cloud-plans-item" | "administration-cloud-plans-item-delete" | "administration-cloud-plans-item-edit" | "administration-cloud-plans-item-public-badge" | + "administration-customize-plan-switch" | "administration-debug-customer-account-message" | "administration-debug-customer-exit-button" | "administration-ee-license-key-input" | diff --git a/webapp/package.json b/webapp/package.json index e3dbfb9a76..68bd87a85a 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -62,7 +62,7 @@ "uuid": "9.0.0", "web-vitals": "^2.1.0", "yup": "^0.32.9", - "zustand": "4.1.2", + "zustand": "^4.5.5", "zxcvbn": "^4.4.2" }, "scripts": { diff --git a/webapp/src/constants/GlobalValidationSchema.tsx b/webapp/src/constants/GlobalValidationSchema.tsx index d38a3abea2..bf153e3092 100644 --- a/webapp/src/constants/GlobalValidationSchema.tsx +++ b/webapp/src/constants/GlobalValidationSchema.tsx @@ -306,6 +306,7 @@ export class Validation { static readonly CLOUD_PLAN_FORM = Yup.object({ name: Yup.string().required(), + type: Yup.string().required(), stripeProductId: Yup.string().when('free', { is: false, then: Yup.string().required(), diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlanCreateView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlanCreateView.tsx index 800d2b0463..8dc18a643f 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlanCreateView.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlanCreateView.tsx @@ -1,24 +1,14 @@ import { Box, Typography } from '@mui/material'; -import { T, useTranslate } from '@tolgee/react'; -import { useHistory } from 'react-router-dom'; +import { useTranslate } from '@tolgee/react'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { LINKS } from 'tg.constants/links'; -import { useMessage } from 'tg.hooks/useSuccessMessage'; -import { useBillingApiMutation } from 'tg.service/http/useQueryApi'; import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; -import { CloudPlanForm } from './components/CloudPlanForm'; +import { CreateCloudPlanForm } from './components/planForm/CreateCloudPlanForm'; export const AdministrationCloudPlanCreateView = () => { - const messaging = useMessage(); - const history = useHistory(); const { t } = useTranslate(); - const createPlanLoadable = useBillingApiMutation({ - url: '/v2/administration/billing/cloud-plans', - method: 'post', - }); - return ( { {t('administration_cloud_plan_create')} - { - createPlanLoadable.mutate( - { - content: { - 'application/json': { - ...values, - stripeProductId: values.stripeProductId!, - forOrganizationIds: values.public - ? [] - : values.forOrganizationIds, - }, - }, - }, - { - onSuccess() { - messaging.success( - - ); - history.push( - LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS.build() - ); - }, - } - ); - }} - initialData={{ - type: 'PAY_AS_YOU_GO', - name: '', - stripeProductId: undefined, - prices: { - perSeat: 0, - subscriptionMonthly: 0, - subscriptionYearly: 0, - }, - includedUsage: { - seats: 0, - translations: 0, - mtCredits: 0, - }, - enabledFeatures: [], - public: true, - forOrganizationIds: [], - free: false, - nonCommercial: false, - autoAssignOrganizationIds: [], - }} - /> + + diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlanEditView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlanEditView.tsx index 5b71f57d73..76daba28d7 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlanEditView.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlanEditView.tsx @@ -1,48 +1,18 @@ import { Box, Typography } from '@mui/material'; -import { T, useTranslate } from '@tolgee/react'; -import { useHistory, useRouteMatch } from 'react-router-dom'; -import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import { useTranslate } from '@tolgee/react'; +import { useRouteMatch } from 'react-router-dom'; import { DashboardPage } from 'tg.component/layout/DashboardPage'; import { LINKS, PARAMS } from 'tg.constants/links'; -import { useMessage } from 'tg.hooks/useSuccessMessage'; -import { - useBillingApiMutation, - useBillingApiQuery, -} from 'tg.service/http/useQueryApi'; import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; -import { CloudPlanForm } from './components/CloudPlanForm'; +import { EditCloudPlanForm } from './components/planForm/EditCloudPlanForm'; export const AdministrationCloudPlanEditView = () => { const match = useRouteMatch(); const { t } = useTranslate(); - const messaging = useMessage(); - const history = useHistory(); const planId = match.params[PARAMS.PLAN_ID]; - const planLoadable = useBillingApiQuery({ - url: '/v2/administration/billing/cloud-plans/{planId}', - method: 'get', - path: { planId }, - }); - - const planEditLoadable = useBillingApiMutation({ - url: '/v2/administration/billing/cloud-plans/{planId}', - method: 'put', - invalidatePrefix: '/v2/administration/billing/cloud-plans', - }); - - if (planLoadable.isLoading) { - return ; - } - - const planData = planLoadable.data; - - if (!planData) { - return null; - } - return ( { {t('administration_cloud_plan_edit')} - { - planEditLoadable.mutate( - { - path: { planId }, - content: { - 'application/json': { - ...values, - stripeProductId: values.stripeProductId!, - forOrganizationIds: values.public - ? [] - : values.forOrganizationIds, - }, - }, - }, - { - onSuccess() { - messaging.success( - - ); - history.push( - LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS.build() - ); - }, - } - ); - }} - /> + ); diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlansView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlansView.tsx index 9f920b3c42..0763297d75 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlansView.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationCloudPlansView.tsx @@ -31,6 +31,7 @@ export const AdministrationCloudPlansView = () => { const plansLoadable = useBillingApiQuery({ url: '/v2/administration/billing/cloud-plans', method: 'get', + query: {}, }); const deletePlanLoadable = useBillingApiMutation({ diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationEePlanCreateView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationEePlanCreateView.tsx index e01aef1b87..40263cc210 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationEePlanCreateView.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationEePlanCreateView.tsx @@ -7,7 +7,7 @@ import { LINKS } from 'tg.constants/links'; import { useMessage } from 'tg.hooks/useSuccessMessage'; import { useBillingApiMutation } from 'tg.service/http/useQueryApi'; import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; -import { EePlanForm } from './components/EePlanForm'; +import { EePlanForm } from './components/planForm/EePlanForm'; export const AdministrationEePlanCreateView = () => { const messaging = useMessage(); diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationEePlanEditView.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationEePlanEditView.tsx index df7994583b..7024df6371 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationEePlanEditView.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/AdministrationEePlanEditView.tsx @@ -11,7 +11,7 @@ import { useBillingApiQuery, } from 'tg.service/http/useQueryApi'; import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; -import { EePlanForm } from './components/EePlanForm'; +import { EePlanForm } from './components/planForm/EePlanForm'; export const AdministrationEePlanEditView = () => { const match = useRouteMatch(); diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/AssignSwitchCheckbox.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/AssignSwitchCheckbox.tsx deleted file mode 100644 index fca89de6b1..0000000000 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/AssignSwitchCheckbox.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { FC } from 'react'; -import { FormControlLabel, Switch } from '@mui/material'; -import { useFormikContext } from 'formik'; -import { CloudPlanFormData } from './CloudPlanForm'; -import { useTranslate } from '@tolgee/react'; - -type AssignCheckboxProps = { - organizationId: number; -}; - -export const AssignSwitchCheckbox: FC = (props) => { - const { setFieldValue, values } = useFormikContext(); - - const value = values.autoAssignOrganizationIds; - const checked = value.includes(props.organizationId); - - const { t } = useTranslate(); - - const getNewValue = (value: number[], organizationId: number) => { - if (value.includes(organizationId)) { - return value.filter((id) => id !== organizationId); - } - return [...value, organizationId]; - }; - - const isInList = values.forOrganizationIds.includes(props.organizationId); - - const onChange = () => { - const newValue = getNewValue(value, props.organizationId); - setFieldValue('autoAssignOrganizationIds', newValue); - }; - - return ( - <> - {values.free && ( - } - data-cy="administration-cloud-plan-organization-assign-switch" - label={t('administration_cloud_plan_organization_field_auto_assign')} - /> - )} - - ); -}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/CloudPlanForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/CloudPlanForm.tsx deleted file mode 100644 index a61ca6b9ac..0000000000 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/CloudPlanForm.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import { - Box, - Checkbox, - FormControlLabel, - MenuItem, - Switch, - Typography, -} from '@mui/material'; -import { useTranslate } from '@tolgee/react'; -import { Field, FieldProps, Form, Formik } from 'formik'; - -import { TextField } from 'tg.component/common/form/fields/TextField'; -import { SearchSelect } from 'tg.component/searchSelect/SearchSelect'; -import { Select } from 'tg.component/common/form/fields/Select'; -import { components } from 'tg.service/billingApiSchema.generated'; -import { useBillingApiQuery } from 'tg.service/http/useQueryApi'; -import { Validation } from 'tg.constants/GlobalValidationSchema'; -import LoadingButton from 'tg.component/common/form/LoadingButton'; -import { CloudPlanOrganizations } from './CloudPlanOrganizations'; - -type CloudPlanModel = components['schemas']['CloudPlanRequest']; -type EnabledFeature = - components['schemas']['CloudPlanRequest']['enabledFeatures'][number]; - -export type CloudPlanFormData = { - type: CloudPlanModel['type']; - name: string; - prices: CloudPlanModel['prices']; - includedUsage: CloudPlanModel['includedUsage']; - stripeProductId: string | undefined; - enabledFeatures: EnabledFeature[]; - forOrganizationIds: number[]; - public: boolean; - free: boolean; - autoAssignOrganizationIds: CloudPlanModel['autoAssignOrganizationIds']; - nonCommercial: boolean; -}; - -type Props = { - planId?: number; - initialData: CloudPlanFormData; - onSubmit: (value: CloudPlanFormData) => void; - loading: boolean | undefined; -}; - -export function CloudPlanForm({ - planId, - initialData, - onSubmit, - loading, -}: Props) { - const { t } = useTranslate(); - - const productsLoadable = useBillingApiQuery({ - url: '/v2/administration/billing/stripe-products', - method: 'get', - }); - - const featuresLoadable = useBillingApiQuery({ - url: '/v2/administration/billing/features', - method: 'get', - }); - - const products = productsLoadable.data?._embedded?.stripeProducts; - - const typeOptions = [ - { value: 'PAY_AS_YOU_GO', label: 'Pay as you go' }, - { value: 'FIXED', label: 'Fixed' }, - { value: 'SLOTS_FIXED', label: 'Slots fixed' }, - ]; - - return ( - { - let prices = values.prices; - if (values.type !== 'PAY_AS_YOU_GO') { - prices = { - perSeat: values.prices.perSeat, - subscriptionMonthly: values.prices.subscriptionMonthly, - subscriptionYearly: values.prices.subscriptionYearly, - }; - } - onSubmit({ ...values, prices }); - }} - validationSchema={Validation.CLOUD_PLAN_FORM} - > - {({ values, errors, setFieldValue }) => ( -
- - - - - - {({ field, form, meta }: FieldProps) => ( - - label.toLowerCase().includes(prompt.toLowerCase()) - } - SelectProps={{ - // @ts-ignore - 'data-cy': - 'administration-cloud-plan-field-stripe-product', - label: t( - 'administration_cloud_plan_field_stripe_product' - ), - size: 'small', - fullWidth: true, - variant: 'outlined', - error: (meta.touched && meta.error) || '', - }} - value={field.value} - onChange={(val) => form.setFieldValue(field.name, val)} - items={[ - { value: undefined, name: 'None' }, - ...(products?.map(({ id, name }) => ({ - value: id, - name: `${id} ${name}`, - })) || []), - ]} - /> - )} - - - - {t('administration_cloud_plan_form_prices_title')} - - - - - - - - - - {t('administration_cloud_plan_form_limits_title')} - - - - - - - - {t('administration_cloud_plan_form_features_title')} - - - {(props: FieldProps) => - featuresLoadable.data?.map((feature) => { - const values = props.field.value; - - const toggleField = () => { - let newValues = values; - if (values.includes(feature)) { - newValues = values.filter((val) => val !== feature); - } else { - newValues = [...values, feature]; - } - props.form.setFieldValue(props.field.name, newValues); - }; - - return ( - - } - label={feature} - /> - ); - }) || [] - } - - - - setFieldValue('public', !values.public)} - /> - } - data-cy="administration-cloud-plan-field-public" - label={t('administration_cloud_plan_field_public')} - /> - - setFieldValue('free', !values.free)} - /> - } - data-cy="administration-cloud-plan-field-free" - label={t('administration_cloud_plan_field_free')} - /> - - - setFieldValue('nonCommercial', !values.nonCommercial) - } - /> - } - data-cy="administration-cloud-plan-field-non-commercial" - label="Non-commercial" - /> - - {!values.public && ( - - { - setFieldValue('forOrganizationIds', orgs); - }} - /> - {errors.forOrganizationIds && ( - - {errors.forOrganizationIds} - - )} - - )} - - - - {t('global_form_save')} - - - -
- )} -
- ); -} diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanForm.tsx new file mode 100644 index 0000000000..da5e855654 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanForm.tsx @@ -0,0 +1,49 @@ +import { Box } from '@mui/material'; +import { CloudPlanFields } from './fields/CloudPlanFields'; +import React, { ComponentProps, ReactNode } from 'react'; +import { PlanPublicSwitchField } from './fields/PlanPublicSwitchField'; +import { PlanOrganizationsMultiselectField } from './fields/PlanOrganizationsMultiselectField'; +import { CloudPlanSaveButton } from './CloudPlanSaveButton'; +import { CloudPlanFormBase, CloudPlanFormData } from './CloudPlanFormBase'; + +type Props = { + editPlanId?: number; + initialData: CloudPlanFormData; + onSubmit: (value: CloudPlanFormData) => void; + loading: boolean | undefined; + canEditPrices: boolean; + beforeFields?: ReactNode; + publicSwitchFieldProps?: ComponentProps; + showForOrganizationsMultiselect?: boolean; +}; + +export function CloudPlanForm({ + editPlanId, + initialData, + loading, + canEditPrices, + onSubmit, + beforeFields, + publicSwitchFieldProps, + showForOrganizationsMultiselect, +}: Props) { + return ( + + {beforeFields} + + + + + + {!(showForOrganizationsMultiselect === false) && ( + + )} + + + + + ); +} diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanFormBase.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanFormBase.tsx new file mode 100644 index 0000000000..74ee38cf76 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanFormBase.tsx @@ -0,0 +1,68 @@ +import { Form, Formik } from 'formik'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { Validation } from 'tg.constants/GlobalValidationSchema'; +import { useUrlSearch } from 'tg.hooks/useUrlSearch'; +import React from 'react'; + +type CloudPlanModel = components['schemas']['CloudPlanRequest']; +type EnabledFeature = + components['schemas']['CloudPlanRequest']['enabledFeatures'][number]; + +export type CloudPlanFormData = { + type: CloudPlanModel['type']; + name: string; + prices: CloudPlanModel['prices']; + includedUsage: CloudPlanModel['includedUsage']; + stripeProductId: string; + enabledFeatures: EnabledFeature[]; + forOrganizationIds: number[]; + public: boolean; + free: boolean; + nonCommercial: boolean; +}; + +type Props = { + children: React.ReactNode; + initialData: CloudPlanFormData; + onSubmit: (value: CloudPlanFormData) => void; +}; + +export function CloudPlanFormBase({ initialData, children, onSubmit }: Props) { + const { creatingForOrganizationId: creatingForOrganizationIdString } = + useUrlSearch(); + + const creatingForOrganizationId = creatingForOrganizationIdString + ? parseInt(creatingForOrganizationIdString as string) + : undefined; + + return ( + { + let prices = values.prices; + if (values.type !== 'PAY_AS_YOU_GO') { + prices = { + perSeat: values.prices.perSeat, + subscriptionMonthly: values.prices.subscriptionMonthly, + subscriptionYearly: values.prices.subscriptionYearly, + }; + } + + const forOrganizationIds = creatingForOrganizationId + ? [creatingForOrganizationId] + : values.forOrganizationIds; + + onSubmit({ ...values, prices, forOrganizationIds }); + }} + validationSchema={Validation.CLOUD_PLAN_FORM} + > +
{children}
+
+ ); +} diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/CloudPlanOrganizations.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanOrganizations.tsx similarity index 89% rename from webapp/src/ee/billing/administration/subscriptionPlans/components/CloudPlanOrganizations.tsx rename to webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanOrganizations.tsx index 3034e42676..144fb17053 100644 --- a/webapp/src/ee/billing/administration/subscriptionPlans/components/CloudPlanOrganizations.tsx +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanOrganizations.tsx @@ -11,17 +11,16 @@ import { useTranslate } from '@tolgee/react'; import { useState } from 'react'; import { PaginatedHateoasList } from 'tg.component/common/list/PaginatedHateoasList'; import { useApiQuery, useBillingApiQuery } from 'tg.service/http/useQueryApi'; -import { AssignSwitchCheckbox } from './AssignSwitchCheckbox'; type Props = { - planId?: number; + editPlanId?: number; originalOrganizations: number[]; organizations: number[]; setOrganizations: (orgs: number[]) => void; }; export function CloudPlanOrganizations({ - planId, + editPlanId, originalOrganizations, organizations, setOrganizations, @@ -40,11 +39,11 @@ export function CloudPlanOrganizations({ const planOrganizations = useBillingApiQuery({ url: '/v2/administration/billing/cloud-plans/{planId}/organizations', method: 'get', - path: { planId: planId! }, + path: { planId: editPlanId! }, query: { size: 10, page, search }, options: { keepPreviousData: true, - enabled: planId !== undefined && onlyAllowed, + enabled: editPlanId !== undefined && onlyAllowed, }, }); @@ -69,7 +68,7 @@ export function CloudPlanOrganizations({ {t('administration_cloud_plan_form_organizations_title')} ( {organizations.length}) - {planId && ( + {editPlanId && ( @@ -115,12 +114,7 @@ export function CloudPlanOrganizations({ }} /> } - label={ - - {label} - - - } + label={{label}} /> ); diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanSaveButton.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanSaveButton.tsx new file mode 100644 index 0000000000..ff15d4c539 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CloudPlanSaveButton.tsx @@ -0,0 +1,30 @@ +import React, { FC } from 'react'; +import { Box } from '@mui/material'; +import LoadingButton from 'tg.component/common/form/LoadingButton'; +import { useTranslate } from '@tolgee/react'; + +type CloudPlanSaveButtonProps = { + loading: boolean | undefined; +}; + +export const CloudPlanSaveButton: FC = ({ + loading, +}) => { + const { t } = useTranslate(); + + return ( + <> + + + {t('global_form_save')} + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CreateCloudPlanForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CreateCloudPlanForm.tsx new file mode 100644 index 0000000000..266221513b --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CreateCloudPlanForm.tsx @@ -0,0 +1,95 @@ +import React, { FC } from 'react'; +import { CloudPlanForm } from './CloudPlanForm'; +import { T } from '@tolgee/react'; +import { LINKS } from 'tg.constants/links'; +import { + useApiQuery, + useBillingApiMutation, +} from 'tg.service/http/useQueryApi'; +import { getCloudPlanInitialValues } from './getCloudPlanInitialValues'; +import { useMessage } from 'tg.hooks/useSuccessMessage'; +import { useHistory } from 'react-router-dom'; +import { CloudPlanFormData } from './CloudPlanFormBase'; +import { CreatingPlanForOrganizationAlert } from './CreatingPlanForOrganizationAlert'; +import { PlanTemplateSelector } from './fields/PlanTemplateSelector'; +import { useUrlSearch } from 'tg.hooks/useUrlSearch'; + +export const CreateCloudPlanForm: FC = () => { + const messaging = useMessage(); + const history = useHistory(); + + const createPlanLoadable = useBillingApiMutation({ + url: '/v2/administration/billing/cloud-plans', + method: 'post', + }); + + const initialData = getCloudPlanInitialValues(); + + function onSubmit(values: CloudPlanFormData) { + createPlanLoadable.mutate( + { + content: { + 'application/json': { + ...values, + stripeProductId: values.stripeProductId!, + forOrganizationIds: values.public ? [] : values.forOrganizationIds, + }, + }, + }, + { + onSuccess: onSaveSuccess, + } + ); + } + + function onSaveSuccess() { + messaging.success( + + ); + history.push(LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS.build()); + } + + const { creatingForOrganizationId: creatingForOrganizationIdString } = + useUrlSearch(); + + const creatingForOrganizationId = creatingForOrganizationIdString + ? parseInt(creatingForOrganizationIdString as string) + : undefined; + + const organizationLoadable = useApiQuery({ + url: '/v2/organizations/{id}', + method: 'get', + path: { id: creatingForOrganizationId || 0 }, + options: { + enabled: !!creatingForOrganizationId, + }, + }); + + if (!initialData.name && organizationLoadable.data?.name) { + initialData.name = 'Custom for ' + organizationLoadable.data.name; + } + + return ( + + ), + }} + beforeFields={ + <> + + + + } + /> + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CreatingPlanForOrganizationAlert.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CreatingPlanForOrganizationAlert.tsx new file mode 100644 index 0000000000..86da4f2506 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/CreatingPlanForOrganizationAlert.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react'; +import { useApiQuery } from 'tg.service/http/useQueryApi'; +import { Alert } from '@mui/material'; +import { T } from '@tolgee/react'; +import { components } from 'tg.service/apiSchema.generated'; + +export const CreatingPlanForOrganizationAlert: FC<{ + organization?: components['schemas']['OrganizationModel']; +}> = ({ organization }) => { + if (!organization) { + return null; + } + + return ( + + , + }} + /> + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/EditCloudPlanForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/EditCloudPlanForm.tsx new file mode 100644 index 0000000000..7ee254daf1 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/EditCloudPlanForm.tsx @@ -0,0 +1,122 @@ +import React, { FC } from 'react'; +import { CloudPlanForm } from './CloudPlanForm'; +import { T } from '@tolgee/react'; +import { LINKS } from 'tg.constants/links'; +import { + useApiQuery, + useBillingApiMutation, + useBillingApiQuery, +} from 'tg.service/http/useQueryApi'; +import { getCloudPlanInitialValues } from './getCloudPlanInitialValues'; +import { useMessage } from 'tg.hooks/useSuccessMessage'; +import { useHistory } from 'react-router-dom'; +import { useUrlSearch } from 'tg.hooks/useUrlSearch'; +import { SpinnerProgress } from 'tg.component/SpinnerProgress'; +import { Alert } from '@mui/material'; + +export const EditCloudPlanForm: FC<{ planId: number }> = ({ planId }) => { + const messaging = useMessage(); + const history = useHistory(); + + const { editingForOrganizationId: editingForOrganizationIdString } = + useUrlSearch(); + + const editingForOrganizationId = editingForOrganizationIdString + ? parseInt(editingForOrganizationIdString as string) + : undefined; + + const planLoadable = useBillingApiQuery({ + url: '/v2/administration/billing/cloud-plans/{planId}', + method: 'get', + path: { planId }, + }); + + const planEditLoadable = useBillingApiMutation({ + url: '/v2/administration/billing/cloud-plans/{planId}', + method: 'put', + invalidatePrefix: '/v2/administration/billing/cloud-plans', + }); + + if (planLoadable.isLoading) { + return ; + } + + const planData = planLoadable.data; + + if (!planData) { + return null; + } + + const initialData = getCloudPlanInitialValues(planData); + + const NotExclusiveAlert = () => { + const organizationLoadable = useApiQuery({ + url: '/v2/organizations/{id}', + method: 'get', + path: { id: editingForOrganizationId || 0 }, + options: { + enabled: !!editingForOrganizationId, + }, + }); + + if (!editingForOrganizationId) { + return null; + } + + if (editingForOrganizationId === undefined) { + return null; + } + + const isExclusive = + planData.exclusiveForOrganizationId === editingForOrganizationId; + + if (isExclusive || !organizationLoadable.data) { + return null; + } + + return ( + + , + }} + /> + + ); + }; + + return ( + } + loading={planEditLoadable.isLoading} + canEditPrices={planLoadable.data?.canEditPrices || false} + initialData={initialData} + onSubmit={(values) => { + planEditLoadable.mutate( + { + path: { planId }, + content: { + 'application/json': { + ...values, + forOrganizationIds: values.public + ? [] + : values.forOrganizationIds, + }, + }, + }, + { + onSuccess() { + messaging.success( + + ); + history.push(LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS.build()); + }, + } + ); + }} + /> + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/EePlanForm.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/EePlanForm.tsx similarity index 100% rename from webapp/src/ee/billing/administration/subscriptionPlans/components/EePlanForm.tsx rename to webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/EePlanForm.tsx diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/EePlanOrganizations.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/EePlanOrganizations.tsx similarity index 100% rename from webapp/src/ee/billing/administration/subscriptionPlans/components/EePlanOrganizations.tsx rename to webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/EePlanOrganizations.tsx diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanFields.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanFields.tsx new file mode 100644 index 0000000000..72212c87b4 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanFields.tsx @@ -0,0 +1,189 @@ +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { useTranslate } from '@tolgee/react'; +import { + Box, + Checkbox, + FormControlLabel, + MenuItem, + Switch, + Typography, +} from '@mui/material'; +import { Select } from 'tg.component/common/form/fields/Select'; +import { Field, FieldProps, useFormikContext } from 'formik'; +import { SearchSelect } from 'tg.component/searchSelect/SearchSelect'; +import { useBillingApiQuery } from 'tg.service/http/useQueryApi'; +import React, { FC, useEffect } from 'react'; +import { CloudPlanPricesAndLimits } from './CloudPlanPricesAndLimits'; +import { CloudPlanFormData } from '../CloudPlanFormBase'; +import { PlanNonCommercialSwitch } from './PlanNonCommercialSwitch'; + +export const CloudPlanFields: FC<{ + parentName?: string; + isUpdate: boolean; + canEditPrices: boolean; +}> = ({ parentName, isUpdate, canEditPrices }) => { + const { t } = useTranslate(); + + const featuresLoadable = useBillingApiQuery({ + url: '/v2/administration/billing/features', + method: 'get', + }); + + const productsLoadable = useBillingApiQuery({ + url: '/v2/administration/billing/stripe-products', + method: 'get', + }); + + const products = productsLoadable.data?._embedded?.stripeProducts; + + const { setFieldValue, values: formValues } = useFormikContext(); + + const values: CloudPlanFormData = parentName + ? formValues[parentName] + : formValues; + + parentName = parentName ? parentName + '.' : ''; + + const typeOptions = [ + { value: 'PAY_AS_YOU_GO', label: 'Pay as you go', enabled: !values.free }, + { value: 'FIXED', label: 'Fixed', enabled: true }, + { value: 'SLOTS_FIXED', label: 'Slots fixed', enabled: true }, + ]; + + const enabledTypeOptions = typeOptions.filter((t) => t.enabled); + + function onFreeChange() { + setFieldValue(`${parentName}free`, !values.free); + } + + useEffect(() => { + if (!enabledTypeOptions.find((o) => o.value === values.type)) { + setFieldValue(`${parentName}type`, enabledTypeOptions[0].value); + } + }, [values.free]); + + return ( + <> + + onFreeChange()} /> + } + data-cy="administration-cloud-plan-field-free" + label={t('administration_cloud_plan_field_free')} + /> + + + + {({ field, form, meta }: FieldProps) => { + return ( + + label.toLowerCase().includes(prompt.toLowerCase()) + } + SelectProps={{ + // @ts-ignore + 'data-cy': 'administration-cloud-plan-field-stripe-product', + label: t('administration_cloud_plan_field_stripe_product'), + size: 'small', + fullWidth: true, + variant: 'outlined', + error: (meta.touched && meta.error) || '', + }} + value={field.value} + onChange={(val) => form.setFieldValue(field.name, val)} + items={[ + { value: '', name: 'None' }, + ...(products?.map(({ id, name }) => ({ + value: id, + name: `${id} ${name}`, + })) || []), + ]} + /> + ); + }} + + + + + + + + {t('administration_cloud_plan_form_features_title')} + + + {(props: FieldProps) => + featuresLoadable.data?.map((feature) => { + const values = props.field.value; + + const toggleField = () => { + let newValues = values; + if (values.includes(feature)) { + newValues = values.filter((val) => val !== feature); + } else { + newValues = [...values, feature]; + } + props.form.setFieldValue(props.field.name, newValues); + }; + + return ( + + } + label={feature} + /> + ); + }) || [] + } + + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanPricesAndLimits.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanPricesAndLimits.tsx new file mode 100644 index 0000000000..de552e91dc --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/CloudPlanPricesAndLimits.tsx @@ -0,0 +1,114 @@ +import { FC } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { Box, Tooltip, Typography } from '@mui/material'; +import { TextField } from 'tg.component/common/form/fields/TextField'; +import { CloudPlanFormData } from '../CloudPlanFormBase'; + +export const CloudPlanPricesAndLimits: FC<{ + parentName?: string; + values: CloudPlanFormData; + canEditPrices: boolean; +}> = ({ values, parentName, canEditPrices }) => { + const { t } = useTranslate(); + + const Wrapper = ({ children }) => { + if (!canEditPrices) { + return ( + + + {children} + + + ); + } + + return <>{children}; + }; + + return ( + + {!values.free && ( + <> + + {t('administration_cloud_plan_form_prices_title')} + + + + + + + + + )} + + {t('administration_cloud_plan_form_limits_title')} + + + + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanNonCommercialSwitch.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanNonCommercialSwitch.tsx new file mode 100644 index 0000000000..eefc4a54b9 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanNonCommercialSwitch.tsx @@ -0,0 +1,28 @@ +import React, { FC } from 'react'; +import { FormControlLabel, Switch } from '@mui/material'; +import { useFormikContext } from 'formik'; +import { useTranslate } from '@tolgee/react'; +import { CloudPlanFormData } from '../CloudPlanFormBase'; + +export const PlanNonCommercialSwitch: FC = () => { + const { setFieldValue, values } = useFormikContext(); + + const { t } = useTranslate(); + + return ( + <> + + setFieldValue('nonCommercial', !values.nonCommercial) + } + /> + } + data-cy="administration-cloud-plan-field-non-commercial" + label={t('administration_cloud_plan_field_non_commercial')} + /> + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanOrganizationsMultiselectField.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanOrganizationsMultiselectField.tsx new file mode 100644 index 0000000000..46aee51825 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanOrganizationsMultiselectField.tsx @@ -0,0 +1,32 @@ +import React, { FC } from 'react'; +import { Box, Typography } from '@mui/material'; +import { CloudPlanOrganizations } from '../CloudPlanOrganizations'; +import { useFormikContext } from 'formik'; +import { CloudPlanFormData } from '../CloudPlanFormBase'; + +export const PlanOrganizationsMultiselectField: FC<{ editPlanId?: number }> = ({ + editPlanId, +}) => { + const { values, setFieldValue, initialValues, errors } = + useFormikContext(); + + return ( + <> + {!values.public && ( + + { + setFieldValue('forOrganizationIds', orgs); + }} + /> + {errors.forOrganizationIds && ( + {errors.forOrganizationIds} + )} + + )} + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanPublicSwitchField.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanPublicSwitchField.tsx new file mode 100644 index 0000000000..863693db86 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanPublicSwitchField.tsx @@ -0,0 +1,45 @@ +import { Alert, FormControlLabel, Switch, Tooltip } from '@mui/material'; +import { T, useTranslate } from '@tolgee/react'; +import React, { FC, ReactElement, ReactNode } from 'react'; +import { useFormikContext } from 'formik'; +import { CloudPlanFormData } from '../CloudPlanFormBase'; + +export const PlanPublicSwitchField: FC<{ + disabled?: boolean; + disabledInfo?: ReactNode; +}> = ({ disabled, disabledInfo }) => { + const { t } = useTranslate(); + + const { setFieldValue, values } = useFormikContext(); + + function Wrapper({ children }: { children: ReactElement }) { + if (!disabled || !disabledInfo) { + return children; + } + + return {children}; + } + + return ( + + + setFieldValue('public', !values.public)} + /> + } + data-cy="administration-cloud-plan-field-public" + label={t('administration_cloud_plan_field_public')} + /> + {values.public && ( + + + + )} + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanSelectorField.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanSelectorField.tsx new file mode 100644 index 0000000000..2e8a336ada --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanSelectorField.tsx @@ -0,0 +1,93 @@ +import { useBillingApiQuery } from 'tg.service/http/useQueryApi'; +import { useField } from 'formik'; +import { useFieldError } from 'tg.component/common/form/fields/useFieldError'; +import { + SearchSelect, + SelectItem, +} from 'tg.component/searchSelect/SearchSelect'; +import React, { FC } from 'react'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { useTranslate } from '@tolgee/react'; + +type Props = { + organizationId?: number; + onPlanChange?: ( + plan: components['schemas']['AdministrationCloudPlanModel'] + ) => void; +}; + +export const PlanSelectorField = ({ organizationId, onPlanChange }: Props) => { + const fieldName = 'planId'; + const [field, _, helpers] = useField(fieldName); + const { errorTextWhenTouched } = useFieldError({ fieldName }); + + const { t } = useTranslate(); + + function onChange(planId) { + helpers.setValue(planId); + } + + return ( + + ); +}; + +export const PlanSelector: FC< + Props & { + value?: number; + onChange?: (value: number) => void; + selectProps?: React.ComponentProps[`SelectProps`]; + } +> = ({ organizationId, onChange, value, selectProps, onPlanChange }) => { + const plansLoadable = useBillingApiQuery({ + url: '/v2/administration/billing/cloud-plans', + method: 'get', + query: { + filterAssignableToOrganization: organizationId, + }, + }); + + if (plansLoadable.isLoading) { + return null; + } + + const plans = plansLoadable?.data?._embedded?.plans ?? []; + + const selectItems = plans.map( + (plan) => + ({ + value: plan.id, + name: plan.name, + } satisfies SelectItem) + ); + + function handleChange(planId: number) { + if (plansLoadable.data?._embedded?.plans) { + const plan = plansLoadable.data._embedded.plans.find( + (plan) => plan.id === planId + ); + if (plan) { + onChange?.(planId); + onPlanChange?.(plan); + } + } + } + + return ( + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanTemplateSelector.tsx b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanTemplateSelector.tsx new file mode 100644 index 0000000000..e1548a5aa0 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/fields/PlanTemplateSelector.tsx @@ -0,0 +1,41 @@ +import React, { FC, useState } from 'react'; +import { useTranslate } from '@tolgee/react'; +import { useFormikContext } from 'formik'; +import { PlanSelector } from './PlanSelectorField'; +import { getCloudPlanInitialValues } from '../getCloudPlanInitialValues'; +import { Divider, FormHelperText } from '@mui/material'; +import { CloudPlanFormData } from '../CloudPlanFormBase'; + +export const PlanTemplateSelector: FC = () => { + const { t } = useTranslate(); + + const { setValues, values } = useFormikContext(); + + const [selectedPlanId, setSelectedPlanId] = useState( + undefined + ); + + return ( + <> + { + const newValues = { + ...getCloudPlanInitialValues(plan), + name: values.name, + public: false, + }; + setValues(newValues); + setSelectedPlanId(plan.id); + }} + /> + + {t('admin_billing_plan_template_selector_helper_text')} + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/getCloudPlanInitialValues.ts b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/getCloudPlanInitialValues.ts new file mode 100644 index 0000000000..e9bd655bd6 --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptionPlans/components/planForm/getCloudPlanInitialValues.ts @@ -0,0 +1,50 @@ +import { components } from 'tg.service/billingApiSchema.generated'; +import { CloudPlanFormData } from './CloudPlanFormBase'; + +export const getCloudPlanInitialValues = ( + planData?: components['schemas']['AdministrationCloudPlanModel'] +) => { + if (planData) { + return { + ...planData, + prices: { + perThousandMtCredits: planData.prices.perThousandMtCredits ?? 0, + perThousandTranslations: planData.prices.perThousandTranslations ?? 0, + perSeat: planData.prices.perSeat ?? 0, + subscriptionMonthly: planData.prices.subscriptionMonthly ?? 0, + subscriptionYearly: planData.prices.subscriptionYearly ?? 0, + }, + includedUsage: { + seats: planData.includedUsage.seats, + mtCredits: planData.includedUsage.mtCredits, + translations: + planData.type === 'SLOTS_FIXED' + ? planData.includedUsage.translationSlots + : planData.includedUsage.translations, + }, + }; + } + + return { + type: 'PAY_AS_YOU_GO', + name: '', + stripeProductId: '', + prices: { + perSeat: 0, + perThousandMtCredits: 0, + perThousandTranslations: 0, + subscriptionMonthly: 0, + subscriptionYearly: 0, + }, + includedUsage: { + seats: 0, + translations: 0, + mtCredits: 0, + }, + enabledFeatures: [], + public: false, + forOrganizationIds: [], + free: false, + nonCommercial: false, + } as CloudPlanFormData; +}; diff --git a/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsCloudSubscriptionPopover.tsx b/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsCloudSubscriptionPopover.tsx deleted file mode 100644 index f0a6fd994d..0000000000 --- a/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsCloudSubscriptionPopover.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, { FC } from 'react'; -import { useDateFormatter } from 'tg.hooks/useLocale'; -import { Box, Button, Typography } from '@mui/material'; -import { PlanPublicChip } from '../../component/Plan/PlanPublicChip'; -import { T } from '@tolgee/react'; -import { components } from 'tg.service/billingApiSchema.generated'; - -type Props = { - item: components['schemas']['OrganizationWithSubscriptionsModel']; - onOpenAssignTrialDialog: () => void; -}; - -export const AdministrationSubscriptionsCloudSubscriptionPopover: FC = ({ - item, - onOpenAssignTrialDialog, -}) => { - const formatDate = useDateFormatter(); - - return ( - - - - {item.cloudSubscription?.plan.name} - - - - - - - {item.cloudSubscription?.currentBillingPeriod} - - - {item.cloudSubscription?.currentPeriodEnd && - formatDate(item.cloudSubscription?.currentPeriodEnd)} - - - - - ); -}; diff --git a/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptions.tsx b/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsView.tsx similarity index 73% rename from webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptions.tsx rename to webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsView.tsx index 54a12b9b3e..c280766bdb 100644 --- a/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptions.tsx +++ b/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsView.tsx @@ -19,7 +19,8 @@ import { LINKS, PARAMS } from 'tg.constants/links'; import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView'; import { useUrlSearchState } from 'tg.hooks/useUrlSearchState'; import Toolbar from '@mui/material/Toolbar'; -import { AdministrationSubscriptionsCloudPlan } from './AdministrationSubscriptionsCloudPlan'; +import { AdministrationSubscriptionsCloudPlan } from './components/AdministrationSubscriptionsCloudPlan'; +import { AdministrationSubscriptionsListItem } from './components/AdministrationSubscriptionsListItem'; const StyledWrapper = styled('div')` display: flex; @@ -31,7 +32,7 @@ const StyledWrapper = styled('div')` } `; -export const AdministrationSubscriptions = () => { +export const AdministrationSubscriptionsView = () => { const [page, setPage] = useState(0); const [search, setSearch] = useUrlSearchState('search'); @@ -72,20 +73,7 @@ export const AdministrationSubscriptions = () => { searchText={search} loadable={listPermitted} renderItem={(item) => ( - - - - {item.organization.name} - {' '} - - - - - + )} />
diff --git a/webapp/src/ee/billing/administration/subscriptions/CloudPlanSelector.tsx b/webapp/src/ee/billing/administration/subscriptions/CloudPlanSelector.tsx deleted file mode 100644 index 2f863a6698..0000000000 --- a/webapp/src/ee/billing/administration/subscriptions/CloudPlanSelector.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useBillingApiQuery } from 'tg.service/http/useQueryApi'; -import { useField } from 'formik'; -import { useFieldError } from 'tg.component/common/form/fields/useFieldError'; -import { - SearchSelect, - SelectItem, -} from 'tg.component/searchSelect/SearchSelect'; -import React from 'react'; - -export const PlanSelectorField = ({ - organizationId, -}: { - organizationId: number; -}) => { - const plansLoadable = useBillingApiQuery({ - url: '/v2/administration/billing/cloud-plans', - method: 'get', - query: { - filterAssignableToOrganization: organizationId, - }, - }); - - const fieldName = 'planId'; - const [field, _, helpers] = useField(fieldName); - const { errorTextWhenTouched } = useFieldError({ fieldName }); - - if (plansLoadable.isLoading) { - return null; - } - - function onChange(val) { - helpers.setValue(val); - } - - const plans = plansLoadable?.data?._embedded?.plans ?? []; - const selectItems = plans.map( - (plan) => - ({ - value: plan.id, - name: plan.name, - } satisfies SelectItem) - ); - - return ( - - ); -}; diff --git a/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsCloudPlan.tsx b/webapp/src/ee/billing/administration/subscriptions/components/AdministrationSubscriptionsCloudPlan.tsx similarity index 68% rename from webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsCloudPlan.tsx rename to webapp/src/ee/billing/administration/subscriptions/components/AdministrationSubscriptionsCloudPlan.tsx index 3675fc5568..acb7dd2615 100644 --- a/webapp/src/ee/billing/administration/subscriptions/AdministrationSubscriptionsCloudPlan.tsx +++ b/webapp/src/ee/billing/administration/subscriptions/components/AdministrationSubscriptionsCloudPlan.tsx @@ -1,7 +1,7 @@ import { Box, styled, Tooltip } from '@mui/material'; import React, { FC, useState } from 'react'; import { components } from 'tg.service/billingApiSchema.generated'; -import { AdministrationSubscriptionsCloudSubscriptionPopover } from './AdministrationSubscriptionsCloudSubscriptionPopover'; +import { SubscriptionCloudPlanPopover } from './SubscriptionCloudPlanPopover'; import { AssignCloudTrialDialog } from './AssignCloudTrialDialog'; type Props = { @@ -17,21 +17,17 @@ export const AdministrationSubscriptionsCloudPlan: FC = ({ item }) => { const [dialogOpen, setDialogOpen] = useState(false); return ( <> - setDialogOpen(true)} - /> - } + setDialogOpen(true)} > {item.cloudSubscription?.plan.name} - + setDialogOpen(false)} - > + /> ); }; diff --git a/webapp/src/ee/billing/administration/subscriptions/components/AdministrationSubscriptionsListItem.tsx b/webapp/src/ee/billing/administration/subscriptions/components/AdministrationSubscriptionsListItem.tsx new file mode 100644 index 0000000000..d3ff1420ba --- /dev/null +++ b/webapp/src/ee/billing/administration/subscriptions/components/AdministrationSubscriptionsListItem.tsx @@ -0,0 +1,32 @@ +import { + Chip, + Link, + ListItem, + ListItemSecondaryAction, + ListItemText, +} from '@mui/material'; +import { LINKS, PARAMS } from 'tg.constants/links'; +import { AdministrationSubscriptionsCloudPlan } from './AdministrationSubscriptionsCloudPlan'; +import React, { FC } from 'react'; +import { components } from 'tg.service/billingApiSchema.generated'; + +export const AdministrationSubscriptionsListItem: FC<{ + item: components['schemas']['OrganizationWithSubscriptionsModel']; +}> = ({ item }) => { + return ( + + + + {item.organization.name} + {' '} + + + + + + ); +}; diff --git a/webapp/src/ee/billing/administration/subscriptions/AssignCloudTrialDialog.tsx b/webapp/src/ee/billing/administration/subscriptions/components/AssignCloudTrialDialog.tsx similarity index 56% rename from webapp/src/ee/billing/administration/subscriptions/AssignCloudTrialDialog.tsx rename to webapp/src/ee/billing/administration/subscriptions/components/AssignCloudTrialDialog.tsx index 8c93d9536b..888e8e5cbd 100644 --- a/webapp/src/ee/billing/administration/subscriptions/AssignCloudTrialDialog.tsx +++ b/webapp/src/ee/billing/administration/subscriptions/components/AssignCloudTrialDialog.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react'; import { T, useTranslate } from '@tolgee/react'; -import { Form, Formik } from 'formik'; +import { Form, Formik, FormikProps } from 'formik'; import * as Yup from 'yup'; import { Button, @@ -8,13 +8,21 @@ import { DialogActions, DialogContent, DialogTitle, + FormControlLabel, + FormHelperText, + Switch, } from '@mui/material'; import { DateTimePickerField } from 'tg.component/common/form/fields/DateTimePickerField'; import LoadingButton from 'tg.component/common/form/LoadingButton'; -import { PlanSelectorField } from './CloudPlanSelector'; import { useBillingApiMutation } from 'tg.service/http/useQueryApi'; import { useMessage } from 'tg.hooks/useSuccessMessage'; +import { CloudPlanFields } from '../../subscriptionPlans/components/planForm/fields/CloudPlanFields'; +import { getCloudPlanInitialValues } from '../../subscriptionPlans/components/planForm/getCloudPlanInitialValues'; +import { components } from 'tg.service/billingApiSchema.generated'; +import { Validation } from 'tg.constants/GlobalValidationSchema'; +import { PlanSelectorField } from '../../subscriptionPlans/components/planForm/fields/PlanSelectorField'; +import { CloudPlanFormData } from '../../subscriptionPlans/components/planForm/CloudPlanFormBase'; export const AssignCloudTrialDialog: FC<{ open: boolean; @@ -31,14 +39,19 @@ export const AssignCloudTrialDialog: FC<{ const messaging = useMessage(); + const [customize, setCustomize] = React.useState(false); + function handleSave(value: ValuesType) { assignMutation.mutate( { path: { organizationId }, content: { 'application/json': { - planId: value.planId!, + planId: customize ? undefined : value.planId!, trialEnd: value.trialEnd.getTime(), + customPlan: customize + ? { ...value.customPlan, public: false } + : undefined, }, }, }, @@ -55,12 +68,25 @@ export const AssignCloudTrialDialog: FC<{ const currentDaPlus2weeks = new Date(Date.now() + 1000 * 60 * 60 * 24 * 14); + const cloudPlanInitialData = getCloudPlanInitialValues(); + + function setCustomPlanValues( + formikProps: FormikProps, + planData: components['schemas']['AdministrationCloudPlanModel'] + ) { + formikProps.setFieldValue('customPlan', { + ...getCloudPlanInitialValues(planData), + name: planData.name + ' (trial)', + }); + } + return ( {(formikProps) => (
- + @@ -88,7 +115,35 @@ export const AssignCloudTrialDialog: FC<{ }} name="trialEnd" /> - + + + + setCustomPlanValues(formikProps, plan)} + /> + setCustomize(!customize)} + /> + } + data-cy="administration-customize-plan-switch" + label={t('administration_customize_plan_switch')} + /> + {customize && ( + <> + + + + + + )} + )} + + + {item.cloudSubscription?.currentPeriodEnd && !isTrial && ( + <> + + {item.cloudSubscription?.currentBillingPeriod} + + + {formatDate(item.cloudSubscription?.currentPeriodEnd)} + + + )} + + +