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']; id: Scalars['UUID'];
isActive?: Maybe<Scalars['Boolean']>; isActive?: Maybe<Scalars['Boolean']>;
isCustom?: Maybe<Scalars['Boolean']>; isCustom?: Maybe<Scalars['Boolean']>;
isLabelSyncedWithName: Scalars['Boolean'];
isNullable?: Maybe<Scalars['Boolean']>; isNullable?: Maybe<Scalars['Boolean']>;
isSystem?: Maybe<Scalars['Boolean']>; isSystem?: Maybe<Scalars['Boolean']>;
isUnique?: Maybe<Scalars['Boolean']>; isUnique?: Maybe<Scalars['Boolean']>;

View File

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

View File

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

View File

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

View File

@ -6,27 +6,22 @@ export const FIELD_RELATION_METADATA_ID =
'4da0302d-358a-45cd-9973-9f92723ed3c1'; '4da0302d-358a-45cd-9973-9f92723ed3c1';
export const RELATION_METADATA_ID = 'f81d4fae-7dec-11d0-a765-00a0c91e6bf6'; 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 = { export const queries = {
deleteMetadataField: gql` deleteMetadataField: gql`
mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) { mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {
deleteOneField(input: { id: $idToDelete }) { 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! $updatePayload: UpdateFieldInput!
) { ) {
updateOneField(input: { id: $idToUpdate, update: $updatePayload }) { 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'; export const objectMetadataId = '25611fce-6637-4089-b0ca-91afeec95784';
@ -107,7 +192,7 @@ export const variables = {
deleteMetadataFieldRelation: { idToDelete: RELATION_METADATA_ID }, deleteMetadataFieldRelation: { idToDelete: RELATION_METADATA_ID },
activateMetadataField: { activateMetadataField: {
idToUpdate: FIELD_METADATA_ID, idToUpdate: FIELD_METADATA_ID,
updatePayload: { isActive: true, label: undefined }, updatePayload: { isActive: true },
}, },
createMetadataField: { createMetadataField: {
input: { input: {
@ -116,9 +201,10 @@ export const variables = {
description: null, description: null,
icon: undefined, icon: undefined,
label: 'fieldLabel', label: 'fieldLabel',
name: 'fieldlabel', name: 'fieldName',
options: undefined, options: undefined,
settings: undefined, settings: undefined,
isLabelSyncedWithName: true,
objectMetadataId, objectMetadataId,
type: 'TEXT', type: 'TEXT',
}, },
@ -159,4 +245,54 @@ export const responseData = {
defaultValue: '', defaultValue: '',
options: [], 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 labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId imageIdentifierFieldMetadataId
shortcut shortcut
isLabelSyncedWithName
fields(paging: { first: 1000 }, filter: $fieldFilter) { fields(paging: { first: 1000 }, filter: $fieldFilter) {
edges { edges {
node { node {
@ -41,6 +40,7 @@ export const query = gql`
isNullable isNullable
createdAt createdAt
updatedAt updatedAt
isLabelSyncedWithName
relationDefinition { relationDefinition {
relationId relationId
direction direction

View File

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

View File

@ -23,6 +23,7 @@ const fieldMetadataItem: FieldMetadataItem = {
name: 'name', name: 'name',
type: FieldMetadataType.Text, type: FieldMetadataType.Text,
updatedAt: '', updatedAt: '',
isLabelSyncedWithName: true,
}; };
const fieldRelationMetadataItem: FieldMetadataItem = { const fieldRelationMetadataItem: FieldMetadataItem = {
@ -32,6 +33,7 @@ const fieldRelationMetadataItem: FieldMetadataItem = {
name: 'name', name: 'name',
type: FieldMetadataType.Relation, type: FieldMetadataType.Relation,
updatedAt: '', updatedAt: '',
isLabelSyncedWithName: true,
relationDefinition: { relationDefinition: {
relationId: RELATION_METADATA_ID, relationId: RELATION_METADATA_ID,
direction: RelationDefinitionType.OneToMany, 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({ const Wrapper = getJestMetadataAndApolloMocksWrapper({
@ -171,6 +191,8 @@ describe('useFieldMetadataItem', () => {
label: 'fieldLabel', label: 'fieldLabel',
objectMetadataId, objectMetadataId,
type: FieldMetadataType.Text, type: FieldMetadataType.Text,
name: 'fieldName',
isLabelSyncedWithName: true,
}); });
expect(res.data).toEqual({ expect(res.data).toEqual({

View File

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

View File

@ -9,14 +9,28 @@ import {
import { UPDATE_ONE_FIELD_METADATA_ITEM } from '../graphql/mutations'; import { UPDATE_ONE_FIELD_METADATA_ITEM } from '../graphql/mutations';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '../graphql/queries'; import { FIND_MANY_OBJECT_METADATA_ITEMS } from '../graphql/queries';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery'; 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'; import { useApolloMetadataClient } from './useApolloMetadataClient';
export const useUpdateOneFieldMetadataItem = () => { export const useUpdateOneFieldMetadataItem = () => {
const apolloMetadataClient = useApolloMetadataClient(); const apolloMetadataClient = useApolloMetadataClient();
const apolloClient = useApolloClient(); 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({ const { findManyRecordsQuery } = useFindManyRecordsQuery({
objectNameSingular: CoreObjectNameSingular.View, objectNameSingular: CoreObjectNameSingular.View,
recordGqlFields: { recordGqlFields: {
@ -54,20 +68,20 @@ export const useUpdateOneFieldMetadataItem = () => {
| 'name' | 'name'
| 'defaultValue' | 'defaultValue'
| 'options' | 'options'
| 'isLabelSyncedWithName'
>; >;
}) => { }) => {
const result = await mutate({ const result = await mutate({
variables: { variables: {
idToUpdate: fieldMetadataIdToUpdate, idToUpdate: fieldMetadataIdToUpdate,
updatePayload: { updatePayload: updatePayload,
...updatePayload,
label: updatePayload.label ?? undefined,
},
}, },
awaitRefetchQueries: true, awaitRefetchQueries: true,
refetchQueries: [getOperationName(FIND_MANY_OBJECT_METADATA_ITEMS) ?? ''], refetchQueries: [getOperationName(FIND_MANY_OBJECT_METADATA_ITEMS) ?? ''],
}); });
await refetchCurrentUser();
await apolloClient.query({ await apolloClient.query({
query: findManyRecordsQuery, query: findManyRecordsQuery,
variables: { variables: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,17 +4,39 @@ import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema'; 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 { getErrorMessageFromError } from '@/settings/data-model/fields/forms/utils/errorMessages';
import { IconPicker } from '@/ui/input/components/IconPicker'; 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 {
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 = ( export const settingsDataModelFieldIconLabelFormSchema = (
existingOtherLabels: string[] = [], existingOtherLabels: string[] = [],
) => { ) => {
return fieldMetadataItemSchema(existingOtherLabels).pick({ return fieldMetadataItemSchema(existingOtherLabels)
icon: true, .pick({
label: true, icon: true,
}); label: true,
})
.merge(
fieldMetadataItemSchema()
.pick({
name: true,
isLabelSyncedWithName: true,
})
.partial(),
);
}; };
type SettingsDataModelFieldIconLabelFormValues = z.infer< type SettingsDataModelFieldIconLabelFormValues = z.infer<
@ -28,57 +50,182 @@ const StyledInputsContainer = styled.div`
width: 100%; 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 = { type SettingsDataModelFieldIconLabelFormProps = {
disabled?: boolean; disabled?: boolean;
fieldMetadataItem?: FieldMetadataItem; fieldMetadataItem?: FieldMetadataItem;
maxLength?: number; maxLength?: number;
canToggleSyncLabelWithName?: boolean;
}; };
export const SettingsDataModelFieldIconLabelForm = ({ export const SettingsDataModelFieldIconLabelForm = ({
canToggleSyncLabelWithName = true,
disabled, disabled,
fieldMetadataItem, fieldMetadataItem,
maxLength, maxLength,
}: SettingsDataModelFieldIconLabelFormProps) => { }: SettingsDataModelFieldIconLabelFormProps) => {
const { const {
control, control,
trigger, setValue,
watch,
formState: { errors }, formState: { errors },
} = useFormContext<SettingsDataModelFieldIconLabelFormValues>(); } = 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 ( return (
<StyledInputsContainer> <>
<Controller <StyledInputsContainer>
name="icon" <Controller
control={control} name="icon"
defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'} control={control}
render={({ field: { onChange, value } }) => ( defaultValue={fieldMetadataItem?.icon ?? 'IconUsers'}
<IconPicker render={({ field: { onChange, value } }) => (
disabled={disabled} <IconPicker
selectedIconKey={value ?? ''} disabled={disabled}
onChange={({ iconKey }) => onChange(iconKey)} selectedIconKey={value ?? ''}
variant="primary" onChange={({ iconKey }) => onChange(iconKey)}
/> variant="primary"
)} />
/> )}
<Controller />
name="label" <Controller
control={control} name="label"
defaultValue={fieldMetadataItem?.label} control={control}
render={({ field: { onChange, value } }) => ( defaultValue={fieldMetadataItem?.label}
<TextInput render={({ field: { onChange, value } }) => (
placeholder="Employees" <TextInput
value={value} placeholder="Employees"
onChange={(e) => { value={value}
onChange(e); onChange={(value) => {
trigger('label'); onChange(value);
}} if (isLabelSyncedWithName === true) {
error={getErrorMessageFromError(errors.label?.message)} fillNameFromLabel(value);
disabled={disabled} }
maxLength={maxLength} }}
fullWidth error={getErrorMessageFromError(errors.label?.message)}
/> disabled={disabled}
)} maxLength={maxLength}
/> fullWidth
</StyledInputsContainer> />
)}
/>
</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({ export const settingsDataModelFieldRelationFormSchema = z.object({
relation: z.object({ relation: z.object({
field: fieldMetadataItemSchema().pick({ field: fieldMetadataItemSchema()
icon: true, .pick({
label: true, icon: true,
}), label: true,
})
// NOT SURE IF THIS IS CORRECT
.merge(
fieldMetadataItemSchema()
.pick({
name: true,
isLabelSyncedWithName: true,
})
.partial(),
),
objectMetadataId: z.string().uuid(), objectMetadataId: z.string().uuid(),
type: z.enum( type: z.enum(
Object.keys(RELATION_TYPES) as [ Object.keys(RELATION_TYPES) as [

View File

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

View File

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

View File

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

View File

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

View File

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