Create new field type JSON (#4729)

### Description
Create new field type JSON

### Refs
https://github.com/twentyhq/twenty/issues/3900

### Demo


https://github.com/twentyhq/twenty/assets/140154534/9ebdf4d4-f332-4940-b9d8-d9cf91935b67

Fixes #3900

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
This commit is contained in:
gitstart-app[bot] 2024-04-11 11:41:36 +02:00 committed by GitHub
parent f25d58b0d9
commit 584d90ec89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 245 additions and 8 deletions

View File

@ -1,5 +1,8 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { JsonFieldDisplay } from '@/object-record/record-field/meta-types/display/components/JsonFieldDisplay';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { FieldContext } from '../contexts/FieldContext'; import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay'; import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay'; import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay';
@ -59,5 +62,7 @@ export const FieldDisplay = () => {
<SelectFieldDisplay /> <SelectFieldDisplay />
) : isFieldAddress(fieldDefinition) ? ( ) : isFieldAddress(fieldDefinition) ? (
<AddressFieldDisplay /> <AddressFieldDisplay />
) : isFieldRawJson(fieldDefinition) ? (
<JsonFieldDisplay />
) : null; ) : null;
}; };

View File

@ -2,9 +2,11 @@ import { useContext } from 'react';
import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput'; import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput';
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput'; import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput'; import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope'; import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
@ -137,6 +139,14 @@ export const FieldInput = ({
onTab={onTab} onTab={onTab}
onShiftTab={onShiftTab} onShiftTab={onShiftTab}
/> />
) : isFieldRawJson(fieldDefinition) ? (
<RawJsonFieldInput
onEnter={onEnter}
onEscape={onEscape}
onClickOutside={onClickOutside}
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : ( ) : (
<></> <></>
)} )}

View File

@ -5,6 +5,8 @@ import { isFieldAddress } from '@/object-record/record-field/types/guards/isFiel
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue'; import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue'; import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
@ -88,6 +90,10 @@ export const usePersistField = () => {
isFieldAddress(fieldDefinition) && isFieldAddress(fieldDefinition) &&
isFieldAddressValue(valueToPersist); isFieldAddressValue(valueToPersist);
const fieldIsRawJson =
isFieldRawJson(fieldDefinition) &&
isFieldRawJsonValue(valueToPersist);
if ( if (
fieldIsRelation || fieldIsRelation ||
fieldIsText || fieldIsText ||
@ -101,7 +107,8 @@ export const usePersistField = () => {
fieldIsCurrency || fieldIsCurrency ||
fieldIsFullName || fieldIsFullName ||
fieldIsSelect || fieldIsSelect ||
fieldIsAddress fieldIsAddress ||
fieldIsRawJson
) { ) {
const fieldName = fieldDefinition.metadata.fieldName; const fieldName = fieldDefinition.metadata.fieldName;
set( set(

View File

@ -0,0 +1,13 @@
import { useJsonField } from '@/object-record/record-field/meta-types/hooks/useJsonField';
import { JsonDisplay } from '@/ui/field/display/components/JsonDisplay';
export const JsonFieldDisplay = () => {
const { fieldValue, maxWidth } = useJsonField();
return (
<JsonDisplay
text={fieldValue ? JSON.stringify(JSON.parse(fieldValue), null, 2) : ''}
maxWidth={maxWidth}
/>
);
};

View File

@ -0,0 +1,48 @@
import { useContext } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
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';
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 } =
useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.RawJson,
isFieldRawJson,
fieldDefinition,
);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<FieldJsonValue>(
recordStoreFamilySelector({
recordId: entityId,
fieldName: fieldName,
}),
);
const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : '';
const { setDraftValue, getDraftValueSelector } =
useRecordFieldInput<FieldJsonValue>(`${entityId}-${fieldName}`);
const draftValue = useRecoilValue(getDraftValueSelector());
return {
draftValue,
setDraftValue,
maxWidth,
fieldDefinition,
fieldValue: fieldTextValue,
setFieldValue,
hotkeyScope,
};
};

View File

@ -0,0 +1,83 @@
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';
export type RawJsonFieldInputProps = {
onClickOutside?: FieldInputEvent;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
onTab?: FieldInputEvent;
onShiftTab?: FieldInputEvent;
};
export const RawJsonFieldInput = ({
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
}: RawJsonFieldInputProps) => {
const { fieldDefinition, draftValue, hotkeyScope, setDraftValue } =
useJsonField();
const persistField = usePersistField();
const handlePersistField = (newText: string) => {
if (!newText || isValidJSON(newText)) persistField(newText || null);
};
const handleEnter = (newText: string) => {
onEnter?.(() => handlePersistField(newText));
};
const handleEscape = (newText: string) => {
onEscape?.(() => handlePersistField(newText));
};
const handleClickOutside = (
_event: MouseEvent | TouchEvent,
newText: string,
) => {
onClickOutside?.(() => handlePersistField(newText));
};
const handleTab = (newText: string) => {
onTab?.(() => handlePersistField(newText));
};
const handleShiftTab = (newText: string) => {
onShiftTab?.(() => handlePersistField(newText));
};
const handleChange = (newText: string) => {
setDraftValue(newText);
};
const value =
draftValue && isValidJSON(draftValue)
? JSON.stringify(JSON.parse(draftValue), null, 2)
: draftValue ?? '';
return (
<FieldTextAreaOverlay>
<TextAreaInput
placeholder={fieldDefinition.metadata.placeHolder}
autoFocus
value={value}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onTab={handleTab}
hotkeyScope={hotkeyScope}
onChange={handleChange}
maxRows={25}
/>
</FieldTextAreaOverlay>
);
};

View File

@ -78,6 +78,7 @@ export type FieldAddressMetadata = {
export type FieldRawJsonMetadata = { export type FieldRawJsonMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
placeHolder: string;
}; };
export type FieldDefinitionRelationType = export type FieldDefinitionRelationType =
@ -146,3 +147,4 @@ export type FieldRatingValue = (typeof RATING_VALUES)[number];
export type FieldSelectValue = string | null; export type FieldSelectValue = string | null;
export type FieldRelationValue = EntityForSelect | null; export type FieldRelationValue = EntityForSelect | null;
export type FieldJsonValue = string;

View File

@ -0,0 +1,8 @@
import { isNull, isString } from '@sniptt/guards';
import { FieldJsonValue } from '../FieldMetadata';
// TODO: add zod
export const isFieldRawJsonValue = (
fieldValue: unknown,
): fieldValue is FieldJsonValue => isString(fieldValue) || isNull(fieldValue);

View File

@ -13,6 +13,7 @@ import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLi
import { isFieldLinkValue } from '@/object-record/record-field/types/guards/isFieldLinkValue'; import { isFieldLinkValue } from '@/object-record/record-field/types/guards/isFieldLinkValue';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating'; import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue'; import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue';
@ -39,7 +40,8 @@ export const isFieldValueEmpty = ({
isFieldRating(fieldDefinition) || isFieldRating(fieldDefinition) ||
isFieldEmail(fieldDefinition) || isFieldEmail(fieldDefinition) ||
isFieldBoolean(fieldDefinition) || isFieldBoolean(fieldDefinition) ||
isFieldRelation(fieldDefinition) isFieldRelation(fieldDefinition) ||
isFieldRawJson(fieldDefinition)
//|| isFieldPhone(fieldDefinition) //|| isFieldPhone(fieldDefinition)
) { ) {
return isValueEmpty(fieldValue); return isValueEmpty(fieldValue);

View File

@ -0,0 +1,12 @@
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;
}
};

View File

@ -75,6 +75,9 @@ export const generateEmptyFieldValue = (
case FieldMetadataType.MultiSelect: { case FieldMetadataType.MultiSelect: {
throw new Error('Not implemented yet'); throw new Error('Not implemented yet');
} }
case FieldMetadataType.RawJson: {
return null;
}
default: { default: {
throw new Error('Unhandled FieldMetadataType'); throw new Error('Unhandled FieldMetadataType');
} }

View File

@ -2,6 +2,7 @@ import {
IconCalendarEvent, IconCalendarEvent,
IconCheck, IconCheck,
IconCoins, IconCoins,
IconJson,
IconKey, IconKey,
IconLink, IconLink,
IconMail, IconMail,
@ -117,4 +118,9 @@ export const SETTINGS_FIELD_TYPE_CONFIGS: Record<
addressLng: -118.2437, addressLng: -118.2437,
}, },
}, },
[FieldMetadataType.RawJson]: {
label: 'JSON',
Icon: IconJson,
defaultValue: `{ "key": "value" }`,
},
}; };

View File

@ -69,6 +69,7 @@ const previewableTypes = [
FieldMetadataType.Relation, FieldMetadataType.Relation,
FieldMetadataType.Text, FieldMetadataType.Text,
FieldMetadataType.Address, FieldMetadataType.Address,
FieldMetadataType.RawJson,
]; ];
export const SettingsDataModelFieldSettingsFormCard = ({ export const SettingsDataModelFieldSettingsFormCard = ({

View File

@ -2,5 +2,5 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
export type SettingsSupportedFieldType = Exclude< export type SettingsSupportedFieldType = Exclude<
FieldMetadataType, FieldMetadataType,
FieldMetadataType.Position | FieldMetadataType.RawJson FieldMetadataType.Position
>; >;

View File

@ -35,6 +35,7 @@ export type AppTooltipProps = {
className?: string; className?: string;
anchorSelect?: string; anchorSelect?: string;
content?: string; content?: string;
children?: React.ReactNode;
delayHide?: number; delayHide?: number;
offset?: number; offset?: number;
noArrow?: boolean; noArrow?: boolean;
@ -53,6 +54,7 @@ export const AppTooltip = ({
offset, offset,
place, place,
positionStrategy, positionStrategy,
children,
}: AppTooltipProps) => ( }: AppTooltipProps) => (
<StyledAppTooltip <StyledAppTooltip
{...{ {...{
@ -65,6 +67,7 @@ export const AppTooltip = ({
offset, offset,
place, place,
positionStrategy, positionStrategy,
children,
}} }}
/> />
); );

View File

@ -22,9 +22,11 @@ const StyledOverflowingText = styled.div<{ cursorPointer: boolean }>`
export const OverflowingTextWithTooltip = ({ export const OverflowingTextWithTooltip = ({
text, text,
className, className,
mutliline,
}: { }: {
text: string | null | undefined; text: string | null | undefined;
className?: string; className?: string;
mutliline?: boolean;
}) => { }) => {
const textElementId = `title-id-${uuidV4()}`; const textElementId = `title-id-${uuidV4()}`;
@ -65,13 +67,15 @@ export const OverflowingTextWithTooltip = ({
<div onClick={handleTooltipClick}> <div onClick={handleTooltipClick}>
<AppTooltip <AppTooltip
anchorSelect={`#${textElementId}`} anchorSelect={`#${textElementId}`}
content={text ?? ''} content={mutliline ? undefined : text ?? ''}
delayHide={0} delayHide={0}
offset={5} offset={5}
noArrow noArrow
place="bottom" place="bottom"
positionStrategy="absolute" positionStrategy="absolute"
/> >
{mutliline ? <pre>{text}</pre> : ''}
</AppTooltip>
</div>, </div>,
document.body, document.body,
)} )}

View File

@ -0,0 +1,10 @@
import { EllipsisDisplay } from './EllipsisDisplay';
type JsonDisplayProps = {
text: string;
maxWidth?: number;
};
export const JsonDisplay = ({ text, maxWidth }: JsonDisplayProps) => (
<EllipsisDisplay maxWidth={maxWidth}>{text}</EllipsisDisplay>
);

View File

@ -19,6 +19,7 @@ export type TextAreaInputProps = {
onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void; onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void;
hotkeyScope: string; hotkeyScope: string;
onChange?: (newText: string) => void; onChange?: (newText: string) => void;
maxRows?: number;
}; };
const StyledTextArea = styled(TextareaAutosize)` const StyledTextArea = styled(TextareaAutosize)`
@ -45,6 +46,7 @@ export const TextAreaInput = ({
onShiftTab, onShiftTab,
onClickOutside, onClickOutside,
onChange, onChange,
maxRows,
}: TextAreaInputProps) => { }: TextAreaInputProps) => {
const [internalText, setInternalText] = useState(value); const [internalText, setInternalText] = useState(value);
@ -84,6 +86,7 @@ export const TextAreaInput = ({
onChange={handleChange} onChange={handleChange}
autoFocus={autoFocus} autoFocus={autoFocus}
value={internalText} value={internalText}
maxRows={maxRows}
/> />
); );
}; };

View File

@ -1,3 +1,4 @@
import { JsonScalarType } from './json.scalar';
import { PositionScalarType } from './position.scalar'; import { PositionScalarType } from './position.scalar';
import { CursorScalarType } from './cursor.scalar'; import { CursorScalarType } from './cursor.scalar';
import { BigFloatScalarType } from './big-float.scalar'; import { BigFloatScalarType } from './big-float.scalar';
@ -24,4 +25,5 @@ export const scalars = [
UUIDScalarType, UUIDScalarType,
CursorScalarType, CursorScalarType,
PositionScalarType, PositionScalarType,
JsonScalarType,
]; ];

View File

@ -0,0 +1,14 @@
import { isString } from 'class-validator';
import { GraphQLScalarType } from 'graphql';
import GraphQLJSON from 'graphql-type-json';
export const JsonScalarType = new GraphQLScalarType({
...GraphQLJSON,
parseValue: (value) => {
if (isString(value) && isString(JSON.parse(value))) {
throw new Error(`Strings are not supported as JSON: ${value}`);
}
return GraphQLJSON.parseValue(value);
},
});

View File

@ -14,7 +14,6 @@ import {
GraphQLString, GraphQLString,
GraphQLType, GraphQLType,
} from 'graphql'; } from 'graphql';
import GraphQLJSON from 'graphql-type-json';
import { import {
DateScalarMode, DateScalarMode,
@ -39,6 +38,7 @@ import {
UUIDScalarType, UUIDScalarType,
} from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { PositionScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars/position.scalar'; import { PositionScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars/position.scalar';
import { JsonScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars/json.scalar';
export interface TypeOptions<T = any> { export interface TypeOptions<T = any> {
nullable?: boolean; nullable?: boolean;
@ -71,7 +71,7 @@ export class TypeMapperService {
[FieldMetadataType.PROBABILITY, GraphQLFloat], [FieldMetadataType.PROBABILITY, GraphQLFloat],
[FieldMetadataType.RELATION, UUIDScalarType], [FieldMetadataType.RELATION, UUIDScalarType],
[FieldMetadataType.POSITION, PositionScalarType], [FieldMetadataType.POSITION, PositionScalarType],
[FieldMetadataType.RAW_JSON, GraphQLJSON], [FieldMetadataType.RAW_JSON, JsonScalarType],
]); ]);
return typeScalarMapping.get(fieldMetadataType); return typeScalarMapping.get(fieldMetadataType);

View File

@ -72,7 +72,7 @@ const getSchemaComponentsProperties = (
}; };
break; break;
case FieldMetadataType.RAW_JSON: case FieldMetadataType.RAW_JSON:
type: 'object'; itemProperty.type = 'object';
break; break;
default: default:
itemProperty.type = 'string'; itemProperty.type = 'string';

View File

@ -82,6 +82,7 @@ export {
IconHierarchy2, IconHierarchy2,
IconInbox, IconInbox,
IconInfoCircle, IconInfoCircle,
IconJson,
IconKey, IconKey,
IconLanguage, IconLanguage,
IconLayersLinked, IconLayersLinked,