diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromRecordNode.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromRecordNode.ts index 49cfe10bc..23daf1ec6 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromRecordNode.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromRecordNode.ts @@ -1,3 +1,5 @@ +import pick from 'lodash.pick'; + import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; import { RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; @@ -12,15 +14,11 @@ export const getRecordFromRecordNode = ({ return { ...Object.fromEntries( Object.entries(recordNode).map(([fieldName, value]) => { - if (isUndefinedOrNull(value)) { - return [fieldName, value]; - } - - if (Array.isArray(value)) { - return [fieldName, value]; - } - - if (typeof value !== 'object') { + if ( + isUndefinedOrNull(value) || + Array.isArray(value) || + typeof value !== 'object' + ) { return [fieldName, value]; } @@ -32,7 +30,10 @@ export const getRecordFromRecordNode = ({ : [fieldName, getRecordFromRecordNode({ recordNode: value })]; }), ), - id: recordNode.id, - __typename: recordNode.__typename, + // Only adds `id` and `__typename` if they exist. + // RawJson field value passes through this method and does not have `id` or `__typename`. + // This prevents adding an undefined `id` and `__typename` to the RawJson field value, + // which is invalid JSON. + ...pick(recordNode, ['id', '__typename'] as const), } as T; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx index 57ca920cf..703064580 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx @@ -1,6 +1,7 @@ import { useContext } from 'react'; import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay'; +import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { ExpandableListProps } from '@/ui/layout/expandable-list/components/ExpandableList'; @@ -56,7 +57,8 @@ export const FieldDisplay = ({ ) : isFieldRelation(fieldDefinition) ? ( - ) : isFieldPhone(fieldDefinition) ? ( + ) : isFieldPhone(fieldDefinition) || + isFieldDisplayedAsPhone(fieldDefinition) ? ( ) : isFieldText(fieldDefinition) ? ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx index f7a9b6ff2..58320f47b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx @@ -9,6 +9,7 @@ import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput'; import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope'; import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate'; +import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect'; @@ -71,7 +72,8 @@ export const FieldInput = ({ > {isFieldRelation(fieldDefinition) ? ( - ) : isFieldPhone(fieldDefinition) ? ( + ) : isFieldPhone(fieldDefinition) || + isFieldDisplayedAsPhone(fieldDefinition) ? ( { isFieldLink(fieldDefinition) || isFieldEmail(fieldDefinition) || isFieldPhone(fieldDefinition) || + isFieldDisplayedAsPhone(fieldDefinition) || isFieldMultiSelect(fieldDefinition) || (isFieldRelation(fieldDefinition) && fieldDefinition.metadata.relationObjectMetadataNameSingular !== diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/JsonFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/JsonFieldDisplay.tsx index 5a0f553cd..e5b5ca1ec 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/JsonFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/JsonFieldDisplay.tsx @@ -1,4 +1,5 @@ import { useJsonField } from '@/object-record/record-field/meta-types/hooks/useJsonField'; +import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue'; import { JsonDisplay } from '@/ui/field/display/components/JsonDisplay'; export const JsonFieldDisplay = () => { @@ -6,7 +7,7 @@ export const JsonFieldDisplay = () => { return ( ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts index 0219eb173..e9a771ee4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts @@ -1,6 +1,7 @@ import { useContext } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; +import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { FieldJsonValue } from '@/object-record/record-field/types/FieldMetadata'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; @@ -9,7 +10,6 @@ import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldContext } from '../../contexts/FieldContext'; import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; import { isFieldRawJson } from '../../types/guards/isFieldRawJson'; -import { isFieldTextValue } from '../../types/guards/isFieldTextValue'; export const useJsonField = () => { const { entityId, fieldDefinition, hotkeyScope, maxWidth } = @@ -29,7 +29,18 @@ export const useJsonField = () => { fieldName: fieldName, }), ); - const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : ''; + + const persistField = usePersistField(); + + const persistJsonField = (nextValue: string) => { + if (!nextValue) persistField(null); + + try { + persistField(JSON.parse(nextValue)); + } catch { + // Do nothing + } + }; const { setDraftValue, getDraftValueSelector } = useRecordFieldInput(`${entityId}-${fieldName}`); @@ -41,8 +52,9 @@ export const useJsonField = () => { setDraftValue, maxWidth, fieldDefinition, - fieldValue: fieldTextValue, + fieldValue, setFieldValue, hotkeyScope, + persistJsonField, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhoneField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhoneField.ts index b777214a2..ddc55be3b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhoneField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhoneField.ts @@ -4,6 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { FieldPhoneValue } from '@/object-record/record-field/types/FieldMetadata'; +import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -15,7 +16,17 @@ import { isFieldPhone } from '../../types/guards/isFieldPhone'; export const usePhoneField = () => { const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); - assertFieldMetadata(FieldMetadataType.Phone, isFieldPhone, fieldDefinition); + try { + // TODO: temporary - remove when 'Phone' field in 'Person' object + // is migrated to use FieldMetadataType.Phone as type. + assertFieldMetadata( + FieldMetadataType.Text, + isFieldDisplayedAsPhone, + fieldDefinition, + ); + } catch { + assertFieldMetadata(FieldMetadataType.Phone, isFieldPhone, fieldDefinition); + } const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx index 34a06e814..92b4628a4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx @@ -1,8 +1,6 @@ -import { isValidJSON } from '@/object-record/record-field/utils/isFieldValueJson'; import { FieldTextAreaOverlay } from '@/ui/field/input/components/FieldTextAreaOverlay'; import { TextAreaInput } from '@/ui/field/input/components/TextAreaInput'; -import { usePersistField } from '../../../hooks/usePersistField'; import { useJsonField } from '../../hooks/useJsonField'; import { FieldInputEvent } from './DateFieldInput'; @@ -22,53 +20,47 @@ export const RawJsonFieldInput = ({ onTab, onShiftTab, }: RawJsonFieldInputProps) => { - const { fieldDefinition, draftValue, hotkeyScope, setDraftValue } = - useJsonField(); - - const persistField = usePersistField(); - - const handlePersistField = (newText: string) => { - if (!newText || isValidJSON(newText)) persistField(newText || null); - }; + const { + fieldDefinition, + draftValue, + hotkeyScope, + setDraftValue, + persistJsonField, + } = useJsonField(); const handleEnter = (newText: string) => { - onEnter?.(() => handlePersistField(newText)); + onEnter?.(() => persistJsonField(newText)); }; const handleEscape = (newText: string) => { - onEscape?.(() => handlePersistField(newText)); + onEscape?.(() => persistJsonField(newText)); }; const handleClickOutside = ( _event: MouseEvent | TouchEvent, newText: string, ) => { - onClickOutside?.(() => handlePersistField(newText)); + onClickOutside?.(() => persistJsonField(newText)); }; const handleTab = (newText: string) => { - onTab?.(() => handlePersistField(newText)); + onTab?.(() => persistJsonField(newText)); }; const handleShiftTab = (newText: string) => { - onShiftTab?.(() => handlePersistField(newText)); + onShiftTab?.(() => persistJsonField(newText)); }; const handleChange = (newText: string) => { setDraftValue(newText); }; - const value = - draftValue && isValidJSON(draftValue) - ? JSON.stringify(JSON.parse(draftValue), null, 2) - : draftValue ?? ''; - return ( = FieldValue extends FieldTextValue ? FieldTextDraftValue @@ -80,4 +82,6 @@ export type FieldInputDraftValue = FieldValue extends FieldTextValue ? FieldRelationDraftValue : FieldValue extends FieldAddressValue ? FieldAddressDraftValue - : never; + : FieldValue extends FieldJsonValue + ? FieldJsonDraftValue + : never; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index 2e83a5bff..03ef2f2a9 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -173,4 +173,8 @@ export type FieldSelectValue = string | null; export type FieldMultiSelectValue = string[] | null; export type FieldRelationValue = EntityForSelect | null; -export type FieldJsonValue = string; + +// See https://zod.dev/?id=json-type +type Literal = string | number | boolean | null; +export type Json = Literal | { [key: string]: Json } | Json[]; +export type FieldJsonValue = Record | Json[] | null; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDisplayedAsPhone.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDisplayedAsPhone.ts new file mode 100644 index 000000000..dcc3fbba7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDisplayedAsPhone.ts @@ -0,0 +1,14 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldDefinition } from '../FieldDefinition'; +import { FieldMetadata, FieldTextMetadata } from '../FieldMetadata'; + +// TODO: temporary - remove when 'Phone' field in 'Person' object +// is migrated to use FieldMetadataType.Phone as type. +export const isFieldDisplayedAsPhone = ( + field: Pick, 'type' | 'metadata'>, +): field is FieldDefinition => + field.metadata.objectMetadataNameSingular === CoreObjectNameSingular.Person && + field.type === FieldMetadataType.Text && + field.metadata.fieldName === 'phone'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJsonValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJsonValue.ts index 8c7657d8d..50a9f44ae 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJsonValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJsonValue.ts @@ -1,8 +1,20 @@ -import { isNull, isString } from '@sniptt/guards'; +import { z } from 'zod'; -import { FieldJsonValue } from '../FieldMetadata'; +import { FieldJsonValue, Json } from '../FieldMetadata'; + +// See https://zod.dev/?id=json-type +const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); +const jsonSchema: z.ZodType = z.lazy(() => + z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]), +); + +export const jsonWithoutLiteralsSchema: z.ZodType = z.union([ + z.null(), // Exclude literal values other than null + z.array(jsonSchema), + z.record(jsonSchema), +]); -// TODO: add zod export const isFieldRawJsonValue = ( fieldValue: unknown, -): fieldValue is FieldJsonValue => isString(fieldValue) || isNull(fieldValue); +): fieldValue is FieldJsonValue => + jsonWithoutLiteralsSchema.safeParse(fieldValue).success; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts index 72c024f7a..e37880251 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts @@ -3,6 +3,8 @@ import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldIn import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency'; import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue'; +import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; +import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue'; import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; import { computeEmptyDraftValue } from '@/object-record/record-field/utils/computeEmptyDraftValue'; import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty'; @@ -33,9 +35,20 @@ export const computeDraftValueFromFieldValue = ({ currencyCode: fieldValue?.currencyCode ?? '', } as unknown as FieldInputDraftValue; } + if (isFieldRelation(fieldDefinition)) { return computeEmptyDraftValue({ fieldDefinition }); } + if (isFieldRawJson(fieldDefinition)) { + return isFieldRawJsonValue(fieldValue) + ? (JSON.stringify( + fieldValue, + null, + 2, + ) as FieldInputDraftValue) + : computeEmptyDraftValue({ fieldDefinition }); + } + return fieldValue as FieldInputDraftValue; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/computeEmptyDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/computeEmptyDraftValue.ts index 2af0ea25c..b37ec8400 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/computeEmptyDraftValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/computeEmptyDraftValue.ts @@ -8,7 +8,8 @@ import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldE import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; -import { isFieldRelationValue } from '@/object-record/record-field/types/guards/isFieldRelationValue'; +import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; +import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid'; @@ -26,7 +27,8 @@ export const computeEmptyDraftValue = ({ isFieldDateTime(fieldDefinition) || isFieldNumber(fieldDefinition) || isFieldEmail(fieldDefinition) || - isFieldRelationValue(fieldDefinition) + isFieldRelation(fieldDefinition) || + isFieldRawJson(fieldDefinition) ) { return '' as FieldInputDraftValue; } diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueJson.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueJson.ts deleted file mode 100644 index 6e577e779..000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueJson.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { isString } from '@sniptt/guards'; - -export const isValidJSON = (str: string) => { - try { - if (isString(JSON.parse(str))) { - throw new Error(`Strings are not supported as JSON: ${str}`); - } - return true; - } catch (error) { - return false; - } -}; diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsDataModelDefaultValue.tsx b/packages/twenty-front/src/modules/settings/data-model/components/SettingsDataModelDefaultValue.tsx index ce586f911..7f7cffc26 100644 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsDataModelDefaultValue.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/components/SettingsDataModelDefaultValue.tsx @@ -6,6 +6,7 @@ import { z } from 'zod'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { Select } from '@/ui/input/components/Select'; import { CardContent } from '@/ui/layout/card/components/CardContent'; +import { isDefined } from '~/utils/isDefined'; // TODO: rename to SettingsDataModelFieldBooleanForm and move to settings/data-model/fields/forms/components @@ -41,6 +42,7 @@ export const SettingsDataModelFieldBooleanForm = ({ }: SettingsDataModelFieldBooleanFormProps) => { const { control } = useFormContext(); + const isEditMode = isDefined(fieldMetadataItem?.defaultValue); const initialValue = fieldMetadataItem?.defaultValue ?? true; return ( @@ -54,6 +56,9 @@ export const SettingsDataModelFieldBooleanForm = ({ (