remove singleton usage of i18n (#14422)
closes https://github.com/twentyhq/core-team-issues/issues/1480 --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
@@ -1,42 +1,11 @@
|
||||
import { i18n, type Messages } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { Container, Html } from '@react-email/components';
|
||||
|
||||
import { BaseHead } from 'src/components/BaseHead';
|
||||
import { Footer } from 'src/components/Footer';
|
||||
import { Logo } from 'src/components/Logo';
|
||||
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { messages as afMessages } from '../locales/generated/af-ZA';
|
||||
import { messages as arMessages } from '../locales/generated/ar-SA';
|
||||
import { messages as caMessages } from '../locales/generated/ca-ES';
|
||||
import { messages as csMessages } from '../locales/generated/cs-CZ';
|
||||
import { messages as daMessages } from '../locales/generated/da-DK';
|
||||
import { messages as deMessages } from '../locales/generated/de-DE';
|
||||
import { messages as elMessages } from '../locales/generated/el-GR';
|
||||
import { messages as enMessages } from '../locales/generated/en';
|
||||
import { messages as esMessages } from '../locales/generated/es-ES';
|
||||
import { messages as fiMessages } from '../locales/generated/fi-FI';
|
||||
import { messages as frMessages } from '../locales/generated/fr-FR';
|
||||
import { messages as heMessages } from '../locales/generated/he-IL';
|
||||
import { messages as huMessages } from '../locales/generated/hu-HU';
|
||||
import { messages as itMessages } from '../locales/generated/it-IT';
|
||||
import { messages as jaMessages } from '../locales/generated/ja-JP';
|
||||
import { messages as koMessages } from '../locales/generated/ko-KR';
|
||||
import { messages as nlMessages } from '../locales/generated/nl-NL';
|
||||
import { messages as noMessages } from '../locales/generated/no-NO';
|
||||
import { messages as plMessages } from '../locales/generated/pl-PL';
|
||||
import { messages as pseudoEnMessages } from '../locales/generated/pseudo-en';
|
||||
import { messages as ptBRMessages } from '../locales/generated/pt-BR';
|
||||
import { messages as ptPTMessages } from '../locales/generated/pt-PT';
|
||||
import { messages as roMessages } from '../locales/generated/ro-RO';
|
||||
import { messages as ruMessages } from '../locales/generated/ru-RU';
|
||||
import { messages as srMessages } from '../locales/generated/sr-Cyrl';
|
||||
import { messages as svMessages } from '../locales/generated/sv-SE';
|
||||
import { messages as trMessages } from '../locales/generated/tr-TR';
|
||||
import { messages as ukMessages } from '../locales/generated/uk-UA';
|
||||
import { messages as viMessages } from '../locales/generated/vi-VN';
|
||||
import { messages as zhHansMessages } from '../locales/generated/zh-CN';
|
||||
import { messages as zhHantMessages } from '../locales/generated/zh-TW';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
type BaseEmailProps = {
|
||||
children: JSX.Element | JSX.Element[] | string;
|
||||
@@ -44,59 +13,17 @@ type BaseEmailProps = {
|
||||
locale: keyof typeof APP_LOCALES;
|
||||
};
|
||||
|
||||
const messages: Record<keyof typeof APP_LOCALES, Messages> = {
|
||||
en: enMessages,
|
||||
'pseudo-en': pseudoEnMessages,
|
||||
'af-ZA': afMessages,
|
||||
'ar-SA': arMessages,
|
||||
'ca-ES': caMessages,
|
||||
'cs-CZ': csMessages,
|
||||
'da-DK': daMessages,
|
||||
'de-DE': deMessages,
|
||||
'el-GR': elMessages,
|
||||
'es-ES': esMessages,
|
||||
'fi-FI': fiMessages,
|
||||
'fr-FR': frMessages,
|
||||
'he-IL': heMessages,
|
||||
'hu-HU': huMessages,
|
||||
'it-IT': itMessages,
|
||||
'ja-JP': jaMessages,
|
||||
'ko-KR': koMessages,
|
||||
'nl-NL': nlMessages,
|
||||
'no-NO': noMessages,
|
||||
'pl-PL': plMessages,
|
||||
'pt-BR': ptBRMessages,
|
||||
'pt-PT': ptPTMessages,
|
||||
'ro-RO': roMessages,
|
||||
'ru-RU': ruMessages,
|
||||
'sr-Cyrl': srMessages,
|
||||
'sv-SE': svMessages,
|
||||
'tr-TR': trMessages,
|
||||
'uk-UA': ukMessages,
|
||||
'vi-VN': viMessages,
|
||||
'zh-CN': zhHansMessages,
|
||||
'zh-TW': zhHantMessages,
|
||||
};
|
||||
|
||||
(Object.entries(messages) as [keyof typeof APP_LOCALES, any][]).forEach(
|
||||
([locale, message]) => {
|
||||
i18n.load(locale, message);
|
||||
},
|
||||
);
|
||||
|
||||
i18n.activate(SOURCE_LOCALE);
|
||||
|
||||
export const BaseEmail = ({ children, width, locale }: BaseEmailProps) => {
|
||||
i18n.activate(locale);
|
||||
const i18nInstance = createI18nInstance(locale);
|
||||
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<I18nProvider i18n={i18nInstance}>
|
||||
<Html lang={locale}>
|
||||
<BaseHead />
|
||||
<Container width={width || 290}>
|
||||
<Logo />
|
||||
{children}
|
||||
<Footer />
|
||||
<Footer i18n={i18nInstance} />
|
||||
</Container>
|
||||
</Html>
|
||||
</I18nProvider>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { type I18n } from '@lingui/core';
|
||||
import { Column, Container, Row } from '@react-email/components';
|
||||
import { Link } from 'src/components/Link';
|
||||
import { ShadowText } from 'src/components/ShadowText';
|
||||
@@ -7,7 +7,11 @@ const footerContainerStyle = {
|
||||
marginTop: '12px',
|
||||
};
|
||||
|
||||
export const Footer = () => {
|
||||
type FooterProps = {
|
||||
i18n: I18n;
|
||||
};
|
||||
|
||||
export const Footer = ({ i18n }: FooterProps) => {
|
||||
return (
|
||||
<Container style={footerContainerStyle}>
|
||||
<Row>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { type I18n } from '@lingui/core';
|
||||
import { MainText } from 'src/components/MainText';
|
||||
import { SubTitle } from 'src/components/SubTitle';
|
||||
|
||||
export const WhatIsTwenty = () => {
|
||||
type WhatIsTwentyProps = {
|
||||
i18n: I18n;
|
||||
};
|
||||
|
||||
export const WhatIsTwenty = ({ i18n }: WhatIsTwentyProps) => {
|
||||
return (
|
||||
<>
|
||||
<SubTitle value={i18n._('What is Twenty?')} />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { Trans } from '@lingui/react';
|
||||
import { BaseEmail } from 'src/components/BaseEmail';
|
||||
import { CallToAction } from 'src/components/CallToAction';
|
||||
import { MainText } from 'src/components/MainText';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
type CleanSuspendedWorkspaceEmailProps = {
|
||||
@@ -19,6 +19,8 @@ export const CleanSuspendedWorkspaceEmail = ({
|
||||
workspaceDisplayName,
|
||||
locale,
|
||||
}: CleanSuspendedWorkspaceEmailProps) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
|
||||
return (
|
||||
<BaseEmail width={333} locale={locale}>
|
||||
<Title value={i18n._('Deleted Workspace')} />
|
||||
@@ -58,6 +60,6 @@ CleanSuspendedWorkspaceEmail.PreviewProps = {
|
||||
userName: 'John Doe',
|
||||
workspaceDisplayName: 'My Workspace',
|
||||
locale: 'en',
|
||||
} as CleanSuspendedWorkspaceEmailProps;
|
||||
};
|
||||
|
||||
export default CleanSuspendedWorkspaceEmail;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { Trans } from '@lingui/react';
|
||||
import { BaseEmail } from 'src/components/BaseEmail';
|
||||
import { CallToAction } from 'src/components/CallToAction';
|
||||
import { Link } from 'src/components/Link';
|
||||
import { MainText } from 'src/components/MainText';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
type PasswordResetLinkEmailProps = {
|
||||
@@ -18,6 +18,8 @@ export const PasswordResetLinkEmail = ({
|
||||
link,
|
||||
locale,
|
||||
}: PasswordResetLinkEmailProps) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
|
||||
return (
|
||||
<BaseEmail locale={locale}>
|
||||
<Title value={i18n._('Reset your password 🗝')} />
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { Trans } from '@lingui/react';
|
||||
import { BaseEmail } from 'src/components/BaseEmail';
|
||||
import { CallToAction } from 'src/components/CallToAction';
|
||||
import { MainText } from 'src/components/MainText';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
type PasswordUpdateNotifyEmailProps = {
|
||||
@@ -19,6 +19,7 @@ export const PasswordUpdateNotifyEmail = ({
|
||||
link,
|
||||
locale,
|
||||
}: PasswordUpdateNotifyEmailProps) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
const formattedDate = i18n.date(new Date());
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { Trans } from '@lingui/react';
|
||||
import { BaseEmail } from 'src/components/BaseEmail';
|
||||
import { CallToAction } from 'src/components/CallToAction';
|
||||
import { MainText } from 'src/components/MainText';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
type SendEmailVerificationLinkEmailProps = {
|
||||
@@ -15,6 +15,8 @@ export const SendEmailVerificationLinkEmail = ({
|
||||
link,
|
||||
locale,
|
||||
}: SendEmailVerificationLinkEmailProps) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
|
||||
return (
|
||||
<BaseEmail width={333} locale={locale}>
|
||||
<Title value={i18n._('Confirm your email address')} />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { Trans } from '@lingui/react';
|
||||
import { Img } from '@react-email/components';
|
||||
import { emailTheme } from 'src/common-style';
|
||||
@@ -12,6 +11,7 @@ import { MainText } from 'src/components/MainText';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { WhatIsTwenty } from 'src/components/WhatIsTwenty';
|
||||
import { capitalize } from 'src/utils/capitalize';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { getImageAbsoluteURI } from 'twenty-shared/utils';
|
||||
|
||||
@@ -34,6 +34,7 @@ export const SendInviteLinkEmail = ({
|
||||
serverUrl,
|
||||
locale,
|
||||
}: SendInviteLinkEmailProps) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
const workspaceLogo = workspace.logo
|
||||
? getImageAbsoluteURI({ imageUrl: workspace.logo, baseUrl: serverUrl })
|
||||
: null;
|
||||
@@ -76,7 +77,7 @@ export const SendInviteLinkEmail = ({
|
||||
{workspace.name ? <HighlightedText value={workspace.name} /> : <></>}
|
||||
<CallToAction href={link} value={i18n._('Accept invite')} />
|
||||
</HighlightedContainer>
|
||||
<WhatIsTwenty />
|
||||
<WhatIsTwenty i18n={i18n} />
|
||||
</BaseEmail>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { BaseEmail } from 'src/components/BaseEmail';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
type TestEmailProps = {
|
||||
@@ -10,6 +10,8 @@ type TestEmailProps = {
|
||||
// This is a test email which isn't used in production
|
||||
// It's useful to do tests and play in a local environment
|
||||
export const TestEmail = ({ locale }: TestEmailProps) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
|
||||
return (
|
||||
<BaseEmail locale={locale}>
|
||||
<Title value={i18n._('Test email')} />
|
||||
@@ -21,6 +23,6 @@ export const TestEmail = ({ locale }: TestEmailProps) => {
|
||||
|
||||
TestEmail.PreviewProps = {
|
||||
locale: 'en',
|
||||
} as TestEmailProps;
|
||||
};
|
||||
|
||||
export default TestEmail;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { Trans } from '@lingui/react';
|
||||
import { Img } from '@react-email/components';
|
||||
import { emailTheme } from 'src/common-style';
|
||||
@@ -11,6 +10,7 @@ import { Link } from 'src/components/Link';
|
||||
import { MainText } from 'src/components/MainText';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { capitalize } from 'src/utils/capitalize';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { getImageAbsoluteURI } from 'twenty-shared/utils';
|
||||
import { DEFAULT_WORKSPACE_LOGO } from 'src/constants/DefaultWorkspaceLogo';
|
||||
@@ -36,6 +36,7 @@ export const SendApprovedAccessDomainValidation = ({
|
||||
serverUrl,
|
||||
locale,
|
||||
}: SendApprovedAccessDomainValidationProps) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
const workspaceLogo = workspace.logo
|
||||
? getImageAbsoluteURI({ imageUrl: workspace.logo, baseUrl: serverUrl })
|
||||
: null;
|
||||
@@ -92,6 +93,6 @@ SendApprovedAccessDomainValidation.PreviewProps = {
|
||||
},
|
||||
serverUrl: 'https://app.twenty.com',
|
||||
locale: 'en',
|
||||
} as SendApprovedAccessDomainValidationProps;
|
||||
};
|
||||
|
||||
export default SendApprovedAccessDomainValidation;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { Trans } from '@lingui/react';
|
||||
import { BaseEmail } from 'src/components/BaseEmail';
|
||||
import { CallToAction } from 'src/components/CallToAction';
|
||||
import { MainText } from 'src/components/MainText';
|
||||
import { Title } from 'src/components/Title';
|
||||
import { createI18nInstance } from 'src/utils/i18n.utils';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
type WarnSuspendedWorkspaceEmailProps = {
|
||||
@@ -21,6 +21,7 @@ export const WarnSuspendedWorkspaceEmail = ({
|
||||
workspaceDisplayName,
|
||||
locale,
|
||||
}: WarnSuspendedWorkspaceEmailProps) => {
|
||||
const i18n = createI18nInstance(locale);
|
||||
const daysLeft = inactiveDaysBeforeDelete - daysSinceInactive;
|
||||
const dayOrDays = daysLeft > 1 ? 'days' : 'day';
|
||||
const remainingDays = daysLeft > 0 ? daysLeft : 0;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { setupI18n, type I18n, type Messages } from '@lingui/core';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { messages as afMessages } from '../locales/generated/af-ZA';
|
||||
import { messages as arMessages } from '../locales/generated/ar-SA';
|
||||
import { messages as caMessages } from '../locales/generated/ca-ES';
|
||||
import { messages as csMessages } from '../locales/generated/cs-CZ';
|
||||
import { messages as daMessages } from '../locales/generated/da-DK';
|
||||
import { messages as deMessages } from '../locales/generated/de-DE';
|
||||
import { messages as elMessages } from '../locales/generated/el-GR';
|
||||
import { messages as enMessages } from '../locales/generated/en';
|
||||
import { messages as esMessages } from '../locales/generated/es-ES';
|
||||
import { messages as fiMessages } from '../locales/generated/fi-FI';
|
||||
import { messages as frMessages } from '../locales/generated/fr-FR';
|
||||
import { messages as heMessages } from '../locales/generated/he-IL';
|
||||
import { messages as huMessages } from '../locales/generated/hu-HU';
|
||||
import { messages as itMessages } from '../locales/generated/it-IT';
|
||||
import { messages as jaMessages } from '../locales/generated/ja-JP';
|
||||
import { messages as koMessages } from '../locales/generated/ko-KR';
|
||||
import { messages as nlMessages } from '../locales/generated/nl-NL';
|
||||
import { messages as noMessages } from '../locales/generated/no-NO';
|
||||
import { messages as plMessages } from '../locales/generated/pl-PL';
|
||||
import { messages as pseudoEnMessages } from '../locales/generated/pseudo-en';
|
||||
import { messages as ptBRMessages } from '../locales/generated/pt-BR';
|
||||
import { messages as ptPTMessages } from '../locales/generated/pt-PT';
|
||||
import { messages as roMessages } from '../locales/generated/ro-RO';
|
||||
import { messages as ruMessages } from '../locales/generated/ru-RU';
|
||||
import { messages as srMessages } from '../locales/generated/sr-Cyrl';
|
||||
import { messages as svMessages } from '../locales/generated/sv-SE';
|
||||
import { messages as trMessages } from '../locales/generated/tr-TR';
|
||||
import { messages as ukMessages } from '../locales/generated/uk-UA';
|
||||
import { messages as viMessages } from '../locales/generated/vi-VN';
|
||||
import { messages as zhHansMessages } from '../locales/generated/zh-CN';
|
||||
import { messages as zhHantMessages } from '../locales/generated/zh-TW';
|
||||
|
||||
const messages: Record<keyof typeof APP_LOCALES, Messages> = {
|
||||
en: enMessages,
|
||||
'pseudo-en': pseudoEnMessages,
|
||||
'af-ZA': afMessages,
|
||||
'ar-SA': arMessages,
|
||||
'ca-ES': caMessages,
|
||||
'cs-CZ': csMessages,
|
||||
'da-DK': daMessages,
|
||||
'de-DE': deMessages,
|
||||
'el-GR': elMessages,
|
||||
'es-ES': esMessages,
|
||||
'fi-FI': fiMessages,
|
||||
'fr-FR': frMessages,
|
||||
'he-IL': heMessages,
|
||||
'hu-HU': huMessages,
|
||||
'it-IT': itMessages,
|
||||
'ja-JP': jaMessages,
|
||||
'ko-KR': koMessages,
|
||||
'nl-NL': nlMessages,
|
||||
'no-NO': noMessages,
|
||||
'pl-PL': plMessages,
|
||||
'pt-BR': ptBRMessages,
|
||||
'pt-PT': ptPTMessages,
|
||||
'ro-RO': roMessages,
|
||||
'ru-RU': ruMessages,
|
||||
'sr-Cyrl': srMessages,
|
||||
'sv-SE': svMessages,
|
||||
'tr-TR': trMessages,
|
||||
'uk-UA': ukMessages,
|
||||
'vi-VN': viMessages,
|
||||
'zh-CN': zhHansMessages,
|
||||
'zh-TW': zhHantMessages,
|
||||
};
|
||||
|
||||
const i18nInstancesMap: Partial<Record<keyof typeof APP_LOCALES, I18n>> = {};
|
||||
|
||||
export const createI18nInstance = (locale: keyof typeof APP_LOCALES): I18n => {
|
||||
if (isDefined(i18nInstancesMap[locale])) {
|
||||
return i18nInstancesMap[locale];
|
||||
}
|
||||
|
||||
const i18nInstance = setupI18n();
|
||||
const localeMessages = messages[locale] ?? messages.en;
|
||||
|
||||
i18nInstance.load(locale, localeMessages);
|
||||
i18nInstance.activate(locale);
|
||||
|
||||
i18nInstancesMap[locale] = i18nInstance;
|
||||
|
||||
return i18nInstance;
|
||||
};
|
||||
+1
@@ -249,6 +249,7 @@ describe('ApprovedAccessDomainService', () => {
|
||||
const sender = {
|
||||
userEmail: 'sender@example.com',
|
||||
name: { firstName: 'John', lastName: 'Doe' },
|
||||
locale: 'en',
|
||||
} as WorkspaceMemberWorkspaceEntity;
|
||||
const workspace = {
|
||||
displayName: 'Test Workspace',
|
||||
|
||||
@@ -26,6 +26,7 @@ import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/ser
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@@ -134,6 +135,14 @@ describe('AuthService', () => {
|
||||
findWorkspaceFromWorkspaceIdOrAuthProvider: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
getI18nInstance: jest.fn().mockReturnValue({
|
||||
_: jest.fn().mockReturnValue('mocked-translation'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { t, msg } from '@lingui/core/macro';
|
||||
import { render } from '@react-email/render';
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
@@ -54,6 +53,7 @@ import { type WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
@@ -87,6 +87,7 @@ export class AuthService {
|
||||
private readonly emailService: EmailService,
|
||||
@InjectRepository(AppToken)
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
private async checkAccessAndUseInvitationOrThrow(
|
||||
@@ -485,14 +486,16 @@ export class AuthService {
|
||||
const html = render(emailTemplate, { pretty: true });
|
||||
const text = render(emailTemplate, { plainText: true });
|
||||
|
||||
i18n.activate(firstUserWorkspace.locale);
|
||||
const passwordChangedMsg = msg`Your Password Has Been Successfully Changed`;
|
||||
const i18n = this.i18nService.getI18nInstance(firstUserWorkspace.locale);
|
||||
const subject = i18n._(passwordChangedMsg);
|
||||
|
||||
await this.emailService.send({
|
||||
from: `${this.twentyConfigService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
to: user.email,
|
||||
subject: t`Your Password Has Been Successfully Changed`,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
|
||||
+9
@@ -14,6 +14,7 @@ import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
|
||||
import { ResetPasswordService } from './reset-password.service';
|
||||
|
||||
@@ -67,6 +68,14 @@ describe('ResetPasswordService', () => {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
getI18nInstance: jest.fn().mockReturnValue({
|
||||
_: jest.fn().mockReturnValue('mocked-translation'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
+7
-4
@@ -3,8 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { render } from '@react-email/render';
|
||||
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
@@ -28,6 +27,7 @@ import { type PasswordResetToken } from 'src/engine/core-modules/auth/dto/token.
|
||||
import { type ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@@ -45,6 +45,7 @@ export class ResetPasswordService {
|
||||
@InjectRepository(AppToken)
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
async generatePasswordResetToken(
|
||||
@@ -165,14 +166,16 @@ export class ResetPasswordService {
|
||||
const html = render(emailTemplate, { pretty: true });
|
||||
const text = render(emailTemplate, { plainText: true });
|
||||
|
||||
i18n.activate(locale);
|
||||
const resetPasswordMsg = msg`Action Needed to Reset Password`;
|
||||
const i18n = this.i18nService.getI18nInstance(locale);
|
||||
const subject = i18n._(resetPasswordMsg);
|
||||
|
||||
this.emailService.send({
|
||||
from: `${this.twentyConfigService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
to: email,
|
||||
subject: t`Action Needed to Reset Password`,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
|
||||
+8
-4
@@ -1,8 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { render } from '@react-email/render';
|
||||
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
EmailVerificationExceptionCode,
|
||||
} from 'src/engine/core-modules/email-verification/email-verification.exception';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
|
||||
@@ -37,6 +37,7 @@ export class EmailVerificationService {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly userService: UserService,
|
||||
private readonly emailVerificationTokenService: EmailVerificationTokenService,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
async sendVerificationEmail(
|
||||
@@ -84,13 +85,16 @@ export class EmailVerificationService {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
i18n.activate(locale);
|
||||
const emailVerificationMsg = msg`Welcome to Twenty: Please Confirm Your Email`;
|
||||
const i18n = this.i18nService.getI18nInstance(locale);
|
||||
const subject = i18n._(emailVerificationMsg);
|
||||
|
||||
await this.emailService.send({
|
||||
from: `${this.twentyConfigService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
to: email,
|
||||
subject: t`Welcome to Twenty: Please Confirm Your Email`,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Injectable, type NestMiddleware } from '@nestjs/common';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { type NextFunction, type Request, type Response } from 'express';
|
||||
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
// TODO: this should be deprecated as singleton pattern won't work: user will keep changing locales for eachothers
|
||||
@Injectable()
|
||||
export class I18nMiddleware implements NestMiddleware {
|
||||
use(req: Request, _res: Response, next: NextFunction) {
|
||||
const locale = req.locale;
|
||||
|
||||
if (locale && Object.values(APP_LOCALES).includes(locale)) {
|
||||
i18n.activate(locale);
|
||||
} else {
|
||||
i18n.activate(SOURCE_LOCALE);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
Global,
|
||||
type MiddlewareConsumer,
|
||||
Module,
|
||||
type NestModule,
|
||||
} from '@nestjs/common';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { I18nMiddleware } from 'src/engine/core-modules/i18n/i18n.middleware';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
|
||||
@Global()
|
||||
@@ -13,8 +7,4 @@ import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
providers: [I18nService],
|
||||
exports: [I18nService],
|
||||
})
|
||||
export class I18nModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(I18nMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
export class I18nModule {}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type I18n,
|
||||
type MessageOptions,
|
||||
type Messages,
|
||||
i18n,
|
||||
setupI18n,
|
||||
} from '@lingui/core';
|
||||
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
@@ -90,14 +89,7 @@ export class I18nService implements OnModuleInit {
|
||||
localeI18n.activate(locale);
|
||||
|
||||
this.i18nInstancesMap[locale] = localeI18n;
|
||||
|
||||
// TODO: deprecate this line which is legacy as soon as we only use the i18nInstancesMap
|
||||
// Also deprecate i18n.middleware.ts
|
||||
i18n.load(locale, messages);
|
||||
});
|
||||
|
||||
// TODO: deprecate this line which is legacy as soon as we only use the i18nInstancesMap
|
||||
i18n.activate(SOURCE_LOCALE);
|
||||
}
|
||||
|
||||
getI18nInstance(locale: keyof typeof APP_LOCALES) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
|
||||
import { RequestLocale } from 'src/engine/decorators/locale/request-locale.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
|
||||
|
||||
@Controller('rest/metadata/views')
|
||||
@@ -40,6 +41,7 @@ export class ViewController {
|
||||
constructor(
|
||||
private readonly viewService: ViewService,
|
||||
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -163,6 +165,7 @@ export class ViewController {
|
||||
const objectMetadata = objectMetadataMaps.byId[view.objectMetadataId];
|
||||
|
||||
if (objectMetadata) {
|
||||
const i18n = this.i18nService.getI18nInstance(locale ?? 'en');
|
||||
const translatedObjectLabel = resolveObjectMetadataStandardOverride(
|
||||
{
|
||||
labelPlural: objectMetadata.labelPlural,
|
||||
@@ -174,6 +177,7 @@ export class ViewController {
|
||||
},
|
||||
'labelPlural',
|
||||
locale,
|
||||
i18n,
|
||||
);
|
||||
|
||||
processedName = this.viewService.processViewNameWithTemplate(
|
||||
|
||||
@@ -36,6 +36,7 @@ import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
|
||||
@Resolver(() => ViewDTO)
|
||||
@UseFilters(ViewGraphqlApiExceptionFilter)
|
||||
@@ -48,6 +49,7 @@ export class ViewResolver {
|
||||
private readonly viewFilterGroupService: ViewFilterGroupService,
|
||||
private readonly viewSortService: ViewSortService,
|
||||
private readonly viewGroupService: ViewGroupService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly viewV2Service: ViewV2Service,
|
||||
) {}
|
||||
@@ -65,6 +67,7 @@ export class ViewResolver {
|
||||
});
|
||||
|
||||
if (objectMetadata) {
|
||||
const i18n = this.i18nService.getI18nInstance(context.req.locale);
|
||||
const translatedObjectLabel = resolveObjectMetadataStandardOverride(
|
||||
{
|
||||
labelPlural: objectMetadata.labelPlural,
|
||||
@@ -76,6 +79,7 @@ export class ViewResolver {
|
||||
},
|
||||
'labelPlural',
|
||||
context.req.locale,
|
||||
i18n,
|
||||
);
|
||||
|
||||
return this.viewService.processViewNameWithTemplate(
|
||||
|
||||
+10
@@ -16,6 +16,7 @@ import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
|
||||
import { WorkspaceInvitationService } from './workspace-invitation.service';
|
||||
|
||||
@@ -83,6 +84,14 @@ describe('WorkspaceInvitationService', () => {
|
||||
// Add other methods as needed
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
getI18nInstance: jest.fn().mockReturnValue({
|
||||
_: jest.fn().mockReturnValue('mocked-translation'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -152,6 +161,7 @@ describe('WorkspaceInvitationService', () => {
|
||||
const sender = {
|
||||
userEmail: 'sender@example.com',
|
||||
name: { firstName: 'Sender' },
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'createWorkspaceInvitation').mockResolvedValue({
|
||||
|
||||
+7
-4
@@ -3,8 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { render } from '@react-email/render';
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
@@ -46,6 +46,7 @@ export class WorkspaceInvitationService {
|
||||
private readonly emailService: EmailService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
async validatePersonalInvitation({
|
||||
@@ -309,12 +310,14 @@ export class WorkspaceInvitationService {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
i18n.activate(sender.locale);
|
||||
const joinTeamMsg = msg`Join your team on Twenty`;
|
||||
const i18n = this.i18nService.getI18nInstance(sender.locale);
|
||||
const subject = i18n._(joinTeamMsg);
|
||||
|
||||
await this.emailService.send({
|
||||
from: `${sender.name.firstName} ${sender.name.lastName} (via Twenty) <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
to: invitation.value.email,
|
||||
subject: t`Join your team on Twenty`,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
|
||||
+10
-10
@@ -1,6 +1,5 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { type UpdateOneInputType } from '@ptc-org/nestjs-query-graphql';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
@@ -16,12 +15,7 @@ import { type IndexMetadataEntity } from 'src/engine/metadata-modules/index-meta
|
||||
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
|
||||
|
||||
jest.mock('@lingui/core', () => ({
|
||||
i18n: {
|
||||
_: jest.fn().mockImplementation((messageId) => `translated:${messageId}`),
|
||||
},
|
||||
}));
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
|
||||
// Create a type that omits id and workspaceId from UpdateFieldInput
|
||||
type UpdateFieldInputForTest = Omit<UpdateFieldInput, 'id' | 'workspaceId'>;
|
||||
@@ -50,6 +44,14 @@ describe('BeforeUpdateOneField', () => {
|
||||
findOneWithinWorkspace: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
getI18nInstance: jest.fn().mockReturnValue({
|
||||
_: jest.fn().mockReturnValue('mocked-translation'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -482,9 +484,7 @@ describe('BeforeUpdateOneField', () => {
|
||||
});
|
||||
|
||||
it('should reset locale-specific translations when they match translated defaults', async () => {
|
||||
const translatedLabel = 'translated:msg-label';
|
||||
|
||||
(i18n._ as jest.Mock).mockImplementation(() => translatedLabel);
|
||||
const translatedLabel = 'mocked-translation';
|
||||
|
||||
const instance: UpdateOneInputType<UpdateFieldInputForTest> = {
|
||||
id: mockFieldId,
|
||||
|
||||
+3
-1
@@ -1,6 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import {
|
||||
type BeforeUpdateOneHook,
|
||||
type UpdateOneInputType,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
ValidationError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { type FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
|
||||
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
|
||||
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
@@ -30,6 +30,7 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
||||
constructor(
|
||||
readonly fieldMetadataService: FieldMetadataService,
|
||||
readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
async run(
|
||||
@@ -289,6 +290,7 @@ export class BeforeUpdateOneField<T extends UpdateFieldInput>
|
||||
locale: keyof typeof APP_LOCALES,
|
||||
): boolean {
|
||||
const messageId = generateMessageId(originalValue ?? '');
|
||||
const i18n = this.i18nService.getI18nInstance(locale);
|
||||
const translatedMessage = i18n._(messageId);
|
||||
|
||||
if (newValue !== translatedMessage) {
|
||||
|
||||
+6
-3
@@ -1,20 +1,23 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { type I18n } from '@lingui/core';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
||||
import { resolveFieldMetadataStandardOverride } from 'src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util';
|
||||
|
||||
jest.mock('@lingui/core');
|
||||
jest.mock('src/engine/core-modules/i18n/utils/generateMessageId');
|
||||
|
||||
const mockI18n = i18n as jest.Mocked<typeof i18n>;
|
||||
const mockGenerateMessageId = generateMessageId as jest.MockedFunction<
|
||||
typeof generateMessageId
|
||||
>;
|
||||
|
||||
describe('resolveFieldMetadataStandardOverride', () => {
|
||||
let mockI18n: jest.Mocked<I18n>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockI18n = {
|
||||
_: jest.fn(),
|
||||
} as unknown as jest.Mocked<I18n>;
|
||||
});
|
||||
|
||||
describe('Custom fields', () => {
|
||||
|
||||
+10
-10
@@ -2,7 +2,6 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { type UpdateOneInputType } from '@ptc-org/nestjs-query-graphql';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
@@ -11,12 +10,7 @@ import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-met
|
||||
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
|
||||
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
|
||||
jest.mock('@lingui/core', () => ({
|
||||
i18n: {
|
||||
_: jest.fn().mockImplementation((messageId) => `translated:${messageId}`),
|
||||
},
|
||||
}));
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
|
||||
type UpdateObjectPayloadForTest = Omit<
|
||||
UpdateObjectPayload,
|
||||
@@ -47,6 +41,14 @@ describe('BeforeUpdateOneObject', () => {
|
||||
findBy: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
getI18nInstance: jest.fn().mockReturnValue({
|
||||
_: jest.fn().mockReturnValue('mocked-translation'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -511,9 +513,7 @@ describe('BeforeUpdateOneObject', () => {
|
||||
});
|
||||
|
||||
it('should reset locale-specific translations when they match translated defaults', async () => {
|
||||
const translatedLabel = 'translated:msg-label';
|
||||
|
||||
(i18n._ as jest.Mock).mockImplementation(() => translatedLabel);
|
||||
const translatedLabel = 'mocked-translation';
|
||||
|
||||
const instance: UpdateOneInputType<UpdateObjectPayloadForTest> = {
|
||||
id: mockObjectId,
|
||||
|
||||
+6
-2
@@ -4,7 +4,6 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import {
|
||||
type BeforeUpdateOneHook,
|
||||
type UpdateOneInputType,
|
||||
@@ -13,6 +12,7 @@ import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { type ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto';
|
||||
import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
|
||||
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
@@ -26,7 +26,10 @@ interface StandardObjectUpdate extends Partial<UpdateObjectPayload> {
|
||||
export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
||||
implements BeforeUpdateOneHook<T>
|
||||
{
|
||||
constructor(readonly objectMetadataService: ObjectMetadataService) {}
|
||||
constructor(
|
||||
readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
// TODO: this logic could be moved to a policy guard
|
||||
async run(
|
||||
@@ -220,6 +223,7 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
|
||||
locale: keyof typeof APP_LOCALES,
|
||||
): boolean {
|
||||
const messageId = generateMessageId(originalValue ?? '');
|
||||
const i18n = this.i18nService.getI18nInstance(locale);
|
||||
const translatedMessage = i18n._(messageId);
|
||||
|
||||
if (newValue !== translatedMessage) {
|
||||
|
||||
+14
@@ -31,6 +31,7 @@ import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-meta
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { objectMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/object-metadata/utils/object-metadata-graphql-api-exception-handler.util';
|
||||
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
|
||||
@@ -47,6 +48,7 @@ export class ObjectMetadataResolver {
|
||||
private readonly beforeUpdateOneObject: BeforeUpdateOneObject<UpdateObjectPayload>,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
|
||||
private readonly i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
@@ -54,10 +56,13 @@ export class ObjectMetadataResolver {
|
||||
@Parent() objectMetadata: ObjectMetadataDTO,
|
||||
@Context() context: I18nContext,
|
||||
): Promise<string> {
|
||||
const i18n = this.i18nService.getI18nInstance(context.req.locale);
|
||||
|
||||
return resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
'labelPlural',
|
||||
context.req.locale,
|
||||
i18n,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,10 +71,13 @@ export class ObjectMetadataResolver {
|
||||
@Parent() objectMetadata: ObjectMetadataDTO,
|
||||
@Context() context: I18nContext,
|
||||
): Promise<string> {
|
||||
const i18n = this.i18nService.getI18nInstance(context.req.locale);
|
||||
|
||||
return resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
context.req.locale,
|
||||
i18n,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,10 +86,13 @@ export class ObjectMetadataResolver {
|
||||
@Parent() objectMetadata: ObjectMetadataDTO,
|
||||
@Context() context: I18nContext,
|
||||
): Promise<string> {
|
||||
const i18n = this.i18nService.getI18nInstance(context.req.locale);
|
||||
|
||||
return resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
'description',
|
||||
context.req.locale,
|
||||
i18n,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,10 +102,13 @@ export class ObjectMetadataResolver {
|
||||
@Parent() objectMetadata: ObjectMetadataDTO,
|
||||
@Context() context: I18nContext,
|
||||
): Promise<string> {
|
||||
const i18n = this.i18nService.getI18nInstance(context.req.locale);
|
||||
|
||||
return resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
'icon',
|
||||
context.req.locale,
|
||||
i18n,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+32
-3
@@ -1,21 +1,24 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { type I18n } from '@lingui/core';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
|
||||
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
||||
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
|
||||
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
|
||||
|
||||
jest.mock('@lingui/core');
|
||||
jest.mock('src/engine/core-modules/i18n/utils/generateMessageId');
|
||||
|
||||
const mockI18n = i18n as jest.Mocked<typeof i18n>;
|
||||
const mockGenerateMessageId = generateMessageId as jest.MockedFunction<
|
||||
typeof generateMessageId
|
||||
>;
|
||||
|
||||
describe('resolveObjectMetadataStandardOverride', () => {
|
||||
let mockI18n: jest.Mocked<I18n>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockI18n = {
|
||||
_: jest.fn(),
|
||||
} as unknown as jest.Mocked<I18n>;
|
||||
});
|
||||
|
||||
describe('Custom objects', () => {
|
||||
@@ -41,6 +44,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('My Custom');
|
||||
@@ -68,6 +72,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'description',
|
||||
undefined,
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('Custom Description');
|
||||
@@ -95,6 +100,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'icon',
|
||||
SOURCE_LOCALE,
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('custom-icon');
|
||||
@@ -118,6 +124,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'icon',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('override-icon');
|
||||
@@ -148,6 +155,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
),
|
||||
).toBe('Libellé traduit');
|
||||
expect(
|
||||
@@ -155,6 +163,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelPlural',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
),
|
||||
).toBe('Libellés traduits');
|
||||
expect(
|
||||
@@ -162,6 +171,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'description',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
),
|
||||
).toBe('Description traduite');
|
||||
});
|
||||
@@ -191,6 +201,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('Standard Label');
|
||||
@@ -220,6 +231,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'description',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('Standard Description');
|
||||
@@ -250,6 +262,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
undefined,
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('Standard Label');
|
||||
@@ -277,6 +290,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
SOURCE_LOCALE,
|
||||
mockI18n,
|
||||
),
|
||||
).toBe('Overridden Label');
|
||||
expect(
|
||||
@@ -284,6 +298,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelPlural',
|
||||
SOURCE_LOCALE,
|
||||
mockI18n,
|
||||
),
|
||||
).toBe('Overridden Labels');
|
||||
expect(
|
||||
@@ -291,6 +306,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'description',
|
||||
SOURCE_LOCALE,
|
||||
mockI18n,
|
||||
),
|
||||
).toBe('Overridden Description');
|
||||
expect(
|
||||
@@ -298,6 +314,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'icon',
|
||||
SOURCE_LOCALE,
|
||||
mockI18n,
|
||||
),
|
||||
).toBe('overridden-icon');
|
||||
});
|
||||
@@ -322,6 +339,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('Standard Label');
|
||||
@@ -339,10 +357,14 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
},
|
||||
};
|
||||
|
||||
mockGenerateMessageId.mockReturnValue('generated-message-id');
|
||||
mockI18n._.mockReturnValue('generated-message-id');
|
||||
|
||||
const result = resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
SOURCE_LOCALE,
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('Standard Label');
|
||||
@@ -367,6 +389,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(mockGenerateMessageId).toHaveBeenCalledWith('Standard Label');
|
||||
@@ -393,6 +416,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('Standard Label');
|
||||
@@ -423,6 +447,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('Translation Override');
|
||||
@@ -447,6 +472,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
SOURCE_LOCALE,
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('Source Override');
|
||||
@@ -471,6 +497,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
'de-DE',
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('Auto Translated Label');
|
||||
@@ -496,6 +523,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
undefined,
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('Source Override');
|
||||
@@ -520,6 +548,7 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
undefined,
|
||||
mockI18n,
|
||||
);
|
||||
|
||||
expect(result).toBe('Auto Translated Label');
|
||||
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { type I18n } from '@lingui/core';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -18,6 +18,7 @@ export const resolveObjectMetadataStandardOverride = (
|
||||
>,
|
||||
labelKey: 'labelPlural' | 'labelSingular' | 'description' | 'icon',
|
||||
locale: keyof typeof APP_LOCALES | undefined,
|
||||
i18nInstance: I18n,
|
||||
): string => {
|
||||
const safeLocale = locale ?? SOURCE_LOCALE;
|
||||
|
||||
@@ -52,7 +53,7 @@ export const resolveObjectMetadataStandardOverride = (
|
||||
}
|
||||
|
||||
const messageId = generateMessageId(objectMetadata[labelKey] ?? '');
|
||||
const translatedMessage = i18n._(messageId);
|
||||
const translatedMessage = i18nInstance._(messageId);
|
||||
|
||||
if (translatedMessage === messageId) {
|
||||
return objectMetadata[labelKey] ?? '';
|
||||
|
||||
+7
-4
@@ -1,8 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { render } from '@react-email/render';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import {
|
||||
@@ -16,6 +15,7 @@ import { In, Repository } from 'typeorm';
|
||||
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
@@ -50,6 +50,7 @@ export class CleanerWorkspaceService {
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
@InjectRepository(UserWorkspace)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly i18nService: I18nService,
|
||||
) {
|
||||
this.inactiveDaysBeforeSoftDelete = this.twentyConfigService.get(
|
||||
'WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION',
|
||||
@@ -125,14 +126,16 @@ export class CleanerWorkspaceService {
|
||||
const html = render(emailTemplate, { pretty: true });
|
||||
const text = render(emailTemplate, { plainText: true });
|
||||
|
||||
i18n.activate(workspaceMember.locale);
|
||||
const workspaceDeletionMsg = msg`Action needed to prevent workspace deletion`;
|
||||
const i18n = this.i18nService.getI18nInstance(workspaceMember.locale);
|
||||
const subject = i18n._(workspaceDeletionMsg);
|
||||
|
||||
this.emailService.send({
|
||||
to: workspaceMember.userEmail,
|
||||
from: `${this.twentyConfigService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.twentyConfigService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
subject: t`Action needed to prevent workspace deletion`,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user