Files
twenty/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts
T
Félix Malfait b1a781fbd9 feat(server): resolve app translations across remaining metadata resolvers (#22237)
## Summary

**PR 3/4** of the app-metadata-translations stack. Extends runtime
translation resolution to the remaining Twenty-rendered metadata types
so coverage is complete.

- New shared `MetadataTranslationResolverService.getApplicationCatalog({
applicationId, workspaceId, locale })` — the single seam for fetching an
app's per-locale catalog.
- Wired into the **page-layout tab**, **page-layout widget**,
**view-field-group**, **command-menu navigation item**, and **view
name** resolvers, each extended with an optional `applicationCatalog`
param (backward-compatible).

Together with PR 1/4 (object + field), this covers all seven
translatable metadata surfaces.

## Stack
Stacks on #22236 (PR 2/4). Base branch:
`claude/app-translation-2-sdk-manifest`.

## Verification note
`yarn install` could not complete in the remote dev environment, so
typecheck/lint/tests were not run locally — **CI is the source of
truth**.

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/22237?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. -->
2026-06-29 22:49:36 +02:00

231 lines
8.5 KiB
TypeScript

import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Context, Mutation, Parent, ResolveField } 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 { 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';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateOneFieldMetadataInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
import { UpdateOneFieldMetadataInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { fieldMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/field-metadata/utils/field-metadata-graphql-api-exception-handler.util';
import { resolveFieldMetadataStandardOverride } from 'src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util';
import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
// Keep @Parent() structurally typed so ResolverValidationPipe does not validate
// FieldMetadataDTO date decorators on already-loaded parent records.
type FieldMetadataStandardOverrideParent = Parameters<
typeof resolveFieldMetadataStandardOverride
>[0] &
Pick<FieldMetadataDTO, 'applicationId'>;
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
@MetadataResolver(() => FieldMetadataDTO)
@UseFilters(
PermissionsGraphqlApiExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
export class FieldMetadataResolver {
constructor(
private readonly fieldMetadataService: FieldMetadataService,
private readonly i18nService: I18nService,
) {}
@ResolveField(() => Boolean, {
nullable: true,
deprecationReason: 'Use isUIEditable',
})
async isUIReadOnly(
@Parent() fieldMetadata: Pick<FieldMetadataDTO, 'isUIEditable'>,
): Promise<boolean> {
return !(fieldMetadata.isUIEditable ?? true);
}
private async resolveStandardOverride(
fieldMetadata: FieldMetadataStandardOverrideParent,
labelKey: 'label' | 'description' | 'icon',
context: { loaders: IDataloaders } & I18nContext,
workspaceId: string,
): Promise<string> {
const i18n = this.i18nService.getI18nInstance(context.req.locale);
const standardApplicationId =
await context.loaders.standardApplicationIdLoader.load({ workspaceId });
const isStandardApp = fieldMetadata.applicationId === standardApplicationId;
const applicationCatalog =
await context.loaders.applicationTranslationCatalogLoader.load({
applicationId: fieldMetadata.applicationId,
workspaceId,
locale: context.req.locale,
});
return resolveFieldMetadataStandardOverride(
fieldMetadata,
labelKey,
context.req.locale,
i18n,
isStandardApp,
applicationCatalog,
);
}
@ResolveField(() => String, { nullable: true })
async label(
@Parent() fieldMetadata: FieldMetadataStandardOverrideParent,
@Context() context: { loaders: IDataloaders } & I18nContext,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<string> {
return this.resolveStandardOverride(
fieldMetadata,
'label',
context,
workspaceId,
);
}
@ResolveField(() => String, { nullable: true })
async description(
@Parent() fieldMetadata: FieldMetadataStandardOverrideParent,
@Context() context: { loaders: IDataloaders } & I18nContext,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<string> {
return this.resolveStandardOverride(
fieldMetadata,
'description',
context,
workspaceId,
);
}
@ResolveField(() => String, { nullable: true })
async icon(
@Parent() fieldMetadata: FieldMetadataStandardOverrideParent,
@Context() context: { loaders: IDataloaders } & I18nContext,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<string> {
return this.resolveStandardOverride(
fieldMetadata,
'icon',
context,
workspaceId,
);
}
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Mutation(() => FieldMetadataDTO)
async createOneField(
@Args('input') input: CreateOneFieldMetadataInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
try {
const flatFieldMetadata = await this.fieldMetadataService.createOneField({
createFieldInput: input.field,
workspaceId,
});
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
} catch (error) {
return fieldMetadataGraphqlApiExceptionHandler(error);
}
}
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Mutation(() => FieldMetadataDTO)
async updateOneField(
@Args('input') input: UpdateOneFieldMetadataInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
try {
const flatFieldMetadata = await this.fieldMetadataService.updateOneField({
updateFieldInput: { ...input.update, id: input.id },
workspaceId,
});
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
} catch (error) {
fieldMetadataGraphqlApiExceptionHandler(error);
}
}
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Mutation(() => FieldMetadataDTO)
async deleteOneField(
@Args('input') deleteOneFieldInput: DeleteOneFieldInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
if (!isDefined(workspaceId)) {
throw new ForbiddenError('Could not retrieve workspace ID');
}
try {
const flatFieldMetadata = await this.fieldMetadataService.deleteOneField({
deleteOneFieldInput,
workspaceId,
});
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
} catch (error) {
fieldMetadataGraphqlApiExceptionHandler(error);
}
}
@ResolveField(() => RelationDTO, { nullable: true })
async relation(
@AuthWorkspace() workspace: WorkspaceEntity,
@Parent()
{
id: fieldMetadataId,
objectMetadataId,
}: Pick<FieldMetadataDTO, 'id' | 'objectMetadataId'>,
@Context() context: { loaders: IDataloaders },
): Promise<RelationDTO | null> {
try {
return await context.loaders.relationLoader.load({
fieldMetadataId,
objectMetadataId,
workspaceId: workspace.id,
});
} catch (error) {
return fieldMetadataGraphqlApiExceptionHandler(error);
}
}
@ResolveField(() => [RelationDTO], { nullable: true })
async morphRelations(
@AuthWorkspace() workspace: WorkspaceEntity,
@Parent()
{
id: fieldMetadataId,
objectMetadataId,
}: Pick<FieldMetadataDTO, 'id' | 'objectMetadataId'>,
@Context() context: { loaders: IDataloaders },
): Promise<RelationDTO[] | null> {
try {
return await context.loaders.morphRelationLoader.load({
fieldMetadataId,
objectMetadataId,
workspaceId: workspace.id,
});
} catch (error) {
return fieldMetadataGraphqlApiExceptionHandler(error);
}
}
}