feat(server): resolve app-owned metadata translations at runtime (#22235)
## Summary First of a **4-PR stack** that lets apps built with `twenty-sdk` translate their metadata, resolved at runtime. The standard Twenty app is modelled as "an app like any other" — `NULL applicationRegistrationId` ⟺ the standard app, no special-casing. This PR adds the server foundation and wires runtime resolution for **object** and **field** metadata: - New `applicationTranslation` core table + entity (nullable `applicationRegistrationId`, `locale`, `messages` jsonb), one row per (app, locale) to avoid multi-MB rows. - `ApplicationTranslationCacheService` (process-local, 30s TTL) + `ApplicationTranslationSyncService` (upsert + soft-delete from a manifest). - Shared `translateStandardLabel` util: application catalog → i18n bundle → source value. - Object/field resolvers + dataloaders prefetch and apply the per-app catalog. The new `applicationCatalog` param is **optional**, so standard behaviour is byte-unchanged. - Fast instance command to create the table. ## Stack **PR 1/4**, targets `main`. Followed by: (2) twenty-sdk extract/compile → `manifest.translations`, (3) resolution across the remaining metadata resolvers, (4) the per-locale standard-override editor. ## Tests Unit: `translateStandardLabel`, `resolveObjectMetadataStandardOverride` (including the application-catalog path). ## Verification note The remote dev environment for this branch could not complete `yarn install` (no package-registry egress), so typecheck/lint/tests were not run locally — **CI is the source of truth** for this stack. Changes follow existing patterns. https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA --- _Generated by [Claude Code](https://claude.ai/code/session_01NiE7o3cd3zCLZarVkJa6UA)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22235?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+47
@@ -0,0 +1,47 @@
|
||||
import { type QueryRunner } from 'typeorm';
|
||||
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
|
||||
|
||||
@RegisteredInstanceCommand('2.17.0', 1801000100000)
|
||||
export class CreateApplicationTranslationCoreTableFastInstanceCommand
|
||||
implements FastInstanceCommand
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE IF NOT EXISTS "core"."applicationTranslation" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"applicationRegistrationId" uuid,
|
||||
"locale" text NOT NULL,
|
||||
"messages" jsonb NOT NULL DEFAULT '{}',
|
||||
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"deletedAt" TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT "PK_applicationTranslation_id" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_62da06a264eae9a1e84f6c611bd" FOREIGN KEY ("applicationRegistrationId") REFERENCES "core"."applicationRegistration"("id") ON DELETE CASCADE
|
||||
)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_APPLICATION_TRANSLATION_REGISTRATION_LOCALE_UNIQUE"
|
||||
ON "core"."applicationTranslation" ("applicationRegistrationId", "locale")
|
||||
WHERE "deletedAt" IS NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_APPLICATION_TRANSLATION_STANDARD_LOCALE_UNIQUE"
|
||||
ON "core"."applicationTranslation" ("locale")
|
||||
WHERE "deletedAt" IS NULL AND "applicationRegistrationId" IS NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "core"."IDX_APPLICATION_TRANSLATION_STANDARD_LOCALE_UNIQUE"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "core"."IDX_APPLICATION_TRANSLATION_REGISTRATION_LOCALE_UNIQUE"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "core"."applicationTranslation"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -81,6 +81,7 @@ import { AddPrimaryPublicDomainToApplicationFastInstanceCommand } from 'src/data
|
||||
import { MakePublicDomainApplicationIdNotNullSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-slow-1782281874769-make-public-domain-application-id-not-null';
|
||||
import { AddServerTriggerSettingsToLogicFunctionFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782211913427-add-server-trigger-settings-to-logic-function';
|
||||
import { CreateDpaAgreementCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-17/2-17-instance-command-fast-1801000020000-create-dpa-agreement-core-table';
|
||||
import { CreateApplicationTranslationCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-17/2-17-instance-command-fast-1801000100000-create-application-translation-core-table';
|
||||
|
||||
export const INSTANCE_COMMANDS = [
|
||||
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
|
||||
@@ -164,4 +165,5 @@ export const INSTANCE_COMMANDS = [
|
||||
AddPrimaryPublicDomainToApplicationFastInstanceCommand,
|
||||
MakePublicDomainApplicationIdNotNullSlowInstanceCommand,
|
||||
CreateDpaAgreementCoreTableFastInstanceCommand,
|
||||
CreateApplicationTranslationCoreTableFastInstanceCommand,
|
||||
];
|
||||
|
||||
+2
@@ -5,6 +5,7 @@ import { ApplicationManifestMigrationService } from 'src/engine/core-modules/app
|
||||
import { ApplicationManifestResolver } from 'src/engine/core-modules/application/application-manifest/application-manifest.resolver';
|
||||
import { ComputeApplicationManifestAllUniversalFlatEntityMapsService } from 'src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
|
||||
import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module';
|
||||
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
@@ -18,6 +19,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
@Module({
|
||||
imports: [
|
||||
ApplicationModule,
|
||||
ApplicationTranslationModule,
|
||||
ApplicationVariableEntityModule,
|
||||
FeatureFlagModule,
|
||||
FileStorageModule,
|
||||
|
||||
+20
@@ -8,6 +8,7 @@ import { PackageJson } from 'type-fest';
|
||||
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
|
||||
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
|
||||
import { ApplicationTranslationSyncService } from 'src/engine/core-modules/application/application-translation/application-translation-sync.service';
|
||||
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import {
|
||||
@@ -37,6 +38,7 @@ export class ApplicationSyncService {
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly applicationTranslationSyncService: ApplicationTranslationSyncService,
|
||||
@Inject(LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN)
|
||||
private readonly logicFunctionDriverFactory: LogicFunctionDriverFactory,
|
||||
) {}
|
||||
@@ -71,6 +73,24 @@ export class ApplicationSyncService {
|
||||
dryRun,
|
||||
});
|
||||
|
||||
if (!dryRun && isDefined(ownerFlatApplication.applicationRegistrationId)) {
|
||||
// Translation sync runs after the metadata migration is already applied
|
||||
// and is non-critical to the application itself, so a failure here must
|
||||
// never abort an otherwise successful install/sync. It is idempotent and
|
||||
// self-heals on the next sync.
|
||||
try {
|
||||
await this.applicationTranslationSyncService.syncFromManifest({
|
||||
applicationRegistrationId:
|
||||
ownerFlatApplication.applicationRegistrationId,
|
||||
translations: manifest.translations,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to sync application translations for registration ${ownerFlatApplication.applicationRegistrationId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Application sync from manifest ${dryRun ? 'plan computed (dry run)' : 'completed'}`,
|
||||
);
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationTranslationEntity } from 'src/engine/core-modules/application/application-translation/application-translation.entity';
|
||||
|
||||
type ApplicationCatalogsByLocale = Partial<
|
||||
Record<keyof typeof APP_LOCALES, Record<string, string>>
|
||||
>;
|
||||
|
||||
type ApplicationTranslationCacheEntry = {
|
||||
catalogsByLocale: ApplicationCatalogsByLocale;
|
||||
loadedAt: number;
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
|
||||
const EMPTY_CATALOG: Record<string, string> = {};
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationTranslationCacheService {
|
||||
private readonly cache = new Map<string, ApplicationTranslationCacheEntry>();
|
||||
|
||||
constructor(
|
||||
// applicationTranslation is a core cross-workspace table keyed by applicationRegistrationId, not workspaceId.
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(ApplicationTranslationEntity)
|
||||
private readonly applicationTranslationRepository: Repository<ApplicationTranslationEntity>,
|
||||
) {}
|
||||
|
||||
async getCatalog({
|
||||
applicationRegistrationId,
|
||||
locale,
|
||||
}: {
|
||||
applicationRegistrationId: string;
|
||||
locale: keyof typeof APP_LOCALES;
|
||||
}): Promise<Record<string, string>> {
|
||||
const entry = await this.getOrLoadEntry(applicationRegistrationId);
|
||||
|
||||
return entry.catalogsByLocale[locale] ?? EMPTY_CATALOG;
|
||||
}
|
||||
|
||||
invalidate(applicationRegistrationId: string): void {
|
||||
this.cache.delete(applicationRegistrationId);
|
||||
}
|
||||
|
||||
private async getOrLoadEntry(
|
||||
applicationRegistrationId: string,
|
||||
): Promise<ApplicationTranslationCacheEntry> {
|
||||
const cachedEntry = this.cache.get(applicationRegistrationId);
|
||||
|
||||
if (
|
||||
isDefined(cachedEntry) &&
|
||||
Date.now() - cachedEntry.loadedAt < CACHE_TTL_MS
|
||||
) {
|
||||
return cachedEntry;
|
||||
}
|
||||
|
||||
const rows = await this.applicationTranslationRepository.find({
|
||||
where: { applicationRegistrationId },
|
||||
});
|
||||
|
||||
const catalogsByLocale: ApplicationCatalogsByLocale = {};
|
||||
|
||||
for (const row of rows) {
|
||||
catalogsByLocale[row.locale] = row.messages;
|
||||
}
|
||||
|
||||
const entry: ApplicationTranslationCacheEntry = {
|
||||
catalogsByLocale,
|
||||
loadedAt: Date.now(),
|
||||
};
|
||||
|
||||
this.cache.set(applicationRegistrationId, entry);
|
||||
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { type TranslationsManifest } from 'twenty-shared/application';
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationTranslationCacheService } from 'src/engine/core-modules/application/application-translation/application-translation-cache.service';
|
||||
import { ApplicationTranslationEntity } from 'src/engine/core-modules/application/application-translation/application-translation.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationTranslationSyncService {
|
||||
constructor(
|
||||
// applicationTranslation is a core cross-workspace table keyed by applicationRegistrationId, not workspaceId.
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(ApplicationTranslationEntity)
|
||||
private readonly applicationTranslationRepository: Repository<ApplicationTranslationEntity>,
|
||||
private readonly applicationTranslationCacheService: ApplicationTranslationCacheService,
|
||||
) {}
|
||||
|
||||
async syncFromManifest({
|
||||
applicationRegistrationId,
|
||||
translations,
|
||||
}: {
|
||||
applicationRegistrationId: string;
|
||||
translations: TranslationsManifest | undefined;
|
||||
}): Promise<void> {
|
||||
const existingRows = await this.applicationTranslationRepository.find({
|
||||
where: { applicationRegistrationId },
|
||||
withDeleted: true,
|
||||
});
|
||||
|
||||
const existingRowByLocale = new Map<
|
||||
keyof typeof APP_LOCALES,
|
||||
ApplicationTranslationEntity
|
||||
>();
|
||||
|
||||
for (const row of existingRows) {
|
||||
const currentRow = existingRowByLocale.get(row.locale);
|
||||
|
||||
const shouldPreferRow =
|
||||
!isDefined(currentRow) ||
|
||||
(isDefined(currentRow.deletedAt) && !isDefined(row.deletedAt));
|
||||
|
||||
if (shouldPreferRow) {
|
||||
existingRowByLocale.set(row.locale, row);
|
||||
}
|
||||
}
|
||||
|
||||
const manifestLocales = new Set<keyof typeof APP_LOCALES>();
|
||||
const upsertPromises: Promise<unknown>[] = [];
|
||||
|
||||
for (const [locale, messages] of Object.entries(translations ?? {}) as [
|
||||
keyof typeof APP_LOCALES,
|
||||
Record<string, string>,
|
||||
][]) {
|
||||
manifestLocales.add(locale);
|
||||
|
||||
const existingRow = existingRowByLocale.get(locale);
|
||||
|
||||
if (isDefined(existingRow)) {
|
||||
upsertPromises.push(
|
||||
this.applicationTranslationRepository.update(existingRow.id, {
|
||||
messages,
|
||||
deletedAt: null,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
upsertPromises.push(
|
||||
this.applicationTranslationRepository.insert({
|
||||
applicationRegistrationId,
|
||||
locale,
|
||||
messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(upsertPromises);
|
||||
|
||||
const rowsToSoftDelete = existingRows.filter(
|
||||
(row) => !manifestLocales.has(row.locale) && !isDefined(row.deletedAt),
|
||||
);
|
||||
|
||||
if (rowsToSoftDelete.length > 0) {
|
||||
await this.applicationTranslationRepository.softDelete(
|
||||
rowsToSoftDelete.map((row) => row.id),
|
||||
);
|
||||
}
|
||||
|
||||
this.applicationTranslationCacheService.invalidate(
|
||||
applicationRegistrationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
|
||||
@Entity({ name: 'applicationTranslation', schema: 'core' })
|
||||
@Index(
|
||||
'IDX_APPLICATION_TRANSLATION_REGISTRATION_LOCALE_UNIQUE',
|
||||
['applicationRegistrationId', 'locale'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"deletedAt" IS NULL',
|
||||
},
|
||||
)
|
||||
@Index('IDX_APPLICATION_TRANSLATION_STANDARD_LOCALE_UNIQUE', ['locale'], {
|
||||
unique: true,
|
||||
where: '"deletedAt" IS NULL AND "applicationRegistrationId" IS NULL',
|
||||
})
|
||||
export class ApplicationTranslationEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
applicationRegistrationId: string | null;
|
||||
|
||||
@ManyToOne(() => ApplicationRegistrationEntity, {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
@JoinColumn({ name: 'applicationRegistrationId' })
|
||||
applicationRegistration: Relation<ApplicationRegistrationEntity> | null;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
locale: keyof typeof APP_LOCALES;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
messages: Record<string, string>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn({ type: 'timestamptz' })
|
||||
deletedAt: Date | null;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationTranslationCacheService } from 'src/engine/core-modules/application/application-translation/application-translation-cache.service';
|
||||
import { ApplicationTranslationSyncService } from 'src/engine/core-modules/application/application-translation/application-translation-sync.service';
|
||||
import { ApplicationTranslationEntity } from 'src/engine/core-modules/application/application-translation/application-translation.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ApplicationTranslationEntity])],
|
||||
providers: [
|
||||
ApplicationTranslationCacheService,
|
||||
ApplicationTranslationSyncService,
|
||||
],
|
||||
exports: [
|
||||
ApplicationTranslationCacheService,
|
||||
ApplicationTranslationSyncService,
|
||||
],
|
||||
})
|
||||
export class ApplicationTranslationModule {}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { type I18n } from '@lingui/core';
|
||||
|
||||
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
||||
import { translateStandardLabel } from 'src/engine/core-modules/i18n/utils/translate-standard-label.util';
|
||||
|
||||
jest.mock('src/engine/core-modules/i18n/utils/generateMessageId');
|
||||
|
||||
const mockGenerateMessageId = generateMessageId as jest.MockedFunction<
|
||||
typeof generateMessageId
|
||||
>;
|
||||
|
||||
describe('translateStandardLabel', () => {
|
||||
let mockI18n: jest.Mocked<I18n>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockI18n = {
|
||||
_: jest.fn(),
|
||||
} as unknown as jest.Mocked<I18n>;
|
||||
});
|
||||
|
||||
it('should return the source value when it is empty', () => {
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: '',
|
||||
isStandardApp: true,
|
||||
applicationCatalog: undefined,
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('');
|
||||
expect(mockGenerateMessageId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resolve from the application catalog when provided', () => {
|
||||
mockGenerateMessageId.mockReturnValue('company-id');
|
||||
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: 'Company',
|
||||
isStandardApp: false,
|
||||
applicationCatalog: { 'company-id': 'Entreprise' },
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('Entreprise');
|
||||
expect(mockI18n._).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to the source value when the catalog has no matching entry', () => {
|
||||
mockGenerateMessageId.mockReturnValue('missing-id');
|
||||
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: 'Company',
|
||||
isStandardApp: false,
|
||||
applicationCatalog: {},
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('Company');
|
||||
});
|
||||
|
||||
it('should prefer the catalog over the standard bundle for an application', () => {
|
||||
mockGenerateMessageId.mockReturnValue('company-id');
|
||||
mockI18n._.mockReturnValue('Bundle Translation');
|
||||
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: 'Company',
|
||||
isStandardApp: true,
|
||||
applicationCatalog: { 'company-id': 'Entreprise' },
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('Entreprise');
|
||||
expect(mockI18n._).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resolve from the standard bundle when no catalog is provided', () => {
|
||||
mockGenerateMessageId.mockReturnValue('company-id');
|
||||
mockI18n._.mockReturnValue('Entreprise');
|
||||
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: 'Company',
|
||||
isStandardApp: true,
|
||||
applicationCatalog: undefined,
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('Entreprise');
|
||||
expect(mockI18n._).toHaveBeenCalledWith('company-id');
|
||||
});
|
||||
|
||||
it('should return the source value when the standard bundle has no translation', () => {
|
||||
mockGenerateMessageId.mockReturnValue('company-id');
|
||||
mockI18n._.mockReturnValue('company-id');
|
||||
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: 'Company',
|
||||
isStandardApp: true,
|
||||
applicationCatalog: undefined,
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('Company');
|
||||
});
|
||||
|
||||
it('should return the source value for a non-standard app without a catalog', () => {
|
||||
const result = translateStandardLabel({
|
||||
sourceValue: 'Company',
|
||||
isStandardApp: false,
|
||||
applicationCatalog: undefined,
|
||||
i18nInstance: mockI18n,
|
||||
});
|
||||
|
||||
expect(result).toBe('Company');
|
||||
expect(mockGenerateMessageId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { type I18n } from '@lingui/core';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
|
||||
|
||||
export const translateStandardLabel = ({
|
||||
sourceValue,
|
||||
isStandardApp,
|
||||
applicationCatalog,
|
||||
i18nInstance,
|
||||
}: {
|
||||
sourceValue: string;
|
||||
isStandardApp: boolean;
|
||||
applicationCatalog: Record<string, string> | undefined;
|
||||
i18nInstance: I18n;
|
||||
}): string => {
|
||||
if (!isNonEmptyString(sourceValue)) {
|
||||
return sourceValue ?? '';
|
||||
}
|
||||
|
||||
if (!isDefined(applicationCatalog) && !isStandardApp) {
|
||||
return sourceValue;
|
||||
}
|
||||
|
||||
const messageId = generateMessageId(sourceValue);
|
||||
|
||||
if (isDefined(applicationCatalog)) {
|
||||
return applicationCatalog[messageId] ?? sourceValue;
|
||||
}
|
||||
|
||||
if (isStandardApp) {
|
||||
const translatedMessage = i18nInstance._(messageId);
|
||||
|
||||
return translatedMessage === messageId ? sourceValue : translatedMessage;
|
||||
}
|
||||
|
||||
return sourceValue;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import type DataLoader from 'dataloader';
|
||||
|
||||
import {
|
||||
type ApplicationRegistrationIdLoaderPayload,
|
||||
type FieldMetadataLoaderPayload,
|
||||
type IndexFieldMetadataLoaderPayload,
|
||||
type IndexMetadataLoaderPayload,
|
||||
@@ -98,4 +99,9 @@ export interface IDataloaders {
|
||||
StandardApplicationIdLoaderPayload,
|
||||
string
|
||||
>;
|
||||
|
||||
applicationRegistrationIdLoader: DataLoader<
|
||||
ApplicationRegistrationIdLoaderPayload,
|
||||
string | null
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.module';
|
||||
import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module';
|
||||
import { DataloaderService } from 'src/engine/dataloaders/dataloader.service';
|
||||
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
@@ -10,6 +11,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata
|
||||
FieldMetadataModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
ApplicationRegistrationVariableModule,
|
||||
ApplicationTranslationModule,
|
||||
],
|
||||
providers: [DataloaderService],
|
||||
exports: [DataloaderService],
|
||||
|
||||
@@ -8,6 +8,8 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { type IndexMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface';
|
||||
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
|
||||
import { ApplicationTranslationCacheService } from 'src/engine/core-modules/application/application-translation/application-translation-cache.service';
|
||||
import { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
|
||||
import { filterMorphRelationDuplicateFields } from 'src/engine/dataloaders/utils/filter-morph-relation-duplicate-fields.util';
|
||||
@@ -122,12 +124,18 @@ export type StandardApplicationIdLoaderPayload = {
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export type ApplicationRegistrationIdLoaderPayload = {
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DataloaderService {
|
||||
constructor(
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly applicationTranslationCacheService: ApplicationTranslationCacheService,
|
||||
) {}
|
||||
|
||||
createLoaders(): IDataloaders {
|
||||
@@ -150,6 +158,8 @@ export class DataloaderService {
|
||||
const isConfiguredLoader = this.createIsConfiguredLoader();
|
||||
const standardApplicationIdLoader =
|
||||
this.createStandardApplicationIdLoader();
|
||||
const applicationRegistrationIdLoader =
|
||||
this.createApplicationRegistrationIdLoader();
|
||||
|
||||
return {
|
||||
relationLoader,
|
||||
@@ -167,6 +177,7 @@ export class DataloaderService {
|
||||
viewFilterGroupsByViewIdLoader,
|
||||
isConfiguredLoader,
|
||||
standardApplicationIdLoader,
|
||||
applicationRegistrationIdLoader,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,37 +309,66 @@ export class DataloaderService {
|
||||
return new DataLoader<FieldMetadataLoaderPayload, FieldMetadataDTO[]>(
|
||||
async (dataLoaderParams: FieldMetadataLoaderPayload[]) => {
|
||||
const locale = dataLoaderParams[0].locale;
|
||||
const i18nInstance = this.i18nService.getI18nInstance(
|
||||
locale ?? SOURCE_LOCALE,
|
||||
);
|
||||
const safeLocale = locale ?? SOURCE_LOCALE;
|
||||
const i18nInstance = this.i18nService.getI18nInstance(safeLocale);
|
||||
const workspaceId = dataLoaderParams[0].workspaceId;
|
||||
const objectMetadataIds = dataLoaderParams.map(
|
||||
(dataLoaderParam) => dataLoaderParam.objectMetadata.id,
|
||||
);
|
||||
|
||||
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
|
||||
const {
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
flatApplicationMaps,
|
||||
} =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
|
||||
flatMapsKeys: [
|
||||
'flatFieldMetadataMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
'flatApplicationMaps',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const fieldMetadataCollection = objectMetadataIds.map(
|
||||
const objectFlatFieldMetadatasList = objectMetadataIds.map(
|
||||
(objectMetadataId) => {
|
||||
const flatObjectMetadata =
|
||||
findFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityId: objectMetadataId,
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
});
|
||||
const objectFlatFieldMetadatas =
|
||||
findManyFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityIds: flatObjectMetadata.fieldIds,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
return findManyFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityIds: flatObjectMetadata.fieldIds,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const applicationCatalogByRegistrationId =
|
||||
await this.loadApplicationCatalogByRegistrationId({
|
||||
applicationIds: objectFlatFieldMetadatasList
|
||||
.flat()
|
||||
.map((flatFieldMetadata) => flatFieldMetadata.applicationId),
|
||||
flatApplicationMaps,
|
||||
locale: safeLocale,
|
||||
});
|
||||
|
||||
const fieldMetadataCollection = objectFlatFieldMetadatasList.map(
|
||||
(objectFlatFieldMetadatas) => {
|
||||
const overriddenFieldMetadataEntities =
|
||||
objectFlatFieldMetadatas.map((flatFieldMetadata) => {
|
||||
const applicationRegistrationId =
|
||||
flatApplicationMaps.byId[flatFieldMetadata.applicationId]
|
||||
?.applicationRegistrationId;
|
||||
const applicationCatalog = isDefined(applicationRegistrationId)
|
||||
? applicationCatalogByRegistrationId.get(
|
||||
applicationRegistrationId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return FIELD_METADATA_STANDARD_OVERRIDES_PROPERTIES.reduce(
|
||||
(acc, property) => ({
|
||||
...acc,
|
||||
@@ -341,9 +381,10 @@ export class DataloaderService {
|
||||
flatFieldMetadata.standardOverrides ?? undefined,
|
||||
},
|
||||
property,
|
||||
dataLoaderParams[0].locale,
|
||||
locale,
|
||||
i18nInstance,
|
||||
belongsToTwentyStandardApp(flatFieldMetadata),
|
||||
applicationCatalog,
|
||||
),
|
||||
}),
|
||||
flatFieldMetadata,
|
||||
@@ -789,4 +830,65 @@ export class DataloaderService {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private createApplicationRegistrationIdLoader() {
|
||||
return new DataLoader<
|
||||
ApplicationRegistrationIdLoaderPayload,
|
||||
string | null
|
||||
>(async (params: ApplicationRegistrationIdLoaderPayload[]) => {
|
||||
const workspaceId = params[0].workspaceId;
|
||||
|
||||
const { flatApplicationMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatApplicationMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
return params.map(
|
||||
({ applicationId }) =>
|
||||
flatApplicationMaps.byId[applicationId]?.applicationRegistrationId ??
|
||||
null,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async loadApplicationCatalogByRegistrationId({
|
||||
applicationIds,
|
||||
flatApplicationMaps,
|
||||
locale,
|
||||
}: {
|
||||
applicationIds: string[];
|
||||
flatApplicationMaps: FlatApplicationCacheMaps;
|
||||
locale: keyof typeof APP_LOCALES;
|
||||
}): Promise<Map<string, Record<string, string>>> {
|
||||
const registrationIds = [
|
||||
...new Set(
|
||||
applicationIds
|
||||
.map(
|
||||
(applicationId) =>
|
||||
flatApplicationMaps.byId[applicationId]
|
||||
?.applicationRegistrationId,
|
||||
)
|
||||
.filter(isDefined),
|
||||
),
|
||||
];
|
||||
|
||||
const catalogByRegistrationId = new Map<string, Record<string, string>>();
|
||||
|
||||
await Promise.all(
|
||||
registrationIds.map(async (applicationRegistrationId) => {
|
||||
const catalog =
|
||||
await this.applicationTranslationCacheService.getCatalog({
|
||||
applicationRegistrationId,
|
||||
locale,
|
||||
});
|
||||
|
||||
catalogByRegistrationId.set(applicationRegistrationId, catalog);
|
||||
}),
|
||||
);
|
||||
|
||||
return catalogByRegistrationId;
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
@@ -42,6 +43,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
|
||||
@Module({
|
||||
imports: [
|
||||
ApplicationModule,
|
||||
ApplicationTranslationModule,
|
||||
TokenModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
FeatureFlagModule,
|
||||
|
||||
+20
-1
@@ -5,6 +5,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { ApplicationTranslationCacheService } from 'src/engine/core-modules/application/application-translation/application-translation-cache.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { ForbiddenError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
@@ -44,6 +45,7 @@ export class FieldMetadataResolver {
|
||||
constructor(
|
||||
private readonly fieldMetadataService: FieldMetadataService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly applicationTranslationCacheService: ApplicationTranslationCacheService,
|
||||
) {}
|
||||
|
||||
@ResolveField(() => Boolean, {
|
||||
@@ -67,12 +69,29 @@ export class FieldMetadataResolver {
|
||||
const standardApplicationId =
|
||||
await context.loaders.standardApplicationIdLoader.load({ workspaceId });
|
||||
|
||||
const isStandardApp = fieldMetadata.applicationId === standardApplicationId;
|
||||
|
||||
const applicationRegistrationId = isStandardApp
|
||||
? null
|
||||
: await context.loaders.applicationRegistrationIdLoader.load({
|
||||
workspaceId,
|
||||
applicationId: fieldMetadata.applicationId,
|
||||
});
|
||||
|
||||
const applicationCatalog = isDefined(applicationRegistrationId)
|
||||
? await this.applicationTranslationCacheService.getCatalog({
|
||||
applicationRegistrationId,
|
||||
locale: context.req.locale,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return resolveFieldMetadataStandardOverride(
|
||||
fieldMetadata,
|
||||
labelKey,
|
||||
context.req.locale,
|
||||
i18n,
|
||||
fieldMetadata.applicationId === standardApplicationId,
|
||||
isStandardApp,
|
||||
applicationCatalog,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+9
-12
@@ -3,10 +3,9 @@ import { isNonEmptyString } from '@sniptt/guards';
|
||||
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 { translateStandardLabel } from 'src/engine/core-modules/i18n/utils/translate-standard-label.util';
|
||||
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
|
||||
|
||||
// TODO simplify
|
||||
export const resolveFieldMetadataStandardOverride = (
|
||||
fieldMetadata: Pick<
|
||||
FieldMetadataDTO,
|
||||
@@ -16,10 +15,11 @@ export const resolveFieldMetadataStandardOverride = (
|
||||
locale: keyof typeof APP_LOCALES | undefined,
|
||||
i18nInstance: I18n,
|
||||
isStandardApp: boolean,
|
||||
applicationCatalog?: Record<string, string>,
|
||||
): string => {
|
||||
const safeLocale = locale ?? SOURCE_LOCALE;
|
||||
|
||||
if (!isStandardApp) {
|
||||
if (!isStandardApp && !isDefined(applicationCatalog)) {
|
||||
return fieldMetadata[labelKey] ?? '';
|
||||
}
|
||||
|
||||
@@ -43,13 +43,10 @@ export const resolveFieldMetadataStandardOverride = (
|
||||
return fieldMetadata.standardOverrides[labelKey] ?? '';
|
||||
}
|
||||
|
||||
const messageId = generateMessageId(fieldMetadata[labelKey] ?? '');
|
||||
|
||||
const translatedMessage = i18nInstance._(messageId);
|
||||
|
||||
if (translatedMessage === messageId) {
|
||||
return fieldMetadata[labelKey] ?? '';
|
||||
}
|
||||
|
||||
return translatedMessage;
|
||||
return translateStandardLabel({
|
||||
sourceValue: fieldMetadata[labelKey] ?? '',
|
||||
isStandardApp,
|
||||
applicationCatalog,
|
||||
i18nInstance,
|
||||
});
|
||||
};
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationTranslationModule } from 'src/engine/core-modules/application/application-translation/application-translation.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
@@ -46,6 +47,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
WorkspaceCacheStorageModule,
|
||||
FeatureFlagModule,
|
||||
ApplicationModule,
|
||||
ApplicationTranslationModule,
|
||||
NestjsQueryGraphQLModule.forFeature({
|
||||
imports: [
|
||||
TypeORMModule,
|
||||
|
||||
+22
-1
@@ -9,8 +9,10 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { ApplicationTranslationCacheService } from 'src/engine/core-modules/application/application-translation/application-translation-cache.service';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
@@ -46,6 +48,7 @@ export class ObjectMetadataResolver {
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly objectRecordCountService: ObjectRecordCountService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly applicationTranslationCacheService: ApplicationTranslationCacheService,
|
||||
) {}
|
||||
|
||||
@ResolveField(() => Boolean, {
|
||||
@@ -81,12 +84,30 @@ export class ObjectMetadataResolver {
|
||||
const standardApplicationId =
|
||||
await context.loaders.standardApplicationIdLoader.load({ workspaceId });
|
||||
|
||||
const isStandardApp =
|
||||
objectMetadata.applicationId === standardApplicationId;
|
||||
|
||||
const applicationRegistrationId = isStandardApp
|
||||
? null
|
||||
: await context.loaders.applicationRegistrationIdLoader.load({
|
||||
workspaceId,
|
||||
applicationId: objectMetadata.applicationId,
|
||||
});
|
||||
|
||||
const applicationCatalog = isDefined(applicationRegistrationId)
|
||||
? await this.applicationTranslationCacheService.getCatalog({
|
||||
applicationRegistrationId,
|
||||
locale: context.req.locale,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
labelKey,
|
||||
context.req.locale,
|
||||
i18n,
|
||||
objectMetadata.applicationId === standardApplicationId,
|
||||
isStandardApp,
|
||||
applicationCatalog,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+80
@@ -654,4 +654,84 @@ describe('resolveObjectMetadataStandardOverride', () => {
|
||||
expect(mockI18n._).toHaveBeenCalledWith('auto.translation.id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Application objects - catalog translations', () => {
|
||||
it('should translate an application object label from its catalog', () => {
|
||||
mockGenerateMessageId.mockReturnValue('app.label.id');
|
||||
|
||||
const objectMetadata = {
|
||||
labelSingular: 'Property',
|
||||
labelPlural: 'Properties',
|
||||
description: 'A property',
|
||||
icon: 'IconBuilding',
|
||||
isCustom: false,
|
||||
standardOverrides: undefined,
|
||||
};
|
||||
|
||||
const result = resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
false,
|
||||
{ 'app.label.id': 'Bien' },
|
||||
);
|
||||
|
||||
expect(result).toBe('Bien');
|
||||
expect(mockI18n._).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to the source value when the catalog has no entry', () => {
|
||||
mockGenerateMessageId.mockReturnValue('missing.id');
|
||||
|
||||
const objectMetadata = {
|
||||
labelSingular: 'Property',
|
||||
labelPlural: 'Properties',
|
||||
description: 'A property',
|
||||
icon: 'IconBuilding',
|
||||
isCustom: false,
|
||||
standardOverrides: undefined,
|
||||
};
|
||||
|
||||
const result = resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toBe('Property');
|
||||
});
|
||||
|
||||
it('should prioritize a workspace translation override over the catalog', () => {
|
||||
const objectMetadata = {
|
||||
labelSingular: 'Property',
|
||||
labelPlural: 'Properties',
|
||||
description: 'A property',
|
||||
icon: 'IconBuilding',
|
||||
isCustom: false,
|
||||
standardOverrides: {
|
||||
translations: {
|
||||
'fr-FR': {
|
||||
labelSingular: 'Bien immobilier',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveObjectMetadataStandardOverride(
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
'fr-FR',
|
||||
mockI18n,
|
||||
false,
|
||||
{ 'app.label.id': 'Bien' },
|
||||
);
|
||||
|
||||
expect(result).toBe('Bien immobilier');
|
||||
expect(mockGenerateMessageId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+9
-10
@@ -3,7 +3,7 @@ import { isNonEmptyString } from '@sniptt/guards';
|
||||
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 { translateStandardLabel } from 'src/engine/core-modules/i18n/utils/translate-standard-label.util';
|
||||
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
|
||||
|
||||
export const resolveObjectMetadataStandardOverride = (
|
||||
@@ -20,10 +20,11 @@ export const resolveObjectMetadataStandardOverride = (
|
||||
locale: keyof typeof APP_LOCALES | undefined,
|
||||
i18nInstance: I18n,
|
||||
isStandardApp: boolean,
|
||||
applicationCatalog?: Record<string, string>,
|
||||
): string => {
|
||||
const safeLocale = locale ?? SOURCE_LOCALE;
|
||||
|
||||
if (!isStandardApp) {
|
||||
if (!isStandardApp && !isDefined(applicationCatalog)) {
|
||||
return objectMetadata[labelKey] ?? '';
|
||||
}
|
||||
|
||||
@@ -51,12 +52,10 @@ export const resolveObjectMetadataStandardOverride = (
|
||||
return objectMetadata.standardOverrides[labelKey] ?? '';
|
||||
}
|
||||
|
||||
const messageId = generateMessageId(objectMetadata[labelKey] ?? '');
|
||||
const translatedMessage = i18nInstance._(messageId);
|
||||
|
||||
if (translatedMessage === messageId) {
|
||||
return objectMetadata[labelKey] ?? '';
|
||||
}
|
||||
|
||||
return translatedMessage;
|
||||
return translateStandardLabel({
|
||||
sourceValue: objectMetadata[labelKey] ?? '',
|
||||
isStandardApp,
|
||||
applicationCatalog,
|
||||
i18nInstance,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -91,7 +91,7 @@ export type {
|
||||
DatabaseEventTriggerSettings,
|
||||
HttpRouteTriggerSettings,
|
||||
} from './logicFunctionManifestType';
|
||||
export type { Manifest } from './manifestType';
|
||||
export type { TranslationsManifest, Manifest } from './manifestType';
|
||||
export type { NavigationMenuItemManifest } from './navigationMenuItemManifestType';
|
||||
export type { OAuthConnectionProviderConfig } from './oauthConnectionProviderConfigType';
|
||||
export type { OAuthProviderTokenRequestContentType } from './oauthProviderTokenRequestContentType.type';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { type AppLocale } from '@/translations';
|
||||
|
||||
import { type AgentManifest } from './agentManifestType';
|
||||
import { type ApplicationManifest } from './applicationType';
|
||||
import { type AssetManifest } from './assetManifestType';
|
||||
@@ -23,6 +25,10 @@ import {
|
||||
type ViewManifest,
|
||||
} from './viewManifestType';
|
||||
|
||||
export type TranslationsManifest = Partial<
|
||||
Record<AppLocale, Record<string, string>>
|
||||
>;
|
||||
|
||||
export type Manifest = {
|
||||
application: ApplicationManifest;
|
||||
objects: ObjectManifest[];
|
||||
@@ -42,4 +48,5 @@ export type Manifest = {
|
||||
pageLayouts: PageLayoutManifest[];
|
||||
pageLayoutTabs: PageLayoutTabManifest[];
|
||||
commandMenuItems: CommandMenuItemManifest[];
|
||||
translations?: TranslationsManifest;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user