Fix zh-CN sidebar translations in minimalMetadata API (#19342)

The minimalMetadata query was returning untranslated English labels for
standard objects (Companies, People, Opportunities, etc.) when users
requested zh-CN locale. This fix adds translation logic to
MinimalMetadataService using the existing I18nService.

Changes:
- Add translateLabel() method to translate standard object labels
- Pass locale from GraphQL context through resolver to service
- Only translate non-custom objects (custom objects use user-defined
labels)

Co-authored-by: Kevin Yu <kevin.yu@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yukuotec 2026-04-06 02:14:20 +08:00 committed by GitHub
parent ed912ec548
commit 265b2582ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 159 additions and 13 deletions

View file

@ -1,7 +1,8 @@
import { UseGuards } from '@nestjs/common';
import { Query } from '@nestjs/graphql';
import { Context, Query } from '@nestjs/graphql';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { type I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
@ -22,10 +23,12 @@ export class MinimalMetadataResolver {
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@Context() context: I18nContext,
): Promise<MinimalMetadataDTO> {
return this.minimalMetadataService.getMinimalMetadata(
workspace.id,
userWorkspaceId,
context.req.locale,
);
}
}

View file

@ -4,15 +4,18 @@ import {
ALL_METADATA_NAME,
type AllMetadataName,
} from 'twenty-shared/metadata';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { ViewVisibility } from 'twenty-shared/types';
import { isDefined, uncapitalize } from 'twenty-shared/utils';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { ALL_FLAT_ENTITY_MAPS_PROPERTIES } from 'src/engine/metadata-modules/flat-entity/constant/all-flat-entity-maps-properties.constant';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type CollectionHashDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/collection-hash.dto';
import { MinimalMetadataDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-metadata.dto';
import { MinimalObjectMetadataDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-object-metadata.dto';
import { MinimalViewDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-view.dto';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
@ -33,11 +36,13 @@ export class MinimalMetadataService {
constructor(
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly i18nService: I18nService,
) {}
async getMinimalMetadata(
workspaceId: string,
userWorkspaceId?: string,
locale?: string,
): Promise<MinimalMetadataDTO> {
const [{ flatObjectMetadataMaps, flatViewMaps }, cacheHashes] =
await Promise.all([
@ -63,23 +68,48 @@ export class MinimalMetadataService {
})
.filter(isDefined);
const safeLocale = (locale as keyof typeof APP_LOCALES) ?? SOURCE_LOCALE;
const i18nInstance = this.i18nService.getI18nInstance(safeLocale);
const objectMetadataItems: MinimalObjectMetadataDTO[] = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((flatObjectMetadata) => flatObjectMetadata.isActive === true)
.map((flatObjectMetadata) => ({
id: flatObjectMetadata.id,
nameSingular: flatObjectMetadata.nameSingular,
namePlural: flatObjectMetadata.namePlural,
labelSingular: flatObjectMetadata.labelSingular,
labelPlural: flatObjectMetadata.labelPlural,
icon: flatObjectMetadata.icon ?? undefined,
isCustom: flatObjectMetadata.isCustom,
isActive: flatObjectMetadata.isActive,
isSystem: flatObjectMetadata.isSystem,
isRemote: flatObjectMetadata.isRemote,
}));
.map((flatObjectMetadata) => {
const objectMetadataForOverride = {
labelPlural: flatObjectMetadata.labelPlural,
labelSingular: flatObjectMetadata.labelSingular,
description: flatObjectMetadata.description ?? undefined,
icon: flatObjectMetadata.icon ?? undefined,
color: flatObjectMetadata.color ?? undefined,
isCustom: flatObjectMetadata.isCustom,
standardOverrides: flatObjectMetadata.standardOverrides ?? undefined,
};
return {
id: flatObjectMetadata.id,
nameSingular: flatObjectMetadata.nameSingular,
namePlural: flatObjectMetadata.namePlural,
labelSingular: resolveObjectMetadataStandardOverride(
objectMetadataForOverride,
'labelSingular',
safeLocale,
i18nInstance,
),
labelPlural: resolveObjectMetadataStandardOverride(
objectMetadataForOverride,
'labelPlural',
safeLocale,
i18nInstance,
),
icon: flatObjectMetadata.icon ?? undefined,
isCustom: flatObjectMetadata.isCustom,
isActive: flatObjectMetadata.isActive,
isSystem: flatObjectMetadata.isSystem,
isRemote: flatObjectMetadata.isRemote,
};
});
const views: MinimalViewDTO[] = Object.values(
flatViewMaps.byUniversalIdentifier,

View file

@ -0,0 +1,113 @@
import request from 'supertest';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
const client = request(`http://localhost:${APP_PORT}`);
const minimalMetadataQuery = {
query: `
query MinimalMetadataI18n {
minimalMetadata {
objectMetadataItems {
nameSingular
labelSingular
labelPlural
isCustom
}
}
}
`,
};
type MinimalObjectItem = {
nameSingular: string;
labelSingular: string;
labelPlural: string;
isCustom: boolean;
};
const updateWorkspaceMemberLocale = async (locale: string) => {
const response = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
mutation UpdateWorkspaceMember(
$workspaceMemberId: UUID!
$data: WorkspaceMemberUpdateInput!
) {
updateWorkspaceMember(id: $workspaceMemberId, data: $data) {
id
locale
}
}
`,
variables: {
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
data: { locale },
},
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateWorkspaceMember.locale).toBe(locale);
};
const queryMinimalMetadata = () =>
client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(minimalMetadataQuery);
const findObjectByName = (
items: MinimalObjectItem[],
nameSingular: string,
): MinimalObjectItem | undefined =>
items.find((item) => item.nameSingular === nameSingular);
describe('minimalMetadata i18n', () => {
afterAll(async () => {
await updateWorkspaceMemberLocale('en');
});
it('should return English labels when user locale is en', async () => {
await updateWorkspaceMemberLocale('en');
const response = await queryMinimalMetadata();
expect(response.body.data).toBeDefined();
expect(response.body.errors).toBeUndefined();
const { objectMetadataItems } = response.body.data.minimalMetadata;
const company = findObjectByName(objectMetadataItems, 'company');
expect(company).toBeDefined();
expect(company!.labelSingular).toBe('Company');
expect(company!.labelPlural).toBe('Companies');
});
it('should return French labels when user locale is fr-FR', async () => {
await updateWorkspaceMemberLocale('fr-FR');
const response = await queryMinimalMetadata();
expect(response.body.data).toBeDefined();
expect(response.body.errors).toBeUndefined();
const { objectMetadataItems } = response.body.data.minimalMetadata;
const company = findObjectByName(objectMetadataItems, 'company');
const person = findObjectByName(objectMetadataItems, 'person');
const opportunity = findObjectByName(objectMetadataItems, 'opportunity');
expect(company).toBeDefined();
expect(company!.labelSingular).toBe('Entreprise');
expect(company!.labelPlural).toBe('Entreprises');
expect(person).toBeDefined();
expect(person!.labelSingular).toBe('Personne');
expect(person!.labelPlural).toBe('Personnes');
expect(opportunity).toBeDefined();
expect(opportunity!.labelSingular).toBe('Opportunité');
expect(opportunity!.labelPlural).toBe('Opportunités');
});
});