From 52cf6f47955bf2b4ed353d25463d180896885d68 Mon Sep 17 00:00:00 2001 From: AFCMS Date: Mon, 24 Mar 2025 20:19:52 +0100 Subject: [PATCH] Allow to edit labels of standard objects (#10922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #10793 This PR is a work in progress. **Still left to fix:** - [x] When disabling synchronization of labels / api names, the edited labels should be set to the English version. Currently the client just send the localized versions together with the `isLabelSyncedWithName` change. Could be an easy fix. - [ ] Sometimes flipping the switch don't trigger the update function, may be a regression as it seems to affect the custom objects too. - [ ] There is a frontend problem where the labels inputs don't reflect the changes made. When enabling back synchronisation after editing labels, they are correctly back to their base values (backend, navigation breadcrumb, etc) but the label inputs still have the old values (switching pages will put them back to normal). I suspect this could be linked to the above problem. - [ ] API names are still displayed for standard objects per (kept them for debugging, trivial fix) - [ ] `SettingsDataModelObjectAboutForm` have a `disableEdition` parameter which is now used only for a few fields, not sure if it's worth keeping because it's a bit misleading since it doesn't "disable" much? - [ ] I don't know what these do, but I have seen "Remote" object types. Not sure if they work with my patch or not (I don't know how to test them) - [ ] Make it work with metadata synchronisation **What should work:** - Disabling synchronization of standard objects should work, label inputs should no longer be disabled - Modifying labels should work - Enabling back synchronization should reset back the labels to the base value and disable the label inputs again (minus the mentioned display bug) - The synchronisation switch should still work as expected for custom objects - Creating custom objects should still work (it uses the same form) --------- Signed-off-by: AFCMS Co-authored-by: Félix Malfait Co-authored-by: Félix Malfait --- .../object-metadata/graphql/mutations.ts | 4 + .../useCreateOneObjectMetadataItem.ts | 1 + .../useDeleteOneObjectMetadataItem.ts | 1 + .../hooks/__mocks__/useFieldMetadataItem.ts | 1 + .../SettingsDataModelFieldIconLabelForm.tsx | 15 +- ...ngsDataModelFieldIconLabelForm.stories.tsx | 4 +- ...SettingsUpdateDataModelObjectAboutForm.tsx | 77 +++-- .../SettingsDataModelObjectAboutForm.tsx | 50 ++- .../data-model/SettingsObjectFieldEdit.tsx | 4 +- .../1742736630054-standardObjectOverwrite.ts | 25 ++ .../field-metadata/dtos/create-field.input.ts | 4 +- .../field-metadata/dtos/field-metadata.dto.ts | 5 + .../dtos/field-standard-overrides.dto.ts | 21 ++ .../field-metadata/dtos/update-field.input.ts | 9 +- .../field-metadata/field-metadata.entity.ts | 6 +- .../field-metadata/field-metadata.module.ts | 2 + .../field-metadata/field-metadata.resolver.ts | 32 +- .../field-metadata/field-metadata.service.ts | 27 +- .../hooks/before-update-one-field.hook.ts | 225 ++++++++++++ .../dtos/object-metadata.dto.ts | 4 + .../dtos/object-standard-overrides.dto.ts | 26 ++ .../hooks/before-update-one-object.hook.ts | 319 +++++++++++++++--- .../object-metadata/object-metadata.entity.ts | 4 + .../object-metadata.resolver.ts | 25 +- .../object-metadata.service.ts | 11 +- 25 files changed, 768 insertions(+), 134 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/metadata/migrations/1742736630054-standardObjectOverwrite.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto.ts 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 cc9f4ec18f..e6d9e69b92 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 d6261902ee..17043afd2e 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 97a2266761..b98c224815 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 6bba8b5d8a..26daaffeca 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 ec5e077c16..1b87c1ac7a 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 f0c6cc65f9..44e32e33f6 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 05914fdffd..bd58b84b1d 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 47b3daff04..6106d2cf73 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 ce43b1ff30..28d2623abb 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 0000000000..042cbf069d --- /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 c74687ae4f..ff14abc36a 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 978b2502f4..2e6c83b548 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 0000000000..474b6ffe46 --- /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 ac81b21723..3592f6cfcf 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 1bde69d8b9..6da683b340 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 154797d9ef..87d9674c6b 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 6ad3d2b0fc..41a4b9bc04 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 443f7a42bd..7eebf5caa6 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 7d3ad1ad22..5be6cc8edd 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 0000000000..7871726404 --- /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 1f092d8251..766ca05b0e 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 1b660c0e1d..1c6a812057 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 43ef57ed6d..8a7a2e80ab 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 929b6bf0e6..3bc068b8cd 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]; }