mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-23 03:51:36 +03:00
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:
parent
7e4277fbe4
commit
3c7805c6d0
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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']>;
|
||||
|
@ -75,6 +75,7 @@ export const UPDATE_ONE_FIELD_METADATA_ITEM = gql`
|
||||
createdAt
|
||||
updatedAt
|
||||
settings
|
||||
isLabelSyncedWithName
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -69,6 +69,7 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql`
|
||||
defaultValue
|
||||
options
|
||||
settings
|
||||
isLabelSyncedWithName
|
||||
relationDefinition {
|
||||
relationId
|
||||
direction
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
},
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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: {
|
||||
|
@ -39,4 +39,5 @@ export type FieldMetadataItem = Omit<
|
||||
settings?: {
|
||||
displayAsRelativeDate?: boolean;
|
||||
};
|
||||
isLabelSyncedWithName?: boolean;
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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 [
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
@ -145,6 +145,11 @@ export class FieldMetadataDTO<
|
||||
|
||||
objectMetadataId: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Field()
|
||||
isLabelSyncedWithName?: boolean;
|
||||
|
||||
@IsDateString()
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
|
@ -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,
|
||||
|
@ -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' })
|
||||
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user