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:
committed by
GitHub
parent
f25d58b0d9
commit
584d90ec89
@ -1,5 +1,8 @@
|
||||
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 { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
|
||||
import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay';
|
||||
@ -59,5 +62,7 @@ export const FieldDisplay = () => {
|
||||
<SelectFieldDisplay />
|
||||
) : isFieldAddress(fieldDefinition) ? (
|
||||
<AddressFieldDisplay />
|
||||
) : isFieldRawJson(fieldDefinition) ? (
|
||||
<JsonFieldDisplay />
|
||||
) : null;
|
||||
};
|
||||
|
||||
@ -2,9 +2,11 @@ import { useContext } from 'react';
|
||||
|
||||
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 { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
|
||||
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
|
||||
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
|
||||
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 { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
|
||||
|
||||
@ -137,6 +139,14 @@ export const FieldInput = ({
|
||||
onTab={onTab}
|
||||
onShiftTab={onShiftTab}
|
||||
/>
|
||||
) : isFieldRawJson(fieldDefinition) ? (
|
||||
<RawJsonFieldInput
|
||||
onEnter={onEnter}
|
||||
onEscape={onEscape}
|
||||
onClickOutside={onClickOutside}
|
||||
onTab={onTab}
|
||||
onShiftTab={onShiftTab}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
@ -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 { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||
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 { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue';
|
||||
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
|
||||
@ -88,6 +90,10 @@ export const usePersistField = () => {
|
||||
isFieldAddress(fieldDefinition) &&
|
||||
isFieldAddressValue(valueToPersist);
|
||||
|
||||
const fieldIsRawJson =
|
||||
isFieldRawJson(fieldDefinition) &&
|
||||
isFieldRawJsonValue(valueToPersist);
|
||||
|
||||
if (
|
||||
fieldIsRelation ||
|
||||
fieldIsText ||
|
||||
@ -101,7 +107,8 @@ export const usePersistField = () => {
|
||||
fieldIsCurrency ||
|
||||
fieldIsFullName ||
|
||||
fieldIsSelect ||
|
||||
fieldIsAddress
|
||||
fieldIsAddress ||
|
||||
fieldIsRawJson
|
||||
) {
|
||||
const fieldName = fieldDefinition.metadata.fieldName;
|
||||
set(
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -78,6 +78,7 @@ export type FieldAddressMetadata = {
|
||||
export type FieldRawJsonMetadata = {
|
||||
objectMetadataNameSingular?: string;
|
||||
fieldName: string;
|
||||
placeHolder: string;
|
||||
};
|
||||
|
||||
export type FieldDefinitionRelationType =
|
||||
@ -146,3 +147,4 @@ export type FieldRatingValue = (typeof RATING_VALUES)[number];
|
||||
export type FieldSelectValue = string | null;
|
||||
|
||||
export type FieldRelationValue = EntityForSelect | null;
|
||||
export type FieldJsonValue = string;
|
||||
|
||||
@ -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);
|
||||
@ -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 { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
|
||||
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 { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
|
||||
import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue';
|
||||
@ -39,7 +40,8 @@ export const isFieldValueEmpty = ({
|
||||
isFieldRating(fieldDefinition) ||
|
||||
isFieldEmail(fieldDefinition) ||
|
||||
isFieldBoolean(fieldDefinition) ||
|
||||
isFieldRelation(fieldDefinition)
|
||||
isFieldRelation(fieldDefinition) ||
|
||||
isFieldRawJson(fieldDefinition)
|
||||
//|| isFieldPhone(fieldDefinition)
|
||||
) {
|
||||
return isValueEmpty(fieldValue);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
@ -75,6 +75,9 @@ export const generateEmptyFieldValue = (
|
||||
case FieldMetadataType.MultiSelect: {
|
||||
throw new Error('Not implemented yet');
|
||||
}
|
||||
case FieldMetadataType.RawJson: {
|
||||
return null;
|
||||
}
|
||||
default: {
|
||||
throw new Error('Unhandled FieldMetadataType');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user