Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import org.springframework.stereotype.Component
import org.springframework.transaction.PlatformTransactionManager
import java.sql.Timestamp
import java.time.Duration
import java.time.LocalDate
import java.time.ZoneId.systemDefault
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAccessor
Expand Down Expand Up @@ -84,6 +86,11 @@ class CurrentDateProvider(
return forcedDate ?: Date()
}

val localDate: LocalDate
get() {
return (forcedDate ?: date).toInstant().atZone(systemDefault()).toLocalDate()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not simply date instead of (forcedDate ?: date)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true, my bad

}

override fun getNow(): Optional<TemporalAccessor> {
return Optional.of(date.toInstant())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.tolgee.component
import io.tolgee.util.Logging
import io.tolgee.util.logger
import jakarta.annotation.PreDestroy
import org.springframework.scheduling.support.CronTrigger
import org.springframework.stereotype.Component
import java.time.Duration
import java.util.*
Expand Down Expand Up @@ -46,6 +47,19 @@ class SchedulingManager(
return id
}

fun scheduleWithCron(
runnable: Runnable,
cron: String,
): String {
val future = taskScheduler.schedule(runnable, CronTrigger(cron))
if (future == null) {
throw IllegalStateException("Future from scheduler was null")
}
val id = UUID.randomUUID().toString()
scheduledTasks[id] = future
return id
}

@PreDestroy
fun cancelAll() {
Companion.cancelAll()
Expand Down
3 changes: 2 additions & 1 deletion backend/data/src/main/kotlin/io/tolgee/constants/Message.kt
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,9 @@ enum class Message {
SUGGESTION_CANT_BE_PLURAL,
SUGGESTION_MUST_BE_PLURAL,
DUPLICATE_SUGGESTION,

UNSUPPORTED_MEDIA_TYPE,
PLAN_MIGRATION_NOT_FOUND,
PLAN_HAS_MIGRATIONS,
;

val code: String
Expand Down
13 changes: 13 additions & 0 deletions backend/data/src/main/resources/I18n_en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,16 @@ notifications.email.security-settings-link=Check your security settings <a href=
notifications.email.mfa.MFA_ENABLED=Multi-factor authentication has been enabled for your account.
notifications.email.mfa.MFA_DISABLED=Multi-factor authentication has been disabled for your account.
notifications.email.password-changed=Password has been changed for your account.

notifications.email.plan-migration-subject=Your 🐁 plan will be updated on {0}
notifications.email.plan-migration-body=Hello! 👋<br/><br/>\
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, the encoding is somehow broken. I believe utf-8 is now supported for properties file. Can we fix it? In the meantime, I am asking marketa to provide proper e-mail.

We’d like to let you know that starting {0}, your current plan will be automatically updated.<br/>\
Current plan: {1} (€{2}/mo)<br/>\
New plan: {3} (€{4}/mo)<br/>\
<br/>\
If you’d prefer to switch to a different plan, you can easily do so in <a href="{5}">Subscriptions</a>\
<br/><br/>\
If you’re not happy with this change, please email us at <a href="mailto:{6}">{6}</a> and we’ll try to find a solution together.\
<br/><br/>\
Regards,<br/>\
Tolgee
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ class EmailTestUtil() {
verify(javaMailSender).send(any<MimeMessage>())
}

fun verifyTimesEmailSent(num: Int) {
verify(javaMailSender, times(num)).send(any<MimeMessage>())
}

val assertEmailTo: AbstractStringAssert<*>
get() {
@Suppress("CAST_NEVER_SUCCEEDS")
Expand Down
7 changes: 7 additions & 0 deletions e2e/cypress/support/dataCyType.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ declare namespace DataCy {
"administration-plan-field-stripe-product" |
"administration-plan-field-stripe-product-name" |
"administration-plan-selector" |
"administration-plans-create-migration" |
"administration-plans-edit-migration" |
"administration-plans-item-is-migrating-badge" |
"administration-subscriptions-active-self-hosted-ee-plan" |
"administration-subscriptions-assign-plan-save-button" |
"administration-subscriptions-cloud-plan-name" |
Expand Down Expand Up @@ -263,6 +266,7 @@ declare namespace DataCy {
"create-task-submit" |
"dashboard-projects-list-item" |
"default-namespace-select" |
"delete-plan-migration-button" |
"delete-user-button" |
"developer-menu-content-delivery" |
"developer-menu-storage" |
Expand Down Expand Up @@ -558,6 +562,7 @@ declare namespace DataCy {
"permissions-menu-save" |
"plan-limit-dialog-close" |
"plan-limit-exceeded-popover" |
"plan-migration-tooltip-detail" |
"plan_seat_limit_exceeded_while_accepting_invitation_message" |
"project-ai-prompt-dialog-description-input" |
"project-ai-prompt-dialog-save" |
Expand Down Expand Up @@ -677,6 +682,7 @@ declare namespace DataCy {
"signup-error-free-seat-limit" |
"signup-error-plan-seat-limit" |
"signup-error-seats-spending-limit" |
"source-plan-selector" |
"spending-limit-dialog-close" |
"spending-limit-exceeded-popover" |
"sso-migration-info-text" |
Expand Down Expand Up @@ -708,6 +714,7 @@ declare namespace DataCy {
"suggestions-list" |
"tag-autocomplete-input" |
"tag-autocomplete-option" |
"target-plan-selector" |
"task-date-picker" |
"task-detail" |
"task-detail-author" |
Expand Down
11 changes: 11 additions & 0 deletions webapp/src/component/common/FullWidthTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { styled, Tooltip, tooltipClasses, TooltipProps } from '@mui/material';

export const FullWidthTooltip = styled(
({ className, ...props }: TooltipProps) => (
<Tooltip {...props} classes={{ popper: className }} />
)
)({
[`& .${tooltipClasses.tooltip}`]: {
maxWidth: 'none',
},
});
28 changes: 22 additions & 6 deletions webapp/src/component/common/table/PaginatedHateoasTable.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { FC, JSXElementConstructor } from 'react';
import React, { FC, JSXElementConstructor, ReactNode } from 'react';
import {
HateoasListData,
HateoasPaginatedData,
Expand All @@ -8,7 +8,7 @@ import {
PaginatedHateoasList,
PaginatedHateoasListProps,
} from '../list/PaginatedHateoasList';
import { Table, TableBody } from '@mui/material';
import { Table, TableBody, TableHead } from '@mui/material';

export type PaginatedHateoasTableProps<
WrapperComponent extends
Expand All @@ -19,7 +19,9 @@ export type PaginatedHateoasTableProps<
> = Omit<
PaginatedHateoasListProps<WrapperComponent, typeof Table, TData, TItem>,
'listComponent'
>;
> & {
tableHead?: ReactNode;
};

export const PaginatedHateoasTable = <
WrapperComponent extends
Expand All @@ -30,17 +32,31 @@ export const PaginatedHateoasTable = <
>(
props: PaginatedHateoasTableProps<WrapperComponent, TData, TItem>
) => {
const { tableHead, ...rest } = props;
return (
<PaginatedHateoasList
listComponent={PaginatedHateoasTableListComponent}
{...props}
listComponent={(listProps) => (
<PaginatedHateoasTableListComponent
tableHead={tableHead}
{...listProps}
/>
)}
{...rest}
/>
);
};

const PaginatedHateoasTableListComponent: FC = ({ children }) => {
interface PaginatedHateoasTableListComponentProps {
children: ReactNode;
tableHead?: ReactNode;
}

const PaginatedHateoasTableListComponent: FC<
PaginatedHateoasTableListComponentProps
> = ({ children, tableHead }) => {
return (
<Table>
{tableHead && <TableHead>{tableHead}</TableHead>}
<TableBody>{children}</TableBody>
</Table>
);
Expand Down
7 changes: 7 additions & 0 deletions webapp/src/component/layout/HeaderBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type HeaderBarProps = {
switcher?: ReactNode;
maxWidth?: BaseViewWidth;
initialSearch?: string;
customButtons?: ReactNode[];
};

export const HeaderBar: React.VFC<HeaderBarProps> = (props) => {
Expand Down Expand Up @@ -81,6 +82,12 @@ export const HeaderBar: React.VFC<HeaderBarProps> = (props) => {
{props.switcher}
</Box>
)}
{props.customButtons &&
props.customButtons.map((button, index) => (
<Box key={index} display="flex" alignItems="center">
{button}
</Box>
))}
{props.addComponent
? props.addComponent
: (props.onAdd || props.addLinkTo) && (
Expand Down
14 changes: 14 additions & 0 deletions webapp/src/constants/GlobalValidationSchema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ Yup.setLocale({
/>
),
},
number: {
min: ({ min }) => (
<T
keyName="validation_schema_number_min_message"
params={{ min: min.toString() }}
/>
),
},
});

export class Validation {
Expand Down Expand Up @@ -521,6 +529,12 @@ export class Validation {
.required()
.matches(/^#[0-9A-F]{6}$/i, t('validation_invalid_hex_color')),
});

static readonly PLAN_MIRATION_FORM = () =>
Yup.object().shape({
monthlyOffsetDays: Yup.number().required().min(0),
yearlyOffsetDays: Yup.number().required().min(0),
});
Comment on lines +533 to +537
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Fix typo in constant name: PLAN_MIRATION_FORM → PLAN_MIGRATION_FORM.

The constant is misspelled as PLAN_MIRATION_FORM (missing a "G" in "MIGRATION"). This typo will propagate to all consumers of this validation schema.

Apply this diff to fix the typo:

-  static readonly PLAN_MIRATION_FORM = () =>
+  static readonly PLAN_MIGRATION_FORM = () =>
     Yup.object().shape({
       monthlyOffsetDays: Yup.number().required().min(0),
       yearlyOffsetDays: Yup.number().required().min(0),
     });

Then update all references across the codebase:


🏁 Script executed:

#!/bin/bash
# Description: Find all references to the misspelled constant name

rg -n "PLAN_MIRATION_FORM" --type ts --type tsx

Length of output: 27


🏁 Script executed:

#!/bin/bash
# Search for references to the misspelled constant in all .ts and .tsx files
rg -n "PLAN_MIRATION_FORM" -g '*.ts*'

Length of output: 252


Fix typo in constant name: PLAN_MIRATION_FORM → PLAN_MIGRATION_FORM
Rename the constant in webapp/src/constants/GlobalValidationSchema.tsx (line 533) and update its usage in webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx (line 83).

--- a/webapp/src/constants/GlobalValidationSchema.tsx
@@ -533,7 +533,7 @@
-  static readonly PLAN_MIRATION_FORM = () =>
+  static readonly PLAN_MIGRATION_FORM = () =>
--- a/webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx
@@ -83,7 +83,7 @@
-      validationSchema={Validation.PLAN_MIRATION_FORM}
+      validationSchema={Validation.PLAN_MIGRATION_FORM}

Then verify no other references remain:

rg -n "PLAN_MIRATION_FORM" -g '*.ts*'
🤖 Prompt for AI Agents
In webapp/src/constants/GlobalValidationSchema.tsx around lines 533–537 the
constant name PLAN_MIRATION_FORM is misspelled; rename it to PLAN_MIGRATION_FORM
and export it under the corrected name, then update its usage in
webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx
(around line 83) to import/reference PLAN_MIGRATION_FORM instead of
PLAN_MIRATION_FORM; after changes run a project-wide search (e.g., rg -n
"PLAN_MIRATION_FORM" -g '*.ts*') and fix any remaining references to the
misspelled identifier.

}

let GLOBAL_VALIDATION_DEBOUNCE_TIMER: any = undefined;
Expand Down
21 changes: 21 additions & 0 deletions webapp/src/constants/links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export enum PARAMS {
TRANSLATION_ID = 'translationId',
PLAN_ID = 'planId',
TA_ID = 'taId',
PLAN_MIGRATION_ID = 'migrationId',
}

export class LINKS {
Expand Down Expand Up @@ -246,6 +247,26 @@ export class LINKS {
'create'
);

static ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_CREATE = Link.ofParent(
LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS,
'create-migration'
);

static ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_EDIT = Link.ofParent(
LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS,
'migration/' + p(PARAMS.PLAN_MIGRATION_ID)
);

static ADMINISTRATION_BILLING_EE_PLAN_MIGRATION_CREATE = Link.ofParent(
LINKS.ADMINISTRATION_BILLING_EE_PLANS,
'create-migration'
);

static ADMINISTRATION_BILLING_EE_PLAN_MIGRATION_EDIT = Link.ofParent(
LINKS.ADMINISTRATION_BILLING_EE_PLANS,
'migration/' + p(PARAMS.PLAN_MIGRATION_ID)
);

/**
* Organizations
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
CreatePlanMigrationFormData,
PlanMigrationForm,
} from './PlanMigrationForm';
import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types';

const emptyDefaultValues: CreatePlanMigrationFormData = {
enabled: true,
sourcePlanId: 0,
targetPlanId: 0,
monthlyOffsetDays: 14,
yearlyOffsetDays: 30,
};

type Props = {
onSubmit: (values: CreatePlanMigrationFormData) => void;
loading?: boolean;
planType?: PlanType;
};

export const CreatePlanMigrationForm: React.FC<Props> = (props) => {
return (
<PlanMigrationForm<CreatePlanMigrationFormData>
defaultValues={emptyDefaultValues}
{...props}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { PlanMigrationForm, PlanMigrationFormData } from './PlanMigrationForm';
import { components } from 'tg.service/billingApiSchema.generated';
import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types';

type CloudPlanMigrationModel = components['schemas']['CloudPlanMigrationModel'];
type SelfHostedEePlanMigrationModel =
components['schemas']['AdministrationSelfHostedEePlanMigrationModel'];

type Props = {
onSubmit: (values: PlanMigrationFormData) => void;
loading?: boolean;
onDelete?: (id: number) => void;
migration: CloudPlanMigrationModel | SelfHostedEePlanMigrationModel;
planType?: PlanType;
};

export const EditPlanMigrationForm: React.FC<Props> = (props) => {
const { migration } = props;
const initialValues: PlanMigrationFormData = {
enabled: migration.enabled,
sourcePlanFree: migration.sourcePlan.free,
targetPlanId: migration.targetPlan.id,
monthlyOffsetDays: migration.monthlyOffsetDays,
yearlyOffsetDays: migration.yearlyOffsetDays,
};
return (
<PlanMigrationForm<PlanMigrationFormData>
defaultValues={initialValues}
{...props}
/>
);
};
Loading