Add field isLabelSyncedWithName (#8829)

## Context
The recent addition of object renaming introduced issues with enum
names. Enum names should follow the pattern
`${schemaName}.${tableName}_${columnName}_enum`. To address this, and to
allow users to customize the API name (which is included in the enum
name, columnName), this PR implements behavior similar to object
renaming by introducing a `isLabelSyncedWithName` boolean.

<img width="624" alt="Screenshot 2024-12-02 at 11 58 49"
src="https://github.com/user-attachments/assets/690fb71c-83f0-4922-80c0-946c92dacc30">
<img width="596" alt="Screenshot 2024-12-02 at 11 58 39"
src="https://github.com/user-attachments/assets/af9a0037-7cf5-40c3-9ed5-d51b340c8087">
This commit is contained in:
Weiko 2024-12-03 13:22:12 +01:00 committed by GitHub
parent 7e4277fbe4
commit 3c7805c6d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1118 additions and 125 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1508,6 +1508,7 @@ export type Field = {
id: Scalars['UUID'];
isActive?: Maybe<Scalars['Boolean']>;
isCustom?: Maybe<Scalars['Boolean']>;
isLabelSyncedWithName: Scalars['Boolean'];
isNullable?: Maybe<Scalars['Boolean']>;
isSystem?: Maybe<Scalars['Boolean']>;
isUnique?: Maybe<Scalars['Boolean']>;

View File

@ -75,6 +75,7 @@ export const UPDATE_ONE_FIELD_METADATA_ITEM = gql`
createdAt
updatedAt
settings
isLabelSyncedWithName
}
}
`;

View File

@ -69,6 +69,7 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql`
defaultValue
options
settings
isLabelSyncedWithName
relationDefinition {
relationId
direction

View File

@ -21,13 +21,13 @@ export const variables = {
fromDescription: null,
fromIcon: undefined,
fromLabel: 'label',
fromName: 'label',
fromName: 'name',
fromObjectMetadataId: 'objectMetadataId',
relationType: 'ONE_TO_ONE',
toDescription: null,
toIcon: undefined,
toLabel: 'Another label',
toName: 'anotherLabel',
toName: 'anotherName',
toObjectMetadataId: 'objectMetadataId1',
},
},

View File

@ -6,27 +6,22 @@ export const FIELD_RELATION_METADATA_ID =
'4da0302d-358a-45cd-9973-9f92723ed3c1';
export const RELATION_METADATA_ID = 'f81d4fae-7dec-11d0-a765-00a0c91e6bf6';
const baseFields = `
id
type
name
label
description
icon
isCustom
isActive
isNullable
createdAt
updatedAt
settings
`;
export const queries = {
deleteMetadataField: gql`
mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {
deleteOneField(input: { id: $idToDelete }) {
${baseFields}
id
type
name
label
description
icon
isCustom
isActive
isNullable
createdAt
updatedAt
settings
}
}
`,
@ -74,7 +69,19 @@ export const queries = {
$updatePayload: UpdateFieldInput!
) {
updateOneField(input: { id: $idToUpdate, update: $updatePayload }) {
${baseFields}
id
type
name
label
description
icon
isCustom
isActive
isNullable
createdAt
updatedAt
settings
isLabelSyncedWithName
}
}
`,
@ -98,6 +105,84 @@ export const queries = {
}
}
`,
getCurrentUser: gql`
query GetCurrentUser {
currentUser {
...UserQueryFragment
}
}
fragment UserQueryFragment on User {
id
firstName
lastName
email
canImpersonate
supportUserHash
analyticsTinybirdJwts {
getWebhookAnalytics
getPageviewsAnalytics
getUsersAnalytics
getServerlessFunctionDuration
getServerlessFunctionSuccessRate
getServerlessFunctionErrorCount
}
onboardingStatus
workspaceMember {
...WorkspaceMemberQueryFragment
}
workspaceMembers {
...WorkspaceMemberQueryFragment
}
defaultWorkspace {
id
displayName
logo
domainName
inviteHash
allowImpersonation
activationStatus
isPublicInviteLinkEnabled
hasValidEntrepriseKey
featureFlags {
id
key
value
workspaceId
}
metadataVersion
currentBillingSubscription {
id
status
interval
}
workspaceMembersCount
}
workspaces {
workspace {
id
logo
displayName
domainName
}
}
userVars
}
fragment WorkspaceMemberQueryFragment on WorkspaceMember {
id
name {
firstName
lastName
}
colorScheme
avatarUrl
locale
timeZone
dateFormat
timeFormat
}
`,
};
export const objectMetadataId = '25611fce-6637-4089-b0ca-91afeec95784';
@ -107,7 +192,7 @@ export const variables = {
deleteMetadataFieldRelation: { idToDelete: RELATION_METADATA_ID },
activateMetadataField: {
idToUpdate: FIELD_METADATA_ID,
updatePayload: { isActive: true, label: undefined },
updatePayload: { isActive: true },
},
createMetadataField: {
input: {
@ -116,9 +201,10 @@ export const variables = {
description: null,
icon: undefined,
label: 'fieldLabel',
name: 'fieldlabel',
name: 'fieldName',
options: undefined,
settings: undefined,
isLabelSyncedWithName: true,
objectMetadataId,
type: 'TEXT',
},
@ -159,4 +245,54 @@ export const responseData = {
defaultValue: '',
options: [],
},
getCurrentUser: {
currentUser: {
id: 'test-user-id',
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
canImpersonate: false,
supportUserHash: null,
analyticsTinybirdJwts: {
getWebhookAnalytics: null,
getPageviewsAnalytics: null,
getUsersAnalytics: null,
getServerlessFunctionDuration: null,
getServerlessFunctionSuccessRate: null,
getServerlessFunctionErrorCount: null,
},
onboardingStatus: 'completed',
workspaceMember: {
id: 'test-workspace-member-id',
name: {
firstName: 'Test',
lastName: 'User',
},
colorScheme: 'light',
avatarUrl: null,
locale: 'en',
timeZone: 'UTC',
dateFormat: 'MM/DD/YYYY',
timeFormat: '24',
},
workspaceMembers: [],
defaultWorkspace: {
id: 'test-workspace-id',
displayName: 'Test Workspace',
logo: null,
domainName: 'test',
inviteHash: 'test-hash',
allowImpersonation: false,
activationStatus: 'active',
isPublicInviteLinkEnabled: false,
hasValidEntrepriseKey: false,
featureFlags: [],
metadataVersion: 1,
currentBillingSubscription: null,
workspaceMembersCount: 1,
},
workspaces: [],
userVars: null,
},
},
};

View File

@ -25,7 +25,6 @@ export const query = gql`
labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId
shortcut
isLabelSyncedWithName
fields(paging: { first: 1000 }, filter: $fieldFilter) {
edges {
node {
@ -41,6 +40,7 @@ export const query = gql`
isNullable
createdAt
updatedAt
isLabelSyncedWithName
relationDefinition {
relationId
direction

View File

@ -45,11 +45,13 @@ describe('useCreateOneRelationMetadataItem', () => {
relationType: RelationDefinitionType.OneToOne,
field: {
label: 'label',
name: 'name',
},
objectMetadataId: 'objectMetadataId',
connect: {
field: {
label: 'Another label',
name: 'anotherName',
},
objectMetadataId: 'objectMetadataId1',
},

View File

@ -23,6 +23,7 @@ const fieldMetadataItem: FieldMetadataItem = {
name: 'name',
type: FieldMetadataType.Text,
updatedAt: '',
isLabelSyncedWithName: true,
};
const fieldRelationMetadataItem: FieldMetadataItem = {
@ -32,6 +33,7 @@ const fieldRelationMetadataItem: FieldMetadataItem = {
name: 'name',
type: FieldMetadataType.Relation,
updatedAt: '',
isLabelSyncedWithName: true,
relationDefinition: {
relationId: RELATION_METADATA_ID,
direction: RelationDefinitionType.OneToMany,
@ -137,6 +139,24 @@ const mocks = [
},
})),
},
{
request: {
query: queries.getCurrentUser,
variables: {},
},
result: jest.fn(() => ({
data: responseData.getCurrentUser,
})),
},
{
request: {
query: queries.getCurrentUser,
variables: {},
},
result: jest.fn(() => ({
data: responseData.getCurrentUser,
})),
},
];
const Wrapper = getJestMetadataAndApolloMocksWrapper({
@ -171,6 +191,8 @@ describe('useFieldMetadataItem', () => {
label: 'fieldLabel',
objectMetadataId,
type: FieldMetadataType.Text,
name: 'fieldName',
isLabelSyncedWithName: true,
});
expect(res.data).toEqual({

View File

@ -1,6 +1,5 @@
import { useDeleteOneRelationMetadataItem } from '@/object-metadata/hooks/useDeleteOneRelationMetadataItem';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { Field } from '~/generated/graphql';
import { Field, FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldMetadataItem } from '../types/FieldMetadataItem';
import { formatFieldMetadataItemInput } from '../utils/formatFieldMetadataItemInput';
@ -18,6 +17,7 @@ export const useFieldMetadataItem = () => {
const createMetadataField = (
input: Pick<
Field,
| 'name'
| 'label'
| 'icon'
| 'description'
@ -25,6 +25,7 @@ export const useFieldMetadataItem = () => {
| 'type'
| 'options'
| 'settings'
| 'isLabelSyncedWithName'
> & {
objectMetadataId: string;
},
@ -37,6 +38,7 @@ export const useFieldMetadataItem = () => {
type: input.type,
label: formattedInput.label ?? '',
name: formattedInput.name ?? '',
isLabelSyncedWithName: formattedInput.isLabelSyncedWithName ?? true,
});
};

View File

@ -9,14 +9,28 @@ import {
import { UPDATE_ONE_FIELD_METADATA_ITEM } from '../graphql/mutations';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '../graphql/queries';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
import { useGetCurrentUserQuery } from '~/generated/graphql';
import { useApolloMetadataClient } from './useApolloMetadataClient';
export const useUpdateOneFieldMetadataItem = () => {
const apolloMetadataClient = useApolloMetadataClient();
const apolloClient = useApolloClient();
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const { refetch: refetchCurrentUser } = useGetCurrentUserQuery({
onCompleted: (data) => {
if (isDefined(data?.currentUser?.defaultWorkspace)) {
setCurrentWorkspace(data.currentUser.defaultWorkspace);
}
},
});
const { findManyRecordsQuery } = useFindManyRecordsQuery({
objectNameSingular: CoreObjectNameSingular.View,
recordGqlFields: {
@ -54,20 +68,20 @@ export const useUpdateOneFieldMetadataItem = () => {
| 'name'
| 'defaultValue'
| 'options'
| 'isLabelSyncedWithName'
>;
}) => {
const result = await mutate({
variables: {
idToUpdate: fieldMetadataIdToUpdate,
updatePayload: {
...updatePayload,
label: updatePayload.label ?? undefined,
},
updatePayload: updatePayload,
},
awaitRefetchQueries: true,
refetchQueries: [getOperationName(FIND_MANY_OBJECT_METADATA_ITEMS) ?? ''],
});
await refetchCurrentUser();
await apolloClient.query({
query: findManyRecordsQuery,
variables: {

View File

@ -39,4 +39,5 @@ export type FieldMetadataItem = Omit<
settings?: {
displayAsRelativeDate?: boolean;
};
isLabelSyncedWithName?: boolean;
};

View File

@ -24,10 +24,12 @@ describe('formatFieldMetadataItemInput', () => {
const input = {
defaultValue: "'OPTION_1'",
label: 'Example Label',
name: 'exampleLabel',
icon: 'example-icon',
type: FieldMetadataType.Select,
description: 'Example description',
options,
isLabelSyncedWithName: true,
};
const expected = {
@ -37,6 +39,7 @@ describe('formatFieldMetadataItemInput', () => {
name: 'exampleLabel',
options,
defaultValue: "'OPTION_1'",
isLabelSyncedWithName: true,
};
const result = formatFieldMetadataItemInput(input);
@ -47,9 +50,11 @@ describe('formatFieldMetadataItemInput', () => {
it('should handle input without options', () => {
const input = {
label: 'Example Label',
name: 'exampleLabel',
icon: 'example-icon',
type: FieldMetadataType.Select,
description: 'Example description',
isLabelSyncedWithName: true,
};
const expected = {
@ -59,6 +64,7 @@ describe('formatFieldMetadataItemInput', () => {
name: 'exampleLabel',
options: undefined,
defaultValue: undefined,
isLabelSyncedWithName: true,
};
const result = formatFieldMetadataItemInput(input);
@ -86,10 +92,12 @@ describe('formatFieldMetadataItemInput', () => {
const input = {
defaultValue: ["'OPTION_1'", "'OPTION_2'"],
label: 'Example Label',
name: 'exampleLabel',
icon: 'example-icon',
type: FieldMetadataType.MultiSelect,
description: 'Example description',
options,
isLabelSyncedWithName: true,
};
const expected = {
@ -99,6 +107,7 @@ describe('formatFieldMetadataItemInput', () => {
name: 'exampleLabel',
options,
defaultValue: ["'OPTION_1'", "'OPTION_2'"],
isLabelSyncedWithName: true,
};
const result = formatFieldMetadataItemInput(input);
@ -109,9 +118,11 @@ describe('formatFieldMetadataItemInput', () => {
it('should handle multi select input without options', () => {
const input = {
label: 'Example Label',
name: 'exampleLabel',
icon: 'example-icon',
type: FieldMetadataType.MultiSelect,
description: 'Example description',
isLabelSyncedWithName: true,
};
const expected = {
@ -121,6 +132,7 @@ describe('formatFieldMetadataItemInput', () => {
name: 'exampleLabel',
options: undefined,
defaultValue: undefined,
isLabelSyncedWithName: true,
};
const result = formatFieldMetadataItemInput(input);

View File

@ -1,29 +1,29 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
export const formatFieldMetadataItemInput = (
input: Partial<
Pick<
FieldMetadataItem,
| 'type'
| 'name'
| 'label'
| 'defaultValue'
| 'icon'
| 'description'
| 'defaultValue'
| 'type'
| 'options'
| 'settings'
| 'isLabelSyncedWithName'
>
>,
) => {
const label = input.label?.trim();
return {
defaultValue: input.defaultValue,
description: input.description?.trim() ?? null,
icon: input.icon,
label,
name: label ? computeMetadataNameFromLabel(label) : undefined,
label: input.label?.trim(),
name: input.name?.trim(),
options: input.options,
settings: input.settings,
isLabelSyncedWithName: input.isLabelSyncedWithName,
};
};

View File

@ -10,10 +10,10 @@ import { formatFieldMetadataItemInput } from './formatFieldMetadataItemInput';
export type FormatRelationMetadataInputParams = {
relationType: RelationType;
field: Pick<Field, 'label' | 'icon' | 'description'>;
field: Pick<Field, 'label' | 'icon' | 'description' | 'name'>;
objectMetadataId: string;
connect: {
field: Pick<Field, 'label' | 'icon'>;
field: Pick<Field, 'label' | 'icon' | 'name'>;
objectMetadataId: string;
};
};

View File

@ -23,6 +23,7 @@ export const fieldMetadataItemSchema = (existingLabels?: string[]) => {
isUnique: z.boolean(),
isSystem: z.boolean(),
label: metadataLabelSchema(existingLabels),
isLabelSyncedWithName: z.boolean(),
name: camelCaseStringSchema,
options: z
.array(

View File

@ -4,17 +4,39 @@ import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { DATABASE_IDENTIFIER_MAXIMUM_LENGTH } from '@/settings/data-model/constants/DatabaseIdentifierMaximumLength';
import { getErrorMessageFromError } from '@/settings/data-model/fields/forms/utils/errorMessages';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextInput } from '@/ui/input/components/TextInput';
import { useTheme } from '@emotion/react';
import {
AppTooltip,
Card,
IconInfoCircle,
IconRefresh,
isDefined,
TooltipDelay,
} from 'twenty-ui';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
export const settingsDataModelFieldIconLabelFormSchema = (
existingOtherLabels: string[] = [],
) => {
return fieldMetadataItemSchema(existingOtherLabels).pick({
icon: true,
label: true,
});
return fieldMetadataItemSchema(existingOtherLabels)
.pick({
icon: true,
label: true,
})
.merge(
fieldMetadataItemSchema()
.pick({
name: true,
isLabelSyncedWithName: true,
})
.partial(),
);
};
type SettingsDataModelFieldIconLabelFormValues = z.infer<
@ -28,57 +50,182 @@ const StyledInputsContainer = styled.div`
width: 100%;
`;
const StyledAdvancedSettingsSectionInputWrapper = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
width: 100%;
flex: 1;
`;
const StyledAdvancedSettingsOuterContainer = styled.div`
padding-top: ${({ theme }) => theme.spacing(4)};
`;
const StyledAdvancedSettingsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
position: relative;
width: 100%;
`;
type SettingsDataModelFieldIconLabelFormProps = {
disabled?: boolean;
fieldMetadataItem?: FieldMetadataItem;
maxLength?: number;
canToggleSyncLabelWithName?: boolean;
};
export const SettingsDataModelFieldIconLabelForm = ({
canToggleSyncLabelWithName = true,
disabled,
fieldMetadataItem,
maxLength,
}: SettingsDataModelFieldIconLabelFormProps) => {
const {
control,
trigger,
setValue,
watch,
formState: { errors },
} = useFormContext<SettingsDataModelFieldIconLabelFormValues>();
const theme = useTheme();
const isLabelSyncedWithName =
watch('isLabelSyncedWithName') ??
(isDefined(fieldMetadataItem)
? fieldMetadataItem.isLabelSyncedWithName
: true);
const label = watch('label');
const apiNameTooltipText = isLabelSyncedWithName
? 'Deactivate "Synchronize Objects Labels and API Names" to set a custom API name'
: 'Input must be in camel case and cannot start with a number';
const fillNameFromLabel = (label: string) => {
isDefined(label) &&
setValue('name', computeMetadataNameFromLabel(label), {
shouldDirty: true,
});
};
return (
<StyledInputsContainer>
<Controller
name="icon"
control={control}
defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'}
render={({ field: { onChange, value } }) => (
<IconPicker
disabled={disabled}
selectedIconKey={value ?? ''}
onChange={({ iconKey }) => onChange(iconKey)}
variant="primary"
/>
)}
/>
<Controller
name="label"
control={control}
defaultValue={fieldMetadataItem?.label}
render={({ field: { onChange, value } }) => (
<TextInput
placeholder="Employees"
value={value}
onChange={(e) => {
onChange(e);
trigger('label');
}}
error={getErrorMessageFromError(errors.label?.message)}
disabled={disabled}
maxLength={maxLength}
fullWidth
/>
)}
/>
</StyledInputsContainer>
<>
<StyledInputsContainer>
<Controller
name="icon"
control={control}
defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'}
render={({ field: { onChange, value } }) => (
<IconPicker
disabled={disabled}
selectedIconKey={value ?? ''}
onChange={({ iconKey }) => onChange(iconKey)}
variant="primary"
/>
)}
/>
<Controller
name="label"
control={control}
defaultValue={fieldMetadataItem?.label}
render={({ field: { onChange, value } }) => (
<TextInput
placeholder="Employees"
value={value}
onChange={(value) => {
onChange(value);
if (isLabelSyncedWithName === true) {
fillNameFromLabel(value);
}
}}
error={getErrorMessageFromError(errors.label?.message)}
disabled={disabled}
maxLength={maxLength}
fullWidth
/>
)}
/>
</StyledInputsContainer>
{canToggleSyncLabelWithName && (
<StyledAdvancedSettingsOuterContainer>
<AdvancedSettingsWrapper>
<StyledAdvancedSettingsContainer>
<StyledAdvancedSettingsSectionInputWrapper>
<StyledInputsContainer>
<Controller
name="name"
control={control}
defaultValue={fieldMetadataItem?.name}
render={({ field: { onChange, value } }) => (
<>
<TextInput
label="API Name"
placeholder="employees"
value={value}
onChange={onChange}
disabled={disabled || isLabelSyncedWithName}
fullWidth
maxLength={DATABASE_IDENTIFIER_MAXIMUM_LENGTH}
RightIcon={() =>
apiNameTooltipText && (
<>
<IconInfoCircle
id="info-circle-id-name"
size={theme.icon.size.md}
color={theme.font.color.tertiary}
style={{ outline: 'none' }}
/>
<AppTooltip
anchorSelect="#info-circle-id-name"
content={apiNameTooltipText}
offset={5}
noArrow
place="bottom"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
</>
)
}
/>
</>
)}
/>
</StyledInputsContainer>
<Controller
name="isLabelSyncedWithName"
control={control}
defaultValue={
fieldMetadataItem?.isLabelSyncedWithName ?? true
}
render={({ field: { onChange, value } }) => (
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconRefresh}
title="Synchronize Field Label and API Name"
description="Should changing a field's label also change the API name?"
checked={value ?? true}
disabled={
isDefined(fieldMetadataItem) &&
!fieldMetadataItem.isCustom
}
advancedMode
onChange={(value) => {
onChange(value);
if (value === true) {
fillNameFromLabel(label);
}
}}
/>
</Card>
)}
/>
</StyledAdvancedSettingsSectionInputWrapper>
</StyledAdvancedSettingsContainer>
</AdvancedSettingsWrapper>
</StyledAdvancedSettingsOuterContainer>
)}
</>
);
};

View File

@ -20,10 +20,20 @@ import { RelationDefinitionType } from '~/generated-metadata/graphql';
export const settingsDataModelFieldRelationFormSchema = z.object({
relation: z.object({
field: fieldMetadataItemSchema().pick({
icon: true,
label: true,
}),
field: fieldMetadataItemSchema()
.pick({
icon: true,
label: true,
})
// NOT SURE IF THIS IS CORRECT
.merge(
fieldMetadataItemSchema()
.pick({
name: true,
isLabelSyncedWithName: true,
})
.partial(),
),
objectMetadataId: z.string().uuid(),
type: z.enum(
Object.keys(RELATION_TYPES) as [

View File

@ -2,7 +2,7 @@ import { useApolloClient } from '@apollo/client';
import { zodResolver } from '@hookform/resolvers/zod';
import omit from 'lodash.omit';
import pick from 'lodash.pick';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router-dom';
import {
@ -49,6 +49,7 @@ type SettingsDataModelFieldEditFormValues = z.infer<
export const SettingsObjectFieldEdit = () => {
const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar();
const [isPersisting, setIsPersisting] = useState(false);
const { objectSlug = '', fieldSlug = '' } = useParams();
const { findObjectMetadataItemBySlug } = useFilteredObjectMetadataItems();
@ -87,14 +88,16 @@ export const SettingsObjectFieldEdit = () => {
type: fieldMetadataItem?.type as SettingsFieldType,
label: fieldMetadataItem?.label ?? '',
description: fieldMetadataItem?.description,
isLabelSyncedWithName: fieldMetadataItem?.isLabelSyncedWithName ?? true,
},
});
useEffect(() => {
if (isPersisting) return;
if (!objectMetadataItem || !fieldMetadataItem) {
navigate(AppPath.NotFound);
}
}, [fieldMetadataItem, objectMetadataItem, navigate]);
}, [navigate, objectMetadataItem, fieldMetadataItem, isPersisting]);
const { isDirty, isValid, isSubmitting } = formConfig.formState;
const canSave = isDirty && isValid && !isSubmitting;
@ -125,6 +128,8 @@ export const SettingsObjectFieldEdit = () => {
}) ?? {};
if (isDefined(relationFieldMetadataItem)) {
setIsPersisting(true);
await updateOneFieldMetadataItem({
objectMetadataId: objectMetadataItem.id,
fieldMetadataIdToUpdate: relationFieldMetadataItem.id,
@ -141,6 +146,8 @@ export const SettingsObjectFieldEdit = () => {
Object.keys(otherDirtyFields),
);
setIsPersisting(true);
await updateOneFieldMetadataItem({
objectMetadataId: objectMetadataItem.id,
fieldMetadataIdToUpdate: fieldMetadataItem.id,
@ -155,6 +162,8 @@ export const SettingsObjectFieldEdit = () => {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
} finally {
setIsPersisting(false);
}
};
@ -210,6 +219,9 @@ export const SettingsObjectFieldEdit = () => {
disabled={!fieldMetadataItem.isCustom}
fieldMetadataItem={fieldMetadataItem}
maxLength={FIELD_NAME_MAXIMUM_LENGTH}
canToggleSyncLabelWithName={
fieldMetadataItem.type !== FieldMetadataType.Relation
}
/>
</Section>
<Section>

View File

@ -29,6 +29,7 @@ import { H2Title, Section } from 'twenty-ui';
import { z } from 'zod';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { DEFAULT_ICONS_BY_FIELD_TYPE } from '~/pages/settings/data-model/constants/DefaultIconsByFieldType';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
type SettingsDataModelNewFieldFormValues = z.infer<
@ -67,6 +68,7 @@ export const SettingsObjectNewFieldConfigure = () => {
DEFAULT_ICONS_BY_FIELD_TYPE[fieldType] ?? DEFAULT_ICON_FOR_NEW_FIELD,
label: '',
description: '',
name: '',
},
});
@ -134,12 +136,22 @@ export const SettingsObjectNewFieldConfigure = () => {
await createOneRelationMetadata({
relationType: relationFormValues.type,
field: pick(fieldFormValues, ['icon', 'label', 'description']),
field: pick(fieldFormValues, [
'icon',
'label',
'description',
'name',
'isLabelSyncedWithName',
]),
objectMetadataId: activeObjectMetadataItem.id,
connect: {
field: {
icon: relationFormValues.field.icon,
label: relationFormValues.field.label,
name:
(relationFormValues.field.isLabelSyncedWithName ?? true)
? computeMetadataNameFromLabel(relationFormValues.field.label)
: relationFormValues.field.name,
},
objectMetadataId: relationFormValues.objectMetadataId,
},
@ -204,6 +216,9 @@ export const SettingsObjectNewFieldConfigure = () => {
/>
<SettingsDataModelFieldIconLabelForm
maxLength={FIELD_NAME_MAXIMUM_LENGTH}
canToggleSyncLabelWithName={
fieldType !== FieldMetadataType.Relation
}
/>
</Section>
<Section>

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIsLabelSyncedWithNameToFieldMetadata1733153195498
implements MigrationInterface
{
name = 'AddIsLabelSyncedWithNameToFieldMetadata1733153195498';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."fieldMetadata" ADD "isLabelSyncedWithName" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."fieldMetadata" DROP COLUMN "isLabelSyncedWithName"`,
);
}
}

View File

@ -145,6 +145,11 @@ export class FieldMetadataDTO<
objectMetadataId: string;
@IsBoolean()
@IsOptional()
@Field()
isLabelSyncedWithName?: boolean;
@IsDateString()
@Field()
createdAt: Date;

View File

@ -114,6 +114,9 @@ export class FieldMetadataEntity<
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@Column({ default: false })
isLabelSyncedWithName: boolean;
@OneToOne(
() => RelationMetadataEntity,
(relation: RelationMetadataEntity) => relation.fromFieldMetadata,

View File

@ -78,7 +78,7 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface {
@Column({ nullable: true, type: 'uuid' })
imageIdentifierFieldMetadataId?: string | null;
@Column({ default: true })
@Column({ default: false })
isLabelSyncedWithName: boolean;
@Column({ nullable: false, type: 'uuid' })

View File

@ -23,6 +23,13 @@ export class WorkspaceMigrationEnumService {
tableName: string,
migrationColumn: WorkspaceMigrationColumnAlter,
) {
const oldEnumTypeName = await this.getEnumTypeName(
queryRunner,
schemaName,
tableName,
migrationColumn.currentColumnDefinition.columnName,
);
// Rename column name
if (
migrationColumn.currentColumnDefinition.columnName !==
@ -37,13 +44,6 @@ export class WorkspaceMigrationEnumService {
);
}
const oldEnumTypeName = await this.getEnumTypeName(
queryRunner,
schemaName,
tableName,
migrationColumn.currentColumnDefinition.columnName,
);
const columnDefinition = migrationColumn.alteredColumnDefinition;
const tempEnumTypeName = `${oldEnumTypeName}_temp`;
const newEnumTypeName = `${tableName}_${columnDefinition.columnName}_enum`;
@ -236,7 +236,7 @@ export class WorkspaceMigrationEnumService {
[schemaName, tableName, columnName],
);
const enumTypeName = result[0].udt_name;
const enumTypeName = result[0]?.udt_name;
if (!enumTypeName) {
throw new WorkspaceMigrationException(