diff --git a/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts b/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts index cc9f4ec18..e6d9e69b9 100644 --- a/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts +++ b/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts @@ -18,6 +18,7 @@ export const CREATE_ONE_OBJECT_METADATA_ITEM = gql` updatedAt labelIdentifierFieldMetadataId imageIdentifierFieldMetadataId + isLabelSyncedWithName } } `; @@ -39,6 +40,7 @@ export const CREATE_ONE_FIELD_METADATA_ITEM = gql` settings defaultValue options + isLabelSyncedWithName } } `; @@ -104,6 +106,7 @@ export const UPDATE_ONE_OBJECT_METADATA_ITEM = gql` updatedAt labelIdentifierFieldMetadataId imageIdentifierFieldMetadataId + isLabelSyncedWithName } } `; @@ -126,6 +129,7 @@ export const DELETE_ONE_OBJECT_METADATA_ITEM = gql` updatedAt labelIdentifierFieldMetadataId imageIdentifierFieldMetadataId + isLabelSyncedWithName } } `; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts index d6261902e..17043afd2 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts @@ -18,6 +18,7 @@ export const query = gql` updatedAt labelIdentifierFieldMetadataId imageIdentifierFieldMetadataId + isLabelSyncedWithName } } `; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useDeleteOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useDeleteOneObjectMetadataItem.ts index 97a226676..b98c22481 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useDeleteOneObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useDeleteOneObjectMetadataItem.ts @@ -18,6 +18,7 @@ export const query = gql` updatedAt labelIdentifierFieldMetadataId imageIdentifierFieldMetadataId + isLabelSyncedWithName } } `; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts index 6bba8b5d8..26daaffec 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts @@ -113,6 +113,7 @@ export const queries = { settings defaultValue options + isLabelSyncedWithName } } `, diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm.tsx index ec5e077c1..1b87c1ac7 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm.tsx @@ -12,6 +12,7 @@ import { IconPicker } from '@/ui/input/components/IconPicker'; import { TextInput } from '@/ui/input/components/TextInput'; import { useTheme } from '@emotion/react'; import { useLingui } from '@lingui/react/macro'; +import { isDefined } from 'twenty-shared/utils'; import { AppTooltip, Card, @@ -20,7 +21,6 @@ import { TooltipDelay, } from 'twenty-ui'; import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils'; -import { isDefined } from 'twenty-shared/utils'; export const settingsDataModelFieldIconLabelFormSchema = ( existingOtherLabels: string[] = [], @@ -71,7 +71,6 @@ const StyledAdvancedSettingsContainer = styled.div` `; type SettingsDataModelFieldIconLabelFormProps = { - disabled?: boolean; fieldMetadataItem?: FieldMetadataItem; maxLength?: number; canToggleSyncLabelWithName?: boolean; @@ -79,7 +78,6 @@ type SettingsDataModelFieldIconLabelFormProps = { export const SettingsDataModelFieldIconLabelForm = ({ canToggleSyncLabelWithName = true, - disabled, fieldMetadataItem, maxLength, }: SettingsDataModelFieldIconLabelFormProps) => { @@ -107,6 +105,7 @@ export const SettingsDataModelFieldIconLabelForm = ({ const fillNameFromLabel = (label: string) => { isDefined(label) && + fieldMetadataItem?.isCustom && setValue('name', computeMetadataNameFromLabel(label), { shouldDirty: true, }); @@ -121,7 +120,6 @@ export const SettingsDataModelFieldIconLabelForm = ({ defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'} render={({ field: { onChange, value } }) => ( onChange(iconKey)} variant="primary" @@ -143,7 +141,7 @@ export const SettingsDataModelFieldIconLabelForm = ({ } }} error={getErrorMessageFromError(errors.label?.message)} - disabled={disabled} + disabled={isLabelSyncedWithName === true} maxLength={maxLength} fullWidth /> @@ -168,7 +166,8 @@ export const SettingsDataModelFieldIconLabelForm = ({ value={value} onChange={onChange} disabled={ - disabled || (isLabelSyncedWithName ?? false) + (isLabelSyncedWithName ?? false) || + !fieldMetadataItem?.isCustom } fullWidth maxLength={DATABASE_IDENTIFIER_MAXIMUM_LENGTH} @@ -211,10 +210,6 @@ export const SettingsDataModelFieldIconLabelForm = ({ title={t`Synchronize Field Label and API Name`} description={t`Should changing a field's label also change the API name?`} checked={value ?? true} - disabled={ - isDefined(fieldMetadataItem) && - !fieldMetadataItem.isCustom - } advancedMode onChange={(value) => { onChange(value); diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldIconLabelForm.stories.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldIconLabelForm.stories.tsx index f0c6cc65f..44e32e33f 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldIconLabelForm.stories.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldIconLabelForm.stories.tsx @@ -47,9 +47,7 @@ export const WithFieldMetadataItem: Story = { }; export const Disabled: Story = { - args: { - disabled: true, - }, + args: {}, }; export const WithMaxLength: Story = { diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm.tsx index 05914fdff..bd58b84b1 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm.tsx @@ -1,6 +1,5 @@ import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE } from '@/settings/constants/SettingsObjectModel'; import { SettingsDataModelObjectAboutForm } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm'; import { SettingsDataModelObjectAboutFormValues, @@ -15,7 +14,6 @@ import { useSetRecoilState } from 'recoil'; import { ZodError } from 'zod'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { updatedObjectNamePluralState } from '~/pages/settings/data-model/states/updatedObjectNamePluralState'; -import { isDefined } from 'twenty-shared/utils'; type SettingsUpdateDataModelObjectAboutFormProps = { objectMetadataItem: ObjectMetadataItem; @@ -45,9 +43,7 @@ export const SettingsUpdateDataModelObjectAboutForm = ({ defaultValues: { description, icon: icon ?? undefined, - isLabelSyncedWithName: isDefined(isLabelSyncedWithName) - ? isLabelSyncedWithName - : SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE, + isLabelSyncedWithName, labelPlural, labelSingular, namePlural, @@ -68,31 +64,70 @@ export const SettingsUpdateDataModelObjectAboutForm = ({ try { setUpdatedObjectNamePlural(objectNamePluralForRedirection); - await updateOneObjectMetadataItem({ - idToUpdate: objectMetadataItem.id, - updatePayload: formValues, - }); + const updatedObject = await updateObjectMetadata(formValues); - formConfig.reset(undefined, { keepValues: true }); + if (formValues.isLabelSyncedWithName !== isLabelSyncedWithName) { + formConfig.reset({ + description, + icon: icon ?? undefined, + isLabelSyncedWithName: formValues.isLabelSyncedWithName, + labelPlural: updatedObject.data?.updateOneObject.labelPlural, + labelSingular: updatedObject.data?.updateOneObject.labelSingular, + namePlural: updatedObject.data?.updateOneObject.namePlural, + nameSingular: updatedObject.data?.updateOneObject.nameSingular, + }); + } else { + formConfig.reset(undefined, { keepValues: true }); + } navigate(SettingsPath.ObjectDetail, { objectNamePlural: objectNamePluralForRedirection, }); } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - if (error instanceof ZodError) { - enqueueSnackBar(error.issues[0].message, { - variant: SnackBarVariant.Error, - }); - } else { - enqueueSnackBar((error as Error).message, { - variant: SnackBarVariant.Error, - }); - } + handleError(error); } }; + const updateObjectMetadata = async ( + formValues: SettingsDataModelObjectAboutFormValues, + ) => { + const updatePayload = { ...formValues }; + + if (!objectMetadataItem.isCustom) { + const { + nameSingular: _, + namePlural: __, + ...payloadWithoutNames + } = updatePayload; + + return await updateOneObjectMetadataItem({ + idToUpdate: objectMetadataItem.id, + updatePayload: payloadWithoutNames, + }); + } + + return await updateOneObjectMetadataItem({ + idToUpdate: objectMetadataItem.id, + updatePayload, + }); + }; + + const handleError = (error: unknown) => { + // eslint-disable-next-line no-console + console.error(error); + + if (error instanceof ZodError) { + enqueueSnackBar(error.issues[0].message, { + variant: SnackBarVariant.Error, + }); + return; + } + + enqueueSnackBar((error as Error).message, { + variant: SnackBarVariant.Error, + }); + }; + return ( // eslint-disable-next-line react/jsx-props-no-spreading diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx index 47b3daff0..6106d2cf7 100644 --- a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx @@ -1,7 +1,6 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper'; import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; -import { SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE } from '@/settings/constants/SettingsObjectModel'; import { OBJECT_NAME_MAXIMUM_LENGTH } from '@/settings/data-model/constants/ObjectNameMaximumLength'; import { SettingsDataModelObjectAboutFormValues } from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema'; import { IconPicker } from '@/ui/input/components/IconPicker'; @@ -12,6 +11,7 @@ import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; import { plural } from 'pluralize'; import { Controller, useFormContext } from 'react-hook-form'; +import { isDefined } from 'twenty-shared/utils'; import { AppTooltip, Card, @@ -21,7 +21,6 @@ import { } from 'twenty-ui'; import { StringKeyOf } from 'type-fest'; import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils'; -import { isDefined } from 'twenty-shared/utils'; type SettingsDataModelObjectAboutFormProps = { disableEdition?: boolean; @@ -69,8 +68,6 @@ const StyledLabel = styled.span` const infoCircleElementId = 'info-circle-id'; -export const IS_LABEL_SYNCED_WITH_NAME_LABEL = 'isLabelSyncedWithName'; - export const SettingsDataModelObjectAboutForm = ({ disableEdition = false, onNewDirtyField, @@ -81,20 +78,20 @@ export const SettingsDataModelObjectAboutForm = ({ const { t } = useLingui(); const theme = useTheme(); - const isLabelSyncedWithName = - watch(IS_LABEL_SYNCED_WITH_NAME_LABEL) ?? - (isDefined(objectMetadataItem) - ? objectMetadataItem.isLabelSyncedWithName - : SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE); + const isLabelSyncedWithName = watch('isLabelSyncedWithName'); const labelSingular = watch('labelSingular'); const labelPlural = watch('labelPlural'); watch('nameSingular'); watch('namePlural'); watch('description'); watch('icon'); - const apiNameTooltipText = isLabelSyncedWithName - ? t`Deactivate "Synchronize Objects Labels and API Names" to set a custom API name` - : t`Input must be in camel case and cannot start with a number`; + + const apiNameTooltipText = + !isDefined(objectMetadataItem) || objectMetadataItem.isCustom + ? isLabelSyncedWithName + ? t`Deactivate "Synchronize Objects Labels and API Names" to set a custom API name` + : t`Input must be in camel case and cannot start with a number` + : t`Can't change API names for standard objects`; const fillLabelPlural = (labelSingular: string | undefined) => { if (!isDefined(labelSingular)) return; @@ -103,9 +100,6 @@ export const SettingsDataModelObjectAboutForm = ({ setValue('labelPlural', labelPluralFromSingularLabel, { shouldDirty: true, }); - if (isLabelSyncedWithName === true) { - fillNamePluralFromLabelPlural(labelPluralFromSingularLabel); - } }; const fillNameSingularFromLabelSingular = ( @@ -137,7 +131,6 @@ export const SettingsDataModelObjectAboutForm = ({ defaultValue={objectMetadataItem?.icon ?? 'IconListNumbers'} render={({ field: { onChange, value } }) => ( { onChange(iconKey); @@ -168,7 +161,7 @@ export const SettingsDataModelObjectAboutForm = ({ } }} onBlur={() => onNewDirtyField?.()} - disabled={disableEdition} + disabled={!objectMetadataItem?.isCustom && isLabelSyncedWithName} fullWidth maxLength={OBJECT_NAME_MAXIMUM_LENGTH} /> @@ -194,7 +187,7 @@ export const SettingsDataModelObjectAboutForm = ({ } }} onBlur={() => onNewDirtyField?.()} - disabled={disableEdition} + disabled={!objectMetadataItem?.isCustom && isLabelSyncedWithName} fullWidth maxLength={OBJECT_NAME_MAXIMUM_LENGTH} /> @@ -210,7 +203,6 @@ export const SettingsDataModelObjectAboutForm = ({ minRows={4} value={value ?? undefined} onChange={(nextValue) => onChange(nextValue ?? null)} - disabled={disableEdition} onBlur={() => onNewDirtyField?.()} /> )} @@ -303,12 +295,9 @@ export const SettingsDataModelObjectAboutForm = ({ )} ( { onChange(value); - if (value === true) { + onNewDirtyField?.(); + + if ( + value === true && + isDefined(objectMetadataItem) && + objectMetadataItem.isCustom + ) { fillNamePluralFromLabelPlural(labelPlural); fillNameSingularFromLabelSingular(labelSingular); } - onNewDirtyField?.(); }} /> diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx index ce43b1ff3..28d2623ab 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx @@ -34,11 +34,11 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { useLingui } from '@lingui/react/macro'; +import { isDefined } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { useNavigateApp } from '~/hooks/useNavigateApp'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; -import { isDefined } from 'twenty-shared/utils'; //TODO: fix this type type SettingsDataModelFieldEditFormValues = z.infer< @@ -209,7 +209,6 @@ export const SettingsObjectFieldEdit = () => { description={t`The name and icon of this field`} /> { description={t`The description of this field`} /> diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1742736630054-standardObjectOverwrite.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1742736630054-standardObjectOverwrite.ts new file mode 100644 index 000000000..042cbf069 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1742736630054-standardObjectOverwrite.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class StandardObjectOverwrite1742736630054 + implements MigrationInterface +{ + name = 'StandardObjectOverwrite1742736630054'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."fieldMetadata" ADD "standardOverrides" jsonb`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."objectMetadata" ADD "standardOverrides" jsonb`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "metadata"."objectMetadata" DROP COLUMN "standardOverrides"`, + ); + await queryRunner.query( + `ALTER TABLE "metadata"."fieldMetadata" DROP COLUMN "standardOverrides"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/create-field.input.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/create-field.input.ts index c74687ae4..ff14abc36 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/create-field.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/create-field.input.ts @@ -1,14 +1,14 @@ import { Field, InputType, OmitType } from '@nestjs/graphql'; -import { IsOptional, IsUUID, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; +import { IsOptional, IsUUID, ValidateNested } from 'class-validator'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; @InputType() export class CreateFieldInput extends OmitType( FieldMetadataDTO, - ['id', 'createdAt', 'updatedAt'] as const, + ['id', 'createdAt', 'updatedAt', 'standardOverrides'] as const, InputType, ) { @IsUUID() diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts index 978b2502f..2e6c83b54 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts @@ -32,6 +32,7 @@ import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadat import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator'; +import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto'; import { FieldMetadataDefaultOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator'; import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator'; @@ -96,6 +97,10 @@ export class FieldMetadataDTO { @Field({ nullable: true }) icon?: string; + @IsOptional() + @Field(() => FieldStandardOverridesDTO, { nullable: true }) + standardOverrides?: FieldStandardOverridesDTO; + @IsBoolean() @IsOptional() @FilterableField({ nullable: true }) diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto.ts new file mode 100644 index 000000000..474b6ffe4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto.ts @@ -0,0 +1,21 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IsOptional, IsString } from 'class-validator'; + +@ObjectType('StandardOverrides') +export class FieldStandardOverridesDTO { + @IsString() + @IsOptional() + @Field(() => String, { nullable: true }) + label?: string | null; + + @IsString() + @IsOptional() + @Field(() => String, { nullable: true }) + description?: string | null; + + @IsString() + @IsOptional() + @Field(() => String, { nullable: true }) + icon?: string | null; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/update-field.input.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/update-field.input.ts index ac81b2172..3592f6cfc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/update-field.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/update-field.input.ts @@ -15,7 +15,14 @@ import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dto @InputType() export class UpdateFieldInput extends OmitType( PartialType(FieldMetadataDTO, InputType), - ['id', 'type', 'createdAt', 'updatedAt', 'isCustom'] as const, + [ + 'id', + 'type', + 'createdAt', + 'updatedAt', + 'isCustom', + 'standardOverrides', + ] as const, ) { @HideField() id: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts index 1bde69d8b..6da683b34 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts @@ -1,3 +1,4 @@ +import { FieldMetadataType } from 'twenty-shared/types'; import { Column, CreateDateColumn, @@ -12,13 +13,13 @@ import { Unique, UpdateDateColumn, } from 'typeorm'; -import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface'; import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; +import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto'; import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; @@ -76,6 +77,9 @@ export class FieldMetadataEntity< @Column({ nullable: true }) icon: string; + @Column({ type: 'jsonb', nullable: true }) + standardOverrides?: FieldStandardOverridesDTO; + @Column('jsonb', { nullable: true }) options: FieldMetadataOptions; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts index 154797d9e..87d9674c6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts @@ -15,6 +15,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service'; import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadata/field-metadata.resolver'; +import { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook'; import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor'; import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service'; import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service'; @@ -96,6 +97,7 @@ import { UpdateFieldInput } from './dtos/update-field.input'; FieldMetadataRelationService, FieldMetadataRelatedRecordsService, FieldMetadataResolver, + BeforeUpdateOneField, ], exports: [ FieldMetadataService, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts index 6ad3d2b0f..41a4b9bc0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts @@ -24,13 +24,17 @@ import { DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/ import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { RelationDefinitionDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation-definition.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 { + UpdateFieldInput, + UpdateOneFieldMetadataInput, +} from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataException, FieldMetadataExceptionCode, } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service'; +import { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook'; import { fieldMetadataGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/field-metadata/utils/field-metadata-graphql-api-exception-handler.util'; import { SettingsPermissions } from 'src/engine/metadata-modules/permissions/constants/settings-permissions.constants'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; @@ -43,6 +47,7 @@ export class FieldMetadataResolver { constructor( private readonly fieldMetadataService: FieldMetadataService, private readonly featureFlagService: FeatureFlagService, + private readonly beforeUpdateOneField: BeforeUpdateOneField, ) {} @ResolveField(() => String, { nullable: true }) @@ -50,7 +55,7 @@ export class FieldMetadataResolver { @Parent() fieldMetadata: FieldMetadataDTO, @Context() context: I18nContext, ): Promise { - return this.fieldMetadataService.resolveTranslatableString( + return this.fieldMetadataService.resolveOverridableString( fieldMetadata, 'label', context.req.headers['x-locale'], @@ -62,13 +67,25 @@ export class FieldMetadataResolver { @Parent() fieldMetadata: FieldMetadataDTO, @Context() context: I18nContext, ): Promise { - return this.fieldMetadataService.resolveTranslatableString( + return this.fieldMetadataService.resolveOverridableString( fieldMetadata, 'description', context.req.headers['x-locale'], ); } + @ResolveField(() => String, { nullable: true }) + async icon( + @Parent() fieldMetadata: FieldMetadataDTO, + @Context() context: I18nContext, + ): Promise { + return this.fieldMetadataService.resolveOverridableString( + fieldMetadata, + 'icon', + context.req.headers['x-locale'], + ); + } + @UseGuards(SettingsPermissionsGuard(SettingsPermissions.DATA_MODEL)) @Mutation(() => FieldMetadataDTO) async createOneField( @@ -92,8 +109,13 @@ export class FieldMetadataResolver { @AuthWorkspace() { id: workspaceId }: Workspace, ) { try { - return await this.fieldMetadataService.updateOne(input.id, { - ...input.update, + const updatedInput = (await this.beforeUpdateOneField.run( + input, + workspaceId, + )) as UpdateOneFieldMetadataInput; + + return await this.fieldMetadataService.updateOne(updatedInput.id, { + ...updatedInput.update, workspaceId, }); } catch (error) { diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts index 443f7a42b..7eebf5caa 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts @@ -4,11 +4,11 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { i18n } from '@lingui/core'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import isEmpty from 'lodash.isempty'; -import { DataSource, FindOneOptions, In, Repository } from 'typeorm'; -import { v4 as uuidV4, v4 } from 'uuid'; import { APP_LOCALES } from 'twenty-shared/translations'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { DataSource, FindOneOptions, In, Repository } from 'typeorm'; +import { v4 as uuidV4, v4 } from 'uuid'; import { TypeORMService } from 'src/database/typeorm/typeorm.service'; import { settings } from 'src/engine/constants/settings'; @@ -18,6 +18,7 @@ import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-meta import { CreateFieldInput } 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 { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto'; import { RelationDefinitionDTO, RelationDefinitionType, @@ -457,14 +458,23 @@ export class FieldMetadataService extends TypeOrmQueryService { if (fieldMetadata.isCustom) { @@ -612,6 +622,13 @@ export class FieldMetadataService extends TypeOrmQueryService { + standardOverrides?: FieldStandardOverridesDTO; +} + +@Injectable() +export class BeforeUpdateOneField + implements BeforeUpdateOneHook +{ + constructor(readonly fieldMetadataService: FieldMetadataService) {} + + async run( + instance: UpdateOneInputType, + workspaceId: string, + ): Promise> { + if (!workspaceId) { + throw new UnauthorizedException(); + } + + const fieldMetadata = await this.getFieldMetadata(instance, workspaceId); + + if (!fieldMetadata.isCustom) { + return this.handleStandardFieldUpdate(instance, fieldMetadata); + } + + return instance; + } + + private async getFieldMetadata( + instance: UpdateOneInputType, + workspaceId: string, + ) { + const fieldMetadata = + await this.fieldMetadataService.findOneWithinWorkspace(workspaceId, { + where: { + id: instance.id.toString(), + }, + }); + + if (!fieldMetadata) { + throw new BadRequestException('Field does not exist'); + } + + return fieldMetadata; + } + + private handleStandardFieldUpdate( + instance: UpdateOneInputType, + fieldMetadata: FieldMetadataEntity, + ): UpdateOneInputType { + const update: StandardFieldUpdate = {}; + const updatableFields = ['isActive', 'isLabelSyncedWithName']; + const overridableFields = ['label', 'icon', 'description']; + + const hasNonUpdatableFields = Object.keys(instance.update).some( + (key) => + !updatableFields.includes(key) && !overridableFields.includes(key), + ); + + const isUpdatingLabelWhenSynced = + instance.update.label && + fieldMetadata.isLabelSyncedWithName && + instance.update.isLabelSyncedWithName !== false && + instance.update.label !== fieldMetadata.label; + + if (isUpdatingLabelWhenSynced) { + throw new BadRequestException( + 'Cannot update label when it is synced with name', + ); + } + + if (hasNonUpdatableFields) { + throw new BadRequestException( + 'Only isActive, isLabelSyncedWithName, label, icon and description fields can be updated for standard fields', + ); + } + + // Preserve existing overrides + update.standardOverrides = fieldMetadata.standardOverrides + ? { ...fieldMetadata.standardOverrides } + : {}; + + this.handleActiveField(instance, update); + this.handleLabelSyncedWithNameField(instance, update); + this.handleStandardOverrides(instance, fieldMetadata, update); + + return { + id: instance.id, + update: update as T, + }; + } + + private handleActiveField( + instance: UpdateOneInputType, + update: StandardFieldUpdate, + ): void { + if (!isDefined(instance.update.isActive)) { + return; + } + + update.isActive = instance.update.isActive; + } + + private handleLabelSyncedWithNameField( + instance: UpdateOneInputType, + update: StandardFieldUpdate, + ): void { + if (!isDefined(instance.update.isLabelSyncedWithName)) { + return; + } + + update.isLabelSyncedWithName = instance.update.isLabelSyncedWithName; + + if (instance.update.isLabelSyncedWithName === false) { + return; + } + + update.standardOverrides = update.standardOverrides || {}; + update.standardOverrides.label = null; + } + + private handleStandardOverrides( + instance: UpdateOneInputType, + fieldMetadata: FieldMetadataEntity, + update: StandardFieldUpdate, + ): void { + const hasStandardOverrides = + isDefined(instance.update.description) || + isDefined(instance.update.icon) || + isDefined(instance.update.label); + + if (!hasStandardOverrides) { + return; + } + + update.standardOverrides = update.standardOverrides || {}; + + this.handleDescriptionOverride(instance, fieldMetadata, update); + this.handleIconOverride(instance, fieldMetadata, update); + this.handleLabelOverride(instance, fieldMetadata, update); + } + + private handleDescriptionOverride( + instance: UpdateOneInputType, + fieldMetadata: FieldMetadataEntity, + update: StandardFieldUpdate, + ): void { + if (!isDefined(instance.update.description)) { + return; + } + + update.standardOverrides = update.standardOverrides || {}; + + if (instance.update.description === fieldMetadata.description) { + update.standardOverrides.description = null; + + return; + } + + update.standardOverrides.description = instance.update.description; + } + + private handleIconOverride( + instance: UpdateOneInputType, + fieldMetadata: FieldMetadataEntity, + update: StandardFieldUpdate, + ): void { + if (!isDefined(instance.update.icon)) { + return; + } + + update.standardOverrides = update.standardOverrides || {}; + + if (instance.update.icon === fieldMetadata.icon) { + update.standardOverrides.icon = null; + + return; + } + + update.standardOverrides.icon = instance.update.icon; + } + + private handleLabelOverride( + instance: UpdateOneInputType, + fieldMetadata: FieldMetadataEntity, + update: StandardFieldUpdate, + ): void { + if ( + fieldMetadata.isLabelSyncedWithName || + update.isLabelSyncedWithName === true + ) { + return; + } + + if (!isDefined(instance.update.label)) { + return; + } + + update.standardOverrides = update.standardOverrides || {}; + + if (instance.update.label === fieldMetadata.label) { + update.standardOverrides.label = null; + + return; + } + + update.standardOverrides.label = instance.update.label; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto.ts index 7d3ad1ad2..5be6cc8ed 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto.ts @@ -13,6 +13,7 @@ import { WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspa import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto'; +import { ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto'; import { BeforeDeleteOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-delete-one-object.hook'; @ObjectType('Object') @@ -54,6 +55,9 @@ export class ObjectMetadataDTO { @Field({ nullable: true }) icon: string; + @Field(() => ObjectStandardOverridesDTO, { nullable: true }) + standardOverrides?: ObjectStandardOverridesDTO; + @Field({ nullable: true }) shortcut: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto.ts new file mode 100644 index 000000000..787172640 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto.ts @@ -0,0 +1,26 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IsOptional, IsString } from 'class-validator'; + +@ObjectType('ObjectStandardOverrides') +export class ObjectStandardOverridesDTO { + @IsString() + @IsOptional() + @Field(() => String, { nullable: true }) + labelSingular?: string | null; + + @IsString() + @IsOptional() + @Field(() => String, { nullable: true }) + labelPlural?: string | null; + + @IsString() + @IsOptional() + @Field(() => String, { nullable: true }) + description?: string | null; + + @IsString() + @IsOptional() + @Field(() => String, { nullable: true }) + icon?: string | null; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook.ts index 1f092d825..766ca05b0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook.ts @@ -9,12 +9,19 @@ import { BeforeUpdateOneHook, UpdateOneInputType, } from '@ptc-org/nestjs-query-graphql'; +import { isDefined } from 'twenty-shared/utils'; import { Equal, In, Repository } from 'typeorm'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto'; import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; +interface StandardObjectUpdate extends Partial { + standardOverrides?: ObjectStandardOverridesDTO; +} + @Injectable() export class BeforeUpdateOneObject implements BeforeUpdateOneHook @@ -35,6 +42,21 @@ export class BeforeUpdateOneObject throw new UnauthorizedException(); } + const objectMetadata = await this.getObjectMetadata(instance, workspaceId); + + if (!objectMetadata.isCustom) { + return this.handleStandardObjectUpdate(instance, objectMetadata); + } + + await this.validateIdentifierFields(instance, workspaceId); + + return instance; + } + + private async getObjectMetadata( + instance: UpdateOneInputType, + workspaceId: string, + ) { const objectMetadata = await this.objectMetadataService.findOneWithinWorkspace(workspaceId, { where: { @@ -46,58 +68,263 @@ export class BeforeUpdateOneObject throw new BadRequestException('Object does not exist'); } - if (!objectMetadata.isCustom) { - if ( - Object.keys(instance.update).length === 1 && - // eslint-disable-next-line no-prototype-builtins - instance.update.hasOwnProperty('isActive') && - instance.update.isActive !== undefined - ) { - return { - id: instance.id, - update: { - isActive: instance.update.isActive, - } as T, - }; - } + return objectMetadata; + } + private handleStandardObjectUpdate( + instance: UpdateOneInputType, + objectMetadata: ObjectMetadataEntity, + ): UpdateOneInputType { + const update: StandardObjectUpdate = {}; + const updatableFields = ['isActive', 'isLabelSyncedWithName']; + const overridableFields = [ + 'labelSingular', + 'labelPlural', + 'icon', + 'description', + ]; + + // Check if any field is not allowed + const nonUpdatableFields = Object.keys(instance.update).filter( + (key) => + !updatableFields.includes(key) && !overridableFields.includes(key), + ); + + const hasNonUpdatableFields = nonUpdatableFields.length > 0; + + const isUpdatingLabelsWhenSynced = + (instance.update.labelSingular || instance.update.labelPlural) && + objectMetadata.isLabelSyncedWithName && + instance.update.isLabelSyncedWithName !== false && + (instance.update.labelSingular !== objectMetadata.labelSingular || + instance.update.labelPlural !== objectMetadata.labelPlural); + + if (isUpdatingLabelsWhenSynced) { throw new BadRequestException( - 'Only isActive field can be updated for standard objects', + 'Cannot update labels when they are synced with name', ); } - if ( - instance.update.labelIdentifierFieldMetadataId || - instance.update.imageIdentifierFieldMetadataId - ) { - const fields = await this.fieldMetadataRepository.findBy({ - workspaceId: Equal(workspaceId), - objectMetadataId: Equal(instance.id.toString()), - id: In( - [ - instance.update.labelIdentifierFieldMetadataId, - instance.update.imageIdentifierFieldMetadataId, - ].filter((id) => id !== null), - ), - }); - - const fieldIds = fields.map((field) => field.id); - - if ( - instance.update.labelIdentifierFieldMetadataId && - !fieldIds.includes(instance.update.labelIdentifierFieldMetadataId) - ) { - throw new BadRequestException('This label identifier does not exist'); - } - - if ( - instance.update.imageIdentifierFieldMetadataId && - !fieldIds.includes(instance.update.imageIdentifierFieldMetadataId) - ) { - throw new BadRequestException('This image identifier does not exist'); - } + if (hasNonUpdatableFields) { + throw new BadRequestException( + `Only isActive, isLabelSyncedWithName, labelSingular, labelPlural, icon and description fields can be updated for standard objects. Disallowed fields: ${nonUpdatableFields.join(', ')}`, + ); } - return instance; + // preserve existing overrides + update.standardOverrides = objectMetadata.standardOverrides + ? { ...objectMetadata.standardOverrides } + : {}; + + this.handleActiveField(instance, update); + this.handleLabelSyncedWithNameField(instance, update); + this.handleStandardOverrides(instance, objectMetadata, update); + + return { + id: instance.id, + update: update as T, + }; + } + + private handleActiveField( + instance: UpdateOneInputType, + update: StandardObjectUpdate, + ): void { + if (!isDefined(instance.update.isActive)) { + return; + } + + update.isActive = instance.update.isActive; + } + + private handleLabelSyncedWithNameField( + instance: UpdateOneInputType, + update: StandardObjectUpdate, + ): void { + if (!isDefined(instance.update.isLabelSyncedWithName)) { + return; + } + + update.isLabelSyncedWithName = instance.update.isLabelSyncedWithName; + + if (instance.update.isLabelSyncedWithName === false) { + return; + } + + // If setting isLabelSyncedWithName to true, clear label overrides + update.standardOverrides = update.standardOverrides || {}; + update.standardOverrides.labelSingular = null; + update.standardOverrides.labelPlural = null; + } + + private handleStandardOverrides( + instance: UpdateOneInputType, + objectMetadata: ObjectMetadataEntity, + update: StandardObjectUpdate, + ): void { + const hasStandardOverrides = + isDefined(instance.update.description) || + isDefined(instance.update.icon) || + isDefined(instance.update.labelSingular) || + isDefined(instance.update.labelPlural); + + if (!hasStandardOverrides) { + return; + } + + update.standardOverrides = update.standardOverrides || {}; + + this.handleDescriptionOverride(instance, objectMetadata, update); + this.handleIconOverride(instance, objectMetadata, update); + this.handleLabelOverrides(instance, objectMetadata, update); + } + + private handleDescriptionOverride( + instance: UpdateOneInputType, + objectMetadata: ObjectMetadataEntity, + update: StandardObjectUpdate, + ): void { + if (!isDefined(instance.update.description)) { + return; + } + + update.standardOverrides = update.standardOverrides || {}; + + if (instance.update.description === objectMetadata.description) { + update.standardOverrides.description = null; + + return; + } + + update.standardOverrides.description = instance.update.description; + } + + private handleIconOverride( + instance: UpdateOneInputType, + objectMetadata: ObjectMetadataEntity, + update: StandardObjectUpdate, + ): void { + if (!isDefined(instance.update.icon)) { + return; + } + + update.standardOverrides = update.standardOverrides || {}; + + if (instance.update.icon === objectMetadata.icon) { + update.standardOverrides.icon = null; + + return; + } + + update.standardOverrides.icon = instance.update.icon; + } + + private handleLabelOverrides( + instance: UpdateOneInputType, + objectMetadata: ObjectMetadataEntity, + update: StandardObjectUpdate, + ): void { + // Skip label updates if labels are synced with name or will be synced + if ( + objectMetadata.isLabelSyncedWithName || + update.isLabelSyncedWithName === true + ) { + return; + } + + this.handleLabelSingularOverride(instance, objectMetadata, update); + this.handleLabelPluralOverride(instance, objectMetadata, update); + } + + private handleLabelSingularOverride( + instance: UpdateOneInputType, + objectMetadata: ObjectMetadataEntity, + update: StandardObjectUpdate, + ): void { + if (!isDefined(instance.update.labelSingular)) { + return; + } + + update.standardOverrides = update.standardOverrides || {}; + + if (instance.update.labelSingular === objectMetadata.labelSingular) { + update.standardOverrides.labelSingular = null; + + return; + } + + update.standardOverrides.labelSingular = instance.update.labelSingular; + } + + private handleLabelPluralOverride( + instance: UpdateOneInputType, + objectMetadata: ObjectMetadataEntity, + update: StandardObjectUpdate, + ): void { + if (!isDefined(instance.update.labelPlural)) { + return; + } + + update.standardOverrides = update.standardOverrides || {}; + + if (instance.update.labelPlural === objectMetadata.labelPlural) { + update.standardOverrides.labelPlural = null; + + return; + } + + update.standardOverrides.labelPlural = instance.update.labelPlural; + } + + private async validateIdentifierFields( + instance: UpdateOneInputType, + workspaceId: string, + ): Promise { + if ( + !instance.update.labelIdentifierFieldMetadataId && + !instance.update.imageIdentifierFieldMetadataId + ) { + return; + } + + const fields = await this.fieldMetadataRepository.findBy({ + workspaceId: Equal(workspaceId), + objectMetadataId: Equal(instance.id.toString()), + id: In( + [ + instance.update.labelIdentifierFieldMetadataId, + instance.update.imageIdentifierFieldMetadataId, + ].filter((id) => id !== null), + ), + }); + + const fieldIds = fields.map((field) => field.id); + + this.validateLabelIdentifier(instance, fieldIds); + this.validateImageIdentifier(instance, fieldIds); + } + + private validateLabelIdentifier( + instance: UpdateOneInputType, + fieldIds: string[], + ): void { + if ( + instance.update.labelIdentifierFieldMetadataId && + !fieldIds.includes(instance.update.labelIdentifierFieldMetadataId) + ) { + throw new BadRequestException('This label identifier does not exist'); + } + } + + private validateImageIdentifier( + instance: UpdateOneInputType, + fieldIds: string[], + ): void { + if ( + instance.update.imageIdentifierFieldMetadataId && + !fieldIds.includes(instance.update.imageIdentifierFieldMetadataId) + ) { + throw new BadRequestException('This image identifier does not exist'); + } } } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts index 1b660c0e1..1c6a81205 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts @@ -16,6 +16,7 @@ import { WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspa import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto'; import { ObjectPermissionsEntity } from 'src/engine/metadata-modules/object-permissions/object-permissions.entity'; import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; @@ -53,6 +54,9 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface { @Column({ nullable: true }) icon: string; + @Column({ type: 'jsonb', nullable: true }) + standardOverrides?: ObjectStandardOverridesDTO; + @Column({ nullable: false }) targetTableName: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts index 43ef57ed6..8a7a2e80a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.resolver.ts @@ -41,7 +41,7 @@ export class ObjectMetadataResolver { @Parent() objectMetadata: ObjectMetadataDTO, @Context() context: I18nContext, ): Promise { - return this.objectMetadataService.resolveTranslatableString( + return this.objectMetadataService.resolveOverridableString( objectMetadata, 'labelPlural', context.req.headers['x-locale'], @@ -53,7 +53,7 @@ export class ObjectMetadataResolver { @Parent() objectMetadata: ObjectMetadataDTO, @Context() context: I18nContext, ): Promise { - return this.objectMetadataService.resolveTranslatableString( + return this.objectMetadataService.resolveOverridableString( objectMetadata, 'labelSingular', context.req.headers['x-locale'], @@ -65,13 +65,25 @@ export class ObjectMetadataResolver { @Parent() objectMetadata: ObjectMetadataDTO, @Context() context: I18nContext, ): Promise { - return this.objectMetadataService.resolveTranslatableString( + return this.objectMetadataService.resolveOverridableString( objectMetadata, 'description', context.req.headers['x-locale'], ); } + @ResolveField(() => String, { nullable: true }) + async icon( + @Parent() objectMetadata: ObjectMetadataDTO, + @Context() context: I18nContext, + ): Promise { + return this.objectMetadataService.resolveOverridableString( + objectMetadata, + 'icon', + context.req.headers['x-locale'], + ); + } + @UseGuards(SettingsPermissionsGuard(SettingsPermissions.DATA_MODEL)) @Mutation(() => ObjectMetadataDTO) async deleteOneObject( @@ -95,10 +107,13 @@ export class ObjectMetadataResolver { @AuthWorkspace() { id: workspaceId }: Workspace, ) { try { - await this.beforeUpdateOneObject.run(input, workspaceId); + const updatedInput = (await this.beforeUpdateOneObject.run( + input, + workspaceId, + )) as UpdateOneObjectInput; return await this.objectMetadataService.updateOneObject( - input, + updatedInput, workspaceId, ); } catch (error) { diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index 929b6bf0e..3bc068b8c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -552,15 +552,22 @@ export class ObjectMetadataService extends TypeOrmQueryService { if (objectMetadata.isCustom) { return objectMetadata[labelKey]; } + if ( + objectMetadata.standardOverrides && + isDefined(objectMetadata.standardOverrides[labelKey]) + ) { + return objectMetadata.standardOverrides[labelKey] as string; + } + if (!locale) { return objectMetadata[labelKey]; }