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
27 changed files with 1118 additions and 125 deletions

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 [