Allow to edit labels of standard objects (#10922)

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 <afcm.contact@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
AFCMS
2025-03-24 20:19:52 +01:00
committed by GitHub
parent bc1b55ddc3
commit 52cf6f4795
25 changed files with 768 additions and 134 deletions

View File

@ -18,6 +18,7 @@ export const CREATE_ONE_OBJECT_METADATA_ITEM = gql`
updatedAt updatedAt
labelIdentifierFieldMetadataId labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId imageIdentifierFieldMetadataId
isLabelSyncedWithName
} }
} }
`; `;
@ -39,6 +40,7 @@ export const CREATE_ONE_FIELD_METADATA_ITEM = gql`
settings settings
defaultValue defaultValue
options options
isLabelSyncedWithName
} }
} }
`; `;
@ -104,6 +106,7 @@ export const UPDATE_ONE_OBJECT_METADATA_ITEM = gql`
updatedAt updatedAt
labelIdentifierFieldMetadataId labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId imageIdentifierFieldMetadataId
isLabelSyncedWithName
} }
} }
`; `;
@ -126,6 +129,7 @@ export const DELETE_ONE_OBJECT_METADATA_ITEM = gql`
updatedAt updatedAt
labelIdentifierFieldMetadataId labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId imageIdentifierFieldMetadataId
isLabelSyncedWithName
} }
} }
`; `;

View File

@ -18,6 +18,7 @@ export const query = gql`
updatedAt updatedAt
labelIdentifierFieldMetadataId labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId imageIdentifierFieldMetadataId
isLabelSyncedWithName
} }
} }
`; `;

View File

@ -18,6 +18,7 @@ export const query = gql`
updatedAt updatedAt
labelIdentifierFieldMetadataId labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId imageIdentifierFieldMetadataId
isLabelSyncedWithName
} }
} }
`; `;

View File

@ -113,6 +113,7 @@ export const queries = {
settings settings
defaultValue defaultValue
options options
isLabelSyncedWithName
} }
} }
`, `,

View File

@ -12,6 +12,7 @@ import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { import {
AppTooltip, AppTooltip,
Card, Card,
@ -20,7 +21,6 @@ import {
TooltipDelay, TooltipDelay,
} from 'twenty-ui'; } from 'twenty-ui';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils'; import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
import { isDefined } from 'twenty-shared/utils';
export const settingsDataModelFieldIconLabelFormSchema = ( export const settingsDataModelFieldIconLabelFormSchema = (
existingOtherLabels: string[] = [], existingOtherLabels: string[] = [],
@ -71,7 +71,6 @@ const StyledAdvancedSettingsContainer = styled.div`
`; `;
type SettingsDataModelFieldIconLabelFormProps = { type SettingsDataModelFieldIconLabelFormProps = {
disabled?: boolean;
fieldMetadataItem?: FieldMetadataItem; fieldMetadataItem?: FieldMetadataItem;
maxLength?: number; maxLength?: number;
canToggleSyncLabelWithName?: boolean; canToggleSyncLabelWithName?: boolean;
@ -79,7 +78,6 @@ type SettingsDataModelFieldIconLabelFormProps = {
export const SettingsDataModelFieldIconLabelForm = ({ export const SettingsDataModelFieldIconLabelForm = ({
canToggleSyncLabelWithName = true, canToggleSyncLabelWithName = true,
disabled,
fieldMetadataItem, fieldMetadataItem,
maxLength, maxLength,
}: SettingsDataModelFieldIconLabelFormProps) => { }: SettingsDataModelFieldIconLabelFormProps) => {
@ -107,6 +105,7 @@ export const SettingsDataModelFieldIconLabelForm = ({
const fillNameFromLabel = (label: string) => { const fillNameFromLabel = (label: string) => {
isDefined(label) && isDefined(label) &&
fieldMetadataItem?.isCustom &&
setValue('name', computeMetadataNameFromLabel(label), { setValue('name', computeMetadataNameFromLabel(label), {
shouldDirty: true, shouldDirty: true,
}); });
@ -121,7 +120,6 @@ export const SettingsDataModelFieldIconLabelForm = ({
defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'} defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<IconPicker <IconPicker
disabled={disabled}
selectedIconKey={value ?? ''} selectedIconKey={value ?? ''}
onChange={({ iconKey }) => onChange(iconKey)} onChange={({ iconKey }) => onChange(iconKey)}
variant="primary" variant="primary"
@ -143,7 +141,7 @@ export const SettingsDataModelFieldIconLabelForm = ({
} }
}} }}
error={getErrorMessageFromError(errors.label?.message)} error={getErrorMessageFromError(errors.label?.message)}
disabled={disabled} disabled={isLabelSyncedWithName === true}
maxLength={maxLength} maxLength={maxLength}
fullWidth fullWidth
/> />
@ -168,7 +166,8 @@ export const SettingsDataModelFieldIconLabelForm = ({
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={ disabled={
disabled || (isLabelSyncedWithName ?? false) (isLabelSyncedWithName ?? false) ||
!fieldMetadataItem?.isCustom
} }
fullWidth fullWidth
maxLength={DATABASE_IDENTIFIER_MAXIMUM_LENGTH} maxLength={DATABASE_IDENTIFIER_MAXIMUM_LENGTH}
@ -211,10 +210,6 @@ export const SettingsDataModelFieldIconLabelForm = ({
title={t`Synchronize Field Label and API Name`} title={t`Synchronize Field Label and API Name`}
description={t`Should changing a field's label also change the API name?`} description={t`Should changing a field's label also change the API name?`}
checked={value ?? true} checked={value ?? true}
disabled={
isDefined(fieldMetadataItem) &&
!fieldMetadataItem.isCustom
}
advancedMode advancedMode
onChange={(value) => { onChange={(value) => {
onChange(value); onChange(value);

View File

@ -47,9 +47,7 @@ export const WithFieldMetadataItem: Story = {
}; };
export const Disabled: Story = { export const Disabled: Story = {
args: { args: {},
disabled: true,
},
}; };
export const WithMaxLength: Story = { export const WithMaxLength: Story = {

View File

@ -1,6 +1,5 @@
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem'; import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; 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 { SettingsDataModelObjectAboutForm } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
import { import {
SettingsDataModelObjectAboutFormValues, SettingsDataModelObjectAboutFormValues,
@ -15,7 +14,6 @@ import { useSetRecoilState } from 'recoil';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { updatedObjectNamePluralState } from '~/pages/settings/data-model/states/updatedObjectNamePluralState'; import { updatedObjectNamePluralState } from '~/pages/settings/data-model/states/updatedObjectNamePluralState';
import { isDefined } from 'twenty-shared/utils';
type SettingsUpdateDataModelObjectAboutFormProps = { type SettingsUpdateDataModelObjectAboutFormProps = {
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
@ -45,9 +43,7 @@ export const SettingsUpdateDataModelObjectAboutForm = ({
defaultValues: { defaultValues: {
description, description,
icon: icon ?? undefined, icon: icon ?? undefined,
isLabelSyncedWithName: isDefined(isLabelSyncedWithName) isLabelSyncedWithName,
? isLabelSyncedWithName
: SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE,
labelPlural, labelPlural,
labelSingular, labelSingular,
namePlural, namePlural,
@ -68,31 +64,70 @@ export const SettingsUpdateDataModelObjectAboutForm = ({
try { try {
setUpdatedObjectNamePlural(objectNamePluralForRedirection); setUpdatedObjectNamePlural(objectNamePluralForRedirection);
await updateOneObjectMetadataItem({ const updatedObject = await updateObjectMetadata(formValues);
idToUpdate: objectMetadataItem.id,
updatePayload: 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, { navigate(SettingsPath.ObjectDetail, {
objectNamePlural: objectNamePluralForRedirection, objectNamePlural: objectNamePluralForRedirection,
}); });
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console handleError(error);
console.error(error);
if (error instanceof ZodError) {
enqueueSnackBar(error.issues[0].message, {
variant: SnackBarVariant.Error,
});
} else {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.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 ( return (
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
<FormProvider {...formConfig}> <FormProvider {...formConfig}>

View File

@ -1,7 +1,6 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper'; import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; 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 { OBJECT_NAME_MAXIMUM_LENGTH } from '@/settings/data-model/constants/ObjectNameMaximumLength';
import { SettingsDataModelObjectAboutFormValues } from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema'; import { SettingsDataModelObjectAboutFormValues } from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
import { IconPicker } from '@/ui/input/components/IconPicker'; import { IconPicker } from '@/ui/input/components/IconPicker';
@ -12,6 +11,7 @@ import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { plural } from 'pluralize'; import { plural } from 'pluralize';
import { Controller, useFormContext } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form';
import { isDefined } from 'twenty-shared/utils';
import { import {
AppTooltip, AppTooltip,
Card, Card,
@ -21,7 +21,6 @@ import {
} from 'twenty-ui'; } from 'twenty-ui';
import { StringKeyOf } from 'type-fest'; import { StringKeyOf } from 'type-fest';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils'; import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
import { isDefined } from 'twenty-shared/utils';
type SettingsDataModelObjectAboutFormProps = { type SettingsDataModelObjectAboutFormProps = {
disableEdition?: boolean; disableEdition?: boolean;
@ -69,8 +68,6 @@ const StyledLabel = styled.span`
const infoCircleElementId = 'info-circle-id'; const infoCircleElementId = 'info-circle-id';
export const IS_LABEL_SYNCED_WITH_NAME_LABEL = 'isLabelSyncedWithName';
export const SettingsDataModelObjectAboutForm = ({ export const SettingsDataModelObjectAboutForm = ({
disableEdition = false, disableEdition = false,
onNewDirtyField, onNewDirtyField,
@ -81,20 +78,20 @@ export const SettingsDataModelObjectAboutForm = ({
const { t } = useLingui(); const { t } = useLingui();
const theme = useTheme(); const theme = useTheme();
const isLabelSyncedWithName = const isLabelSyncedWithName = watch('isLabelSyncedWithName');
watch(IS_LABEL_SYNCED_WITH_NAME_LABEL) ??
(isDefined(objectMetadataItem)
? objectMetadataItem.isLabelSyncedWithName
: SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE);
const labelSingular = watch('labelSingular'); const labelSingular = watch('labelSingular');
const labelPlural = watch('labelPlural'); const labelPlural = watch('labelPlural');
watch('nameSingular'); watch('nameSingular');
watch('namePlural'); watch('namePlural');
watch('description'); watch('description');
watch('icon'); watch('icon');
const apiNameTooltipText = isLabelSyncedWithName
? t`Deactivate "Synchronize Objects Labels and API Names" to set a custom API name` const apiNameTooltipText =
: t`Input must be in camel case and cannot start with a number`; !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) => { const fillLabelPlural = (labelSingular: string | undefined) => {
if (!isDefined(labelSingular)) return; if (!isDefined(labelSingular)) return;
@ -103,9 +100,6 @@ export const SettingsDataModelObjectAboutForm = ({
setValue('labelPlural', labelPluralFromSingularLabel, { setValue('labelPlural', labelPluralFromSingularLabel, {
shouldDirty: true, shouldDirty: true,
}); });
if (isLabelSyncedWithName === true) {
fillNamePluralFromLabelPlural(labelPluralFromSingularLabel);
}
}; };
const fillNameSingularFromLabelSingular = ( const fillNameSingularFromLabelSingular = (
@ -137,7 +131,6 @@ export const SettingsDataModelObjectAboutForm = ({
defaultValue={objectMetadataItem?.icon ?? 'IconListNumbers'} defaultValue={objectMetadataItem?.icon ?? 'IconListNumbers'}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<IconPicker <IconPicker
disabled={disableEdition}
selectedIconKey={value} selectedIconKey={value}
onChange={({ iconKey }) => { onChange={({ iconKey }) => {
onChange(iconKey); onChange(iconKey);
@ -168,7 +161,7 @@ export const SettingsDataModelObjectAboutForm = ({
} }
}} }}
onBlur={() => onNewDirtyField?.()} onBlur={() => onNewDirtyField?.()}
disabled={disableEdition} disabled={!objectMetadataItem?.isCustom && isLabelSyncedWithName}
fullWidth fullWidth
maxLength={OBJECT_NAME_MAXIMUM_LENGTH} maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
/> />
@ -194,7 +187,7 @@ export const SettingsDataModelObjectAboutForm = ({
} }
}} }}
onBlur={() => onNewDirtyField?.()} onBlur={() => onNewDirtyField?.()}
disabled={disableEdition} disabled={!objectMetadataItem?.isCustom && isLabelSyncedWithName}
fullWidth fullWidth
maxLength={OBJECT_NAME_MAXIMUM_LENGTH} maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
/> />
@ -210,7 +203,6 @@ export const SettingsDataModelObjectAboutForm = ({
minRows={4} minRows={4}
value={value ?? undefined} value={value ?? undefined}
onChange={(nextValue) => onChange(nextValue ?? null)} onChange={(nextValue) => onChange(nextValue ?? null)}
disabled={disableEdition}
onBlur={() => onNewDirtyField?.()} onBlur={() => onNewDirtyField?.()}
/> />
)} )}
@ -303,12 +295,9 @@ export const SettingsDataModelObjectAboutForm = ({
)} )}
<AdvancedSettingsWrapper> <AdvancedSettingsWrapper>
<Controller <Controller
name={IS_LABEL_SYNCED_WITH_NAME_LABEL} name="isLabelSyncedWithName"
control={control} control={control}
defaultValue={ defaultValue={objectMetadataItem?.isLabelSyncedWithName}
objectMetadataItem?.isLabelSyncedWithName ??
SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE
}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<Card rounded> <Card rounded>
<SettingsOptionCardContentToggle <SettingsOptionCardContentToggle
@ -316,18 +305,19 @@ export const SettingsDataModelObjectAboutForm = ({
title={t`Synchronize Objects Labels and API Names`} title={t`Synchronize Objects Labels and API Names`}
description={t`Should changing an object's label also change the API?`} description={t`Should changing an object's label also change the API?`}
checked={value ?? true} checked={value ?? true}
disabled={
isDefined(objectMetadataItem) &&
!objectMetadataItem.isCustom
}
advancedMode advancedMode
onChange={(value) => { onChange={(value) => {
onChange(value); onChange(value);
if (value === true) { onNewDirtyField?.();
if (
value === true &&
isDefined(objectMetadataItem) &&
objectMetadataItem.isCustom
) {
fillNamePluralFromLabelPlural(labelPlural); fillNamePluralFromLabelPlural(labelPlural);
fillNameSingularFromLabelSingular(labelSingular); fillNameSingularFromLabelSingular(labelSingular);
} }
onNewDirtyField?.();
}} }}
/> />
</Card> </Card>

View File

@ -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 { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp'; import { useNavigateApp } from '~/hooks/useNavigateApp';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { isDefined } from 'twenty-shared/utils';
//TODO: fix this type //TODO: fix this type
type SettingsDataModelFieldEditFormValues = z.infer< type SettingsDataModelFieldEditFormValues = z.infer<
@ -209,7 +209,6 @@ export const SettingsObjectFieldEdit = () => {
description={t`The name and icon of this field`} description={t`The name and icon of this field`}
/> />
<SettingsDataModelFieldIconLabelForm <SettingsDataModelFieldIconLabelForm
disabled={!fieldMetadataItem.isCustom}
fieldMetadataItem={fieldMetadataItem} fieldMetadataItem={fieldMetadataItem}
maxLength={FIELD_NAME_MAXIMUM_LENGTH} maxLength={FIELD_NAME_MAXIMUM_LENGTH}
canToggleSyncLabelWithName={ canToggleSyncLabelWithName={
@ -240,7 +239,6 @@ export const SettingsObjectFieldEdit = () => {
description={t`The description of this field`} description={t`The description of this field`}
/> />
<SettingsDataModelFieldDescriptionForm <SettingsDataModelFieldDescriptionForm
disabled={!fieldMetadataItem.isCustom}
fieldMetadataItem={fieldMetadataItem} fieldMetadataItem={fieldMetadataItem}
/> />
</Section> </Section>

View File

@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class StandardObjectOverwrite1742736630054
implements MigrationInterface
{
name = 'StandardObjectOverwrite1742736630054';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."objectMetadata" DROP COLUMN "standardOverrides"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."fieldMetadata" DROP COLUMN "standardOverrides"`,
);
}
}

View File

@ -1,14 +1,14 @@
import { Field, InputType, OmitType } from '@nestjs/graphql'; import { Field, InputType, OmitType } from '@nestjs/graphql';
import { IsOptional, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer'; 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'; import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
@InputType() @InputType()
export class CreateFieldInput extends OmitType( export class CreateFieldInput extends OmitType(
FieldMetadataDTO, FieldMetadataDTO,
['id', 'createdAt', 'updatedAt'] as const, ['id', 'createdAt', 'updatedAt', 'standardOverrides'] as const,
InputType, InputType,
) { ) {
@IsUUID() @IsUUID()

View File

@ -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 { 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 { 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 { 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 { 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'; import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator';
@ -96,6 +97,10 @@ export class FieldMetadataDTO<T extends FieldMetadataType = FieldMetadataType> {
@Field({ nullable: true }) @Field({ nullable: true })
icon?: string; icon?: string;
@IsOptional()
@Field(() => FieldStandardOverridesDTO, { nullable: true })
standardOverrides?: FieldStandardOverridesDTO;
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
@FilterableField({ nullable: true }) @FilterableField({ nullable: true })

View File

@ -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;
}

View File

@ -15,7 +15,14 @@ import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dto
@InputType() @InputType()
export class UpdateFieldInput extends OmitType( export class UpdateFieldInput extends OmitType(
PartialType(FieldMetadataDTO, InputType), PartialType(FieldMetadataDTO, InputType),
['id', 'type', 'createdAt', 'updatedAt', 'isCustom'] as const, [
'id',
'type',
'createdAt',
'updatedAt',
'isCustom',
'standardOverrides',
] as const,
) { ) {
@HideField() @HideField()
id: string; id: string;

View File

@ -1,3 +1,4 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { import {
Column, Column,
CreateDateColumn, CreateDateColumn,
@ -12,13 +13,13 @@ import {
Unique, Unique,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; 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 { 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 { 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 { 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 { 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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity'; import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
@ -76,6 +77,9 @@ export class FieldMetadataEntity<
@Column({ nullable: true }) @Column({ nullable: true })
icon: string; icon: string;
@Column({ type: 'jsonb', nullable: true })
standardOverrides?: FieldStandardOverridesDTO;
@Column('jsonb', { nullable: true }) @Column('jsonb', { nullable: true })
options: FieldMetadataOptions<T>; options: FieldMetadataOptions<T>;

View File

@ -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 { 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 { 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 { 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 { 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 { 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'; 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, FieldMetadataRelationService,
FieldMetadataRelatedRecordsService, FieldMetadataRelatedRecordsService,
FieldMetadataResolver, FieldMetadataResolver,
BeforeUpdateOneField,
], ],
exports: [ exports: [
FieldMetadataService, FieldMetadataService,

View File

@ -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 { 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 { 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 { 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 { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { import {
FieldMetadataException, FieldMetadataException,
FieldMetadataExceptionCode, FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service'; 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 { 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 { 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'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
@ -43,6 +47,7 @@ export class FieldMetadataResolver {
constructor( constructor(
private readonly fieldMetadataService: FieldMetadataService, private readonly fieldMetadataService: FieldMetadataService,
private readonly featureFlagService: FeatureFlagService, private readonly featureFlagService: FeatureFlagService,
private readonly beforeUpdateOneField: BeforeUpdateOneField<UpdateFieldInput>,
) {} ) {}
@ResolveField(() => String, { nullable: true }) @ResolveField(() => String, { nullable: true })
@ -50,7 +55,7 @@ export class FieldMetadataResolver {
@Parent() fieldMetadata: FieldMetadataDTO, @Parent() fieldMetadata: FieldMetadataDTO,
@Context() context: I18nContext, @Context() context: I18nContext,
): Promise<string> { ): Promise<string> {
return this.fieldMetadataService.resolveTranslatableString( return this.fieldMetadataService.resolveOverridableString(
fieldMetadata, fieldMetadata,
'label', 'label',
context.req.headers['x-locale'], context.req.headers['x-locale'],
@ -62,13 +67,25 @@ export class FieldMetadataResolver {
@Parent() fieldMetadata: FieldMetadataDTO, @Parent() fieldMetadata: FieldMetadataDTO,
@Context() context: I18nContext, @Context() context: I18nContext,
): Promise<string> { ): Promise<string> {
return this.fieldMetadataService.resolveTranslatableString( return this.fieldMetadataService.resolveOverridableString(
fieldMetadata, fieldMetadata,
'description', 'description',
context.req.headers['x-locale'], context.req.headers['x-locale'],
); );
} }
@ResolveField(() => String, { nullable: true })
async icon(
@Parent() fieldMetadata: FieldMetadataDTO,
@Context() context: I18nContext,
): Promise<string> {
return this.fieldMetadataService.resolveOverridableString(
fieldMetadata,
'icon',
context.req.headers['x-locale'],
);
}
@UseGuards(SettingsPermissionsGuard(SettingsPermissions.DATA_MODEL)) @UseGuards(SettingsPermissionsGuard(SettingsPermissions.DATA_MODEL))
@Mutation(() => FieldMetadataDTO) @Mutation(() => FieldMetadataDTO)
async createOneField( async createOneField(
@ -92,8 +109,13 @@ export class FieldMetadataResolver {
@AuthWorkspace() { id: workspaceId }: Workspace, @AuthWorkspace() { id: workspaceId }: Workspace,
) { ) {
try { try {
return await this.fieldMetadataService.updateOne(input.id, { const updatedInput = (await this.beforeUpdateOneField.run(
...input.update, input,
workspaceId,
)) as UpdateOneFieldMetadataInput;
return await this.fieldMetadataService.updateOne(updatedInput.id, {
...updatedInput.update,
workspaceId, workspaceId,
}); });
} catch (error) { } catch (error) {

View File

@ -4,11 +4,11 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { i18n } from '@lingui/core'; import { i18n } from '@lingui/core';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import isEmpty from 'lodash.isempty'; 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 { APP_LOCALES } from 'twenty-shared/translations';
import { FieldMetadataType } from 'twenty-shared/types'; import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils'; 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 { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { settings } from 'src/engine/constants/settings'; 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 { 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 { 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 { 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 { import {
RelationDefinitionDTO, RelationDefinitionDTO,
RelationDefinitionType, RelationDefinitionType,
@ -457,14 +458,23 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
fieldMetadataInput: UpdateFieldInput, fieldMetadataInput: UpdateFieldInput,
existingFieldMetadata: FieldMetadataEntity, existingFieldMetadata: FieldMetadataEntity,
) { ) {
const updatableStandardFieldInput: UpdateFieldInput = { const updatableStandardFieldInput: UpdateFieldInput & {
standardOverrides?: FieldStandardOverridesDTO;
} = {
id: fieldMetadataInput.id, id: fieldMetadataInput.id,
isActive: fieldMetadataInput.isActive, isActive: fieldMetadataInput.isActive,
workspaceId: fieldMetadataInput.workspaceId, workspaceId: fieldMetadataInput.workspaceId,
defaultValue: fieldMetadataInput.defaultValue, defaultValue: fieldMetadataInput.defaultValue,
settings: fieldMetadataInput.settings, settings: fieldMetadataInput.settings,
isLabelSyncedWithName: fieldMetadataInput.isLabelSyncedWithName,
}; };
if ('standardOverrides' in fieldMetadataInput) {
updatableStandardFieldInput.standardOverrides = (
fieldMetadataInput as any
).standardOverrides;
}
if ( if (
existingFieldMetadata.type === FieldMetadataType.SELECT || existingFieldMetadata.type === FieldMetadataType.SELECT ||
existingFieldMetadata.type === FieldMetadataType.MULTI_SELECT existingFieldMetadata.type === FieldMetadataType.MULTI_SELECT
@ -599,9 +609,9 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
return fieldMetadataInput; return fieldMetadataInput;
} }
async resolveTranslatableString( async resolveOverridableString(
fieldMetadata: FieldMetadataDTO, fieldMetadata: FieldMetadataDTO,
labelKey: 'label' | 'description', labelKey: 'label' | 'description' | 'icon',
locale: keyof typeof APP_LOCALES | undefined, locale: keyof typeof APP_LOCALES | undefined,
): Promise<string> { ): Promise<string> {
if (fieldMetadata.isCustom) { if (fieldMetadata.isCustom) {
@ -612,6 +622,13 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
return fieldMetadata[labelKey] ?? ''; return fieldMetadata[labelKey] ?? '';
} }
if (
fieldMetadata.standardOverrides &&
isDefined(fieldMetadata.standardOverrides[labelKey])
) {
return fieldMetadata.standardOverrides[labelKey] as string;
}
const messageId = generateMessageId(fieldMetadata[labelKey] ?? ''); const messageId = generateMessageId(fieldMetadata[labelKey] ?? '');
const translatedMessage = i18n._(messageId); const translatedMessage = i18n._(messageId);

View File

@ -0,0 +1,225 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import {
BeforeUpdateOneHook,
UpdateOneInputType,
} from '@ptc-org/nestjs-query-graphql';
import { isDefined } from 'twenty-shared/utils';
import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/field-metadata.service';
interface StandardFieldUpdate extends Partial<UpdateFieldInput> {
standardOverrides?: FieldStandardOverridesDTO;
}
@Injectable()
export class BeforeUpdateOneField<T extends UpdateFieldInput>
implements BeforeUpdateOneHook<T>
{
constructor(readonly fieldMetadataService: FieldMetadataService) {}
async run(
instance: UpdateOneInputType<T>,
workspaceId: string,
): Promise<UpdateOneInputType<T>> {
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<T>,
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<T>,
fieldMetadata: FieldMetadataEntity,
): UpdateOneInputType<T> {
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<T>,
update: StandardFieldUpdate,
): void {
if (!isDefined(instance.update.isActive)) {
return;
}
update.isActive = instance.update.isActive;
}
private handleLabelSyncedWithNameField(
instance: UpdateOneInputType<T>,
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<T>,
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<T>,
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<T>,
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<T>,
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;
}
}

View File

@ -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 { 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 { 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 { 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'; import { BeforeDeleteOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-delete-one-object.hook';
@ObjectType('Object') @ObjectType('Object')
@ -54,6 +55,9 @@ export class ObjectMetadataDTO {
@Field({ nullable: true }) @Field({ nullable: true })
icon: string; icon: string;
@Field(() => ObjectStandardOverridesDTO, { nullable: true })
standardOverrides?: ObjectStandardOverridesDTO;
@Field({ nullable: true }) @Field({ nullable: true })
shortcut: string; shortcut: string;

View File

@ -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;
}

View File

@ -9,12 +9,19 @@ import {
BeforeUpdateOneHook, BeforeUpdateOneHook,
UpdateOneInputType, UpdateOneInputType,
} from '@ptc-org/nestjs-query-graphql'; } from '@ptc-org/nestjs-query-graphql';
import { isDefined } from 'twenty-shared/utils';
import { Equal, In, Repository } from 'typeorm'; import { Equal, In, Repository } from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; 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 { 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'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
interface StandardObjectUpdate extends Partial<UpdateObjectPayload> {
standardOverrides?: ObjectStandardOverridesDTO;
}
@Injectable() @Injectable()
export class BeforeUpdateOneObject<T extends UpdateObjectPayload> export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
implements BeforeUpdateOneHook<T> implements BeforeUpdateOneHook<T>
@ -35,6 +42,21 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
throw new UnauthorizedException(); 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<T>,
workspaceId: string,
) {
const objectMetadata = const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, { await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: { where: {
@ -46,58 +68,263 @@ export class BeforeUpdateOneObject<T extends UpdateObjectPayload>
throw new BadRequestException('Object does not exist'); throw new BadRequestException('Object does not exist');
} }
if (!objectMetadata.isCustom) { return objectMetadata;
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,
};
}
private handleStandardObjectUpdate(
instance: UpdateOneInputType<T>,
objectMetadata: ObjectMetadataEntity,
): UpdateOneInputType<T> {
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( throw new BadRequestException(
'Only isActive field can be updated for standard objects', 'Cannot update labels when they are synced with name',
); );
} }
if ( if (hasNonUpdatableFields) {
instance.update.labelIdentifierFieldMetadataId || throw new BadRequestException(
instance.update.imageIdentifierFieldMetadataId `Only isActive, isLabelSyncedWithName, labelSingular, labelPlural, icon and description fields can be updated for standard objects. Disallowed fields: ${nonUpdatableFields.join(', ')}`,
) { );
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');
}
} }
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<T>,
update: StandardObjectUpdate,
): void {
if (!isDefined(instance.update.isActive)) {
return;
}
update.isActive = instance.update.isActive;
}
private handleLabelSyncedWithNameField(
instance: UpdateOneInputType<T>,
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<T>,
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<T>,
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<T>,
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<T>,
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<T>,
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<T>,
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<T>,
workspaceId: string,
): Promise<void> {
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<T>,
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<T>,
fieldIds: string[],
): void {
if (
instance.update.imageIdentifierFieldMetadataId &&
!fieldIds.includes(instance.update.imageIdentifierFieldMetadataId)
) {
throw new BadRequestException('This image identifier does not exist');
}
} }
} }

View File

@ -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 { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.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 { 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 { ObjectPermissionsEntity } from 'src/engine/metadata-modules/object-permissions/object-permissions.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.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 }) @Column({ nullable: true })
icon: string; icon: string;
@Column({ type: 'jsonb', nullable: true })
standardOverrides?: ObjectStandardOverridesDTO;
@Column({ nullable: false }) @Column({ nullable: false })
targetTableName: string; targetTableName: string;

View File

@ -41,7 +41,7 @@ export class ObjectMetadataResolver {
@Parent() objectMetadata: ObjectMetadataDTO, @Parent() objectMetadata: ObjectMetadataDTO,
@Context() context: I18nContext, @Context() context: I18nContext,
): Promise<string> { ): Promise<string> {
return this.objectMetadataService.resolveTranslatableString( return this.objectMetadataService.resolveOverridableString(
objectMetadata, objectMetadata,
'labelPlural', 'labelPlural',
context.req.headers['x-locale'], context.req.headers['x-locale'],
@ -53,7 +53,7 @@ export class ObjectMetadataResolver {
@Parent() objectMetadata: ObjectMetadataDTO, @Parent() objectMetadata: ObjectMetadataDTO,
@Context() context: I18nContext, @Context() context: I18nContext,
): Promise<string> { ): Promise<string> {
return this.objectMetadataService.resolveTranslatableString( return this.objectMetadataService.resolveOverridableString(
objectMetadata, objectMetadata,
'labelSingular', 'labelSingular',
context.req.headers['x-locale'], context.req.headers['x-locale'],
@ -65,13 +65,25 @@ export class ObjectMetadataResolver {
@Parent() objectMetadata: ObjectMetadataDTO, @Parent() objectMetadata: ObjectMetadataDTO,
@Context() context: I18nContext, @Context() context: I18nContext,
): Promise<string> { ): Promise<string> {
return this.objectMetadataService.resolveTranslatableString( return this.objectMetadataService.resolveOverridableString(
objectMetadata, objectMetadata,
'description', 'description',
context.req.headers['x-locale'], context.req.headers['x-locale'],
); );
} }
@ResolveField(() => String, { nullable: true })
async icon(
@Parent() objectMetadata: ObjectMetadataDTO,
@Context() context: I18nContext,
): Promise<string> {
return this.objectMetadataService.resolveOverridableString(
objectMetadata,
'icon',
context.req.headers['x-locale'],
);
}
@UseGuards(SettingsPermissionsGuard(SettingsPermissions.DATA_MODEL)) @UseGuards(SettingsPermissionsGuard(SettingsPermissions.DATA_MODEL))
@Mutation(() => ObjectMetadataDTO) @Mutation(() => ObjectMetadataDTO)
async deleteOneObject( async deleteOneObject(
@ -95,10 +107,13 @@ export class ObjectMetadataResolver {
@AuthWorkspace() { id: workspaceId }: Workspace, @AuthWorkspace() { id: workspaceId }: Workspace,
) { ) {
try { try {
await this.beforeUpdateOneObject.run(input, workspaceId); const updatedInput = (await this.beforeUpdateOneObject.run(
input,
workspaceId,
)) as UpdateOneObjectInput;
return await this.objectMetadataService.updateOneObject( return await this.objectMetadataService.updateOneObject(
input, updatedInput,
workspaceId, workspaceId,
); );
} catch (error) { } catch (error) {

View File

@ -552,15 +552,22 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
} }
}; };
async resolveTranslatableString( async resolveOverridableString(
objectMetadata: ObjectMetadataDTO, objectMetadata: ObjectMetadataDTO,
labelKey: 'labelPlural' | 'labelSingular' | 'description', labelKey: 'labelPlural' | 'labelSingular' | 'description' | 'icon',
locale: keyof typeof APP_LOCALES | undefined, locale: keyof typeof APP_LOCALES | undefined,
): Promise<string> { ): Promise<string> {
if (objectMetadata.isCustom) { if (objectMetadata.isCustom) {
return objectMetadata[labelKey]; return objectMetadata[labelKey];
} }
if (
objectMetadata.standardOverrides &&
isDefined(objectMetadata.standardOverrides[labelKey])
) {
return objectMetadata.standardOverrides[labelKey] as string;
}
if (!locale) { if (!locale) {
return objectMetadata[labelKey]; return objectMetadata[labelKey];
} }