feat: add Relation field form (#2572)

* feat: add useCreateOneRelationMetadata and useRelationMetadata

Closes #2423

* feat: add Relation field form

Closes #2003

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thaïs
2023-11-17 23:38:39 +01:00
committed by GitHub
parent fea0bbeb2a
commit 18dac1a2b6
34 changed files with 1285 additions and 643 deletions

View File

@ -35,6 +35,11 @@ export const useObjectMetadataItemForSettings = () => {
(objectMetadataItem) => objectMetadataItem.id === id,
);
const findObjectMetadataItemByNamePlural = (namePlural: string) =>
objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.namePlural === namePlural,
);
const { createOneObjectMetadataItem } =
useCreateOneObjectRecordMetadataItem();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
@ -88,6 +93,8 @@ export const useObjectMetadataItemForSettings = () => {
eraseObjectMetadataItem,
findActiveObjectMetadataItemBySlug,
findObjectMetadataItemById,
findObjectMetadataItemByNamePlural,
loading,
objectMetadataItems,
};
};

View File

@ -1,39 +1,51 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldDefinitionRelationType } from '@/ui/object/field/types/FieldDefinition';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
export const parseFieldRelationType = (
field: FieldMetadataItem | undefined,
): FieldDefinitionRelationType | undefined => {
if (field && field.type === 'RELATION') {
if (
isDefined(field.fromRelationMetadata) &&
field.fromRelationMetadata.relationType === 'ONE_TO_MANY'
) {
return 'FROM_NAMY_OBJECTS';
} else if (
isDefined(field.toRelationMetadata) &&
field.toRelationMetadata.relationType === 'ONE_TO_MANY'
) {
return 'TO_ONE_OBJECT';
} else if (
isDefined(field.fromRelationMetadata) &&
field.fromRelationMetadata.relationType === 'MANY_TO_MANY'
) {
return 'FROM_NAMY_OBJECTS';
} else if (
isDefined(field.toRelationMetadata) &&
field.toRelationMetadata.relationType === 'MANY_TO_MANY'
) {
return 'TO_MANY_OBJECTS';
}
if (!field || field.type !== FieldMetadataType.Relation) return;
throw new Error(
`Cannot determine field relation type for field : ${JSON.stringify(
field,
)}.`,
);
} else {
return undefined;
const config: Record<
RelationMetadataType,
{ from: FieldDefinitionRelationType; to: FieldDefinitionRelationType }
> = {
[RelationMetadataType.ManyToMany]: {
from: 'FROM_MANY_OBJECTS',
to: 'TO_MANY_OBJECTS',
},
[RelationMetadataType.OneToMany]: {
from: 'FROM_MANY_OBJECTS',
to: 'TO_ONE_OBJECT',
},
[RelationMetadataType.OneToOne]: {
from: 'FROM_ONE_OBJECT',
to: 'TO_ONE_OBJECT',
},
};
if (
isDefined(field.fromRelationMetadata) &&
field.fromRelationMetadata.relationType in config
) {
return config[field.fromRelationMetadata.relationType].from;
}
if (
isDefined(field.toRelationMetadata) &&
field.toRelationMetadata.relationType in config
) {
return config[field.toRelationMetadata.relationType].to;
}
throw new Error(
`Cannot determine field relation type for field : ${JSON.stringify(
field,
)}.`,
);
};

View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 58 46" width="58" height="46" fill="none" preserveAspectRatio="xMidYMid meet">
<rect width="9" height="9" x=".5" y="18.5" stroke="#EBEBEB" rx="2.5" />
<rect width="4" height="4" x="3" y="21" fill="#D6D6D6" rx="1" />
<rect width="9" height="9" x="48.5" y=".5" stroke="#EBEBEB" rx="2.5" />
<rect width="4" height="4" x="51" y="3" fill="#D6D6D6" rx="1" />
<rect width="9" height="9" x="48.5" y="18.5" stroke="#EBEBEB" rx="2.5" />
<rect width="4" height="4" x="51" y="21" fill="#D6D6D6" rx="1" />
<rect width="9" height="9" x="48.5" y="36.5" stroke="#EBEBEB" rx="2.5" />
<rect width="4" height="4" x="51" y="39" fill="#D6D6D6" rx="1" />
<path fill="#D6D6D6" d="M5.113 22.5h48v1h-48v-1Z" />
<path stroke="#D6D6D6" d="M52.884 41H45.06a7.544 7.544 0 0 1-7.56-7.561V12.56A7.544 7.544 0 0 1 45.06 5h7.793" />
</svg>

After

Width:  |  Height:  |  Size: 870 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="58" height="46" fill="none" viewBox="0 0 58 46">
<rect width="9" height="9" x=".5" y="18.5" stroke="#EBEBEB" rx="2.5"/>
<rect width="4" height="4" x="3" y="21" fill="#D6D6D6" rx="1"/>
<rect width="9" height="9" x="48.5" y="18.5" stroke="#EBEBEB" rx="2.5"/>
<rect width="4" height="4" x="51" y="21" fill="#D6D6D6" rx="1"/>
<path fill="#D6D6D6" d="M5.113 22.5h48v1h-48v-1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 437 B

View File

@ -1,30 +1,25 @@
import { useEffect } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { parseFieldType } from '@/object-metadata/utils/parseFieldType';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
import { Tag } from '@/ui/display/tag/components/Tag';
import { useLazyLoadIcon } from '@/ui/input/hooks/useLazyLoadIcon';
import { FieldDisplay } from '@/ui/object/field/components/FieldDisplay';
import { FieldContext } from '@/ui/object/field/contexts/FieldContext';
import { BooleanFieldInput } from '@/ui/object/field/meta-types/input/components/BooleanFieldInput';
import { entityFieldsFamilySelector } from '@/ui/object/field/states/selectors/entityFieldsFamilySelector';
import { FieldMetadataType } from '~/generated/graphql';
import { assertNotNull } from '~/utils/assert';
import { Field } from '~/generated/graphql';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { SettingsObjectFieldPreviewValueEffect } from '../components/SettingsObjectFieldPreviewValueEffect';
import { dataTypes } from '../constants/dataTypes';
import { useFieldPreview } from '../hooks/useFieldPreview';
import { useRelationFieldPreview } from '../hooks/useRelationFieldPreview';
export type SettingsObjectFieldPreviewProps = {
fieldIconKey?: string | null;
fieldLabel: string;
fieldName?: string;
fieldType: FieldMetadataType;
isObjectCustom: boolean;
objectIconKey?: string | null;
objectLabelPlural: string;
objectNamePlural: string;
className?: string;
fieldMetadata: Pick<Field, 'icon' | 'label' | 'type'> & { id?: string };
objectMetadataId: string;
relationObjectMetadataId?: string;
shrink?: boolean;
};
const StyledContainer = styled.div`
@ -52,7 +47,7 @@ const StyledObjectName = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledFieldPreview = styled.div`
const StyledFieldPreview = styled.div<{ shrink?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
@ -61,7 +56,8 @@ const StyledFieldPreview = styled.div`
gap: ${({ theme }) => theme.spacing(2)};
height: ${({ theme }) => theme.spacing(8)};
overflow: hidden;
padding: 0 ${({ theme }) => theme.spacing(2)};
padding: 0
${({ shrink, theme }) => (shrink ? theme.spacing(1) : theme.spacing(2))};
white-space: nowrap;
`;
@ -73,41 +69,41 @@ const StyledFieldLabel = styled.div`
`;
export const SettingsObjectFieldPreview = ({
fieldIconKey,
fieldLabel,
fieldName,
fieldType,
isObjectCustom,
objectIconKey,
objectLabelPlural,
objectNamePlural,
className,
fieldMetadata,
objectMetadataId,
relationObjectMetadataId,
shrink,
}: SettingsObjectFieldPreviewProps) => {
const theme = useTheme();
const { Icon: ObjectIcon } = useLazyLoadIcon(objectIconKey ?? '');
const { Icon: FieldIcon } = useLazyLoadIcon(fieldIconKey ?? '');
const { objects } = useFindManyObjectRecords({
objectNamePlural,
skip: !fieldName,
const {
entityId,
FieldIcon,
fieldName,
hasValue,
ObjectIcon,
objectMetadataItem,
value,
} = useFieldPreview({
fieldMetadata,
objectMetadataId,
});
const [fieldValue, setFieldValue] = useRecoilState(
entityFieldsFamilySelector({
entityId: objects[0]?.id ?? objectNamePlural,
fieldName: fieldName || 'new-field',
}),
);
const { defaultValue: relationDefaultValue, entityChipDisplayMapper } =
useRelationFieldPreview({
relationObjectMetadataId,
skipDefaultValue:
fieldMetadata.type !== FieldMetadataType.Relation || hasValue,
});
useEffect(() => {
setFieldValue(
fieldName && assertNotNull(objects[0]?.[fieldName])
? objects[0][fieldName]
: dataTypes[fieldType].defaultValue,
);
}, [fieldName, fieldType, fieldValue, objects, setFieldValue]);
const defaultValue =
fieldMetadata.type === FieldMetadataType.Relation
? relationDefaultValue
: dataTypes[fieldMetadata.type].defaultValue;
return (
<StyledContainer>
<StyledContainer className={className}>
<StyledObjectSummary>
<StyledObjectName>
{!!ObjectIcon && (
@ -116,15 +112,20 @@ export const SettingsObjectFieldPreview = ({
stroke={theme.icon.stroke.sm}
/>
)}
{objectLabelPlural}
{objectMetadataItem?.labelPlural}
</StyledObjectName>
{isObjectCustom ? (
{objectMetadataItem?.isCustom ? (
<Tag color="orange" text="Custom" />
) : (
<Tag color="blue" text="Standard" />
)}
</StyledObjectSummary>
<StyledFieldPreview>
<SettingsObjectFieldPreviewValueEffect
entityId={entityId}
fieldName={fieldName}
value={value ?? defaultValue}
/>
<StyledFieldPreview shrink={shrink}>
<StyledFieldLabel>
{!!FieldIcon && (
<FieldIcon
@ -132,22 +133,26 @@ export const SettingsObjectFieldPreview = ({
stroke={theme.icon.stroke.sm}
/>
)}
{fieldLabel}:
{fieldMetadata.label}:
</StyledFieldLabel>
<FieldContext.Provider
value={{
entityId: objects[0]?.id ?? objectNamePlural,
entityId,
fieldDefinition: {
type: parseFieldType(fieldType as FieldMetadataType),
type: parseFieldType(fieldMetadata.type),
Icon: FieldIcon,
fieldMetadataId: '',
label: fieldLabel,
metadata: { fieldName: fieldName || 'new-field' },
fieldMetadataId: fieldMetadata.id || '',
label: fieldMetadata.label,
metadata: { fieldName },
entityChipDisplayMapper:
fieldMetadata.type === FieldMetadataType.Relation
? entityChipDisplayMapper
: undefined,
},
hotkeyScope: 'field-preview',
}}
>
{fieldType === 'BOOLEAN' ? (
{fieldMetadata.type === FieldMetadataType.Boolean ? (
<BooleanFieldInput readonly />
) : (
<FieldDisplay />

View File

@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { entityFieldsFamilySelector } from '@/ui/object/field/states/selectors/entityFieldsFamilySelector';
type SettingsObjectFieldPreviewValueEffectProps = {
entityId: string;
fieldName: string;
value: unknown;
};
export const SettingsObjectFieldPreviewValueEffect = ({
entityId,
fieldName,
value,
}: SettingsObjectFieldPreviewValueEffectProps) => {
const [, setFieldValue] = useRecoilState(
entityFieldsFamilySelector({
entityId,
fieldName,
}),
);
useEffect(() => {
setFieldValue(value);
}, [value, setFieldValue]);
return null;
};

View File

@ -0,0 +1,123 @@
import styled from '@emotion/styled';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { validateMetadataLabel } from '@/object-metadata/utils/validateMetadataLabel';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import { useLazyLoadIcons } from '@/ui/input/hooks/useLazyLoadIcons';
import { Field } from '~/generated-metadata/graphql';
import { relationTypes } from '../constants/relationTypes';
import { RelationType } from '../types/RelationType';
export type SettingsObjectFieldRelationFormValues = Partial<{
field: Partial<Pick<Field, 'icon' | 'label'>>;
objectMetadataId: string;
type: RelationType;
}>;
type SettingsObjectFieldRelationFormProps = {
disableRelationEdition?: boolean;
onChange: (values: SettingsObjectFieldRelationFormValues) => void;
values?: SettingsObjectFieldRelationFormValues;
};
const StyledSelectsContainer = styled.div`
display: grid;
gap: ${({ theme }) => theme.spacing(4)};
grid-template-columns: 1fr 1fr;
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledInputsLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export const SettingsObjectFieldRelationForm = ({
disableRelationEdition,
onChange,
values,
}: SettingsObjectFieldRelationFormProps) => {
const { icons } = useLazyLoadIcons();
const { objectMetadataItems, findObjectMetadataItemById } =
useObjectMetadataItemForSettings();
const selectedObjectMetadataItem =
(values?.objectMetadataId
? findObjectMetadataItemById(values.objectMetadataId)
: undefined) || objectMetadataItems[0];
return (
<div>
<StyledSelectsContainer>
<Select
label="Relation type"
dropdownScopeId="relation-type-select"
disabled={disableRelationEdition}
value={values?.type}
options={Object.entries(relationTypes).map(
([value, { label, Icon }]) => ({
label,
value: value as RelationType,
Icon,
}),
)}
onChange={(value) => onChange({ type: value })}
/>
<Select
label="Object destination"
dropdownScopeId="object-destination-select"
disabled={disableRelationEdition}
value={values?.objectMetadataId}
options={objectMetadataItems.map((objectMetadataItem) => ({
label: objectMetadataItem.labelPlural,
value: objectMetadataItem.id,
Icon: objectMetadataItem.icon
? icons[objectMetadataItem.icon]
: undefined,
}))}
onChange={(value) => onChange({ objectMetadataId: value })}
/>
</StyledSelectsContainer>
<StyledInputsLabel>
Field on {selectedObjectMetadataItem?.labelPlural}
</StyledInputsLabel>
<StyledInputsContainer>
<IconPicker
dropdownScopeId="field-destination-icon-picker"
selectedIconKey={values?.field?.icon || undefined}
onChange={(value) =>
onChange({
field: { ...values?.field, icon: value.iconKey },
})
}
variant="primary"
/>
<TextInput
placeholder="Field name"
value={values?.field?.label || ''}
onChange={(value) => {
if (!value || validateMetadataLabel(value)) {
onChange({
field: { ...values?.field, label: value },
});
}
}}
fullWidth
/>
</StyledInputsContainer>
</div>
);
};

View File

@ -27,6 +27,11 @@ const StyledTitle = styled.h3`
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledPreviewContent = styled.div`
display: flex;
gap: 6px;
`;
const StyledFormContainer = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
@ -46,7 +51,7 @@ export const SettingsObjectFieldTypeCard = ({
<div className={className}>
<StyledPreviewContainer>
<StyledTitle>Preview</StyledTitle>
{preview}
<StyledPreviewContent>{preview}</StyledPreviewContent>
</StyledPreviewContainer>
{!!form && <StyledFormContainer>{form}</StyledFormContainer>}
</div>

View File

@ -3,82 +3,142 @@ import styled from '@emotion/styled';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { Select } from '@/ui/input/components/Select';
import { Section } from '@/ui/layout/section/components/Section';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { Field, FieldMetadataType } from '~/generated-metadata/graphql';
import { dataTypes } from '../constants/dataTypes';
import { relationTypes } from '../constants/relationTypes';
import {
SettingsObjectFieldPreview,
SettingsObjectFieldPreviewProps,
} from './SettingsObjectFieldPreview';
import {
SettingsObjectFieldRelationForm,
SettingsObjectFieldRelationFormValues,
} from './SettingsObjectFieldRelationForm';
import { SettingsObjectFieldTypeCard } from './SettingsObjectFieldTypeCard';
export type SettingsObjectFieldTypeSelectSectionFormValues = Partial<{
type: FieldMetadataType;
relation: SettingsObjectFieldRelationFormValues;
}>;
type SettingsObjectFieldTypeSelectSectionProps = {
disabled?: boolean;
onChange?: (value: FieldMetadataType) => void;
} & Pick<
SettingsObjectFieldPreviewProps,
| 'fieldIconKey'
| 'fieldLabel'
| 'fieldName'
| 'fieldType'
| 'isObjectCustom'
| 'objectIconKey'
| 'objectLabelPlural'
| 'objectNamePlural'
>;
fieldMetadata: Pick<Field, 'icon' | 'label'> & { id?: string };
relationFieldMetadataId?: string;
onChange: (values: SettingsObjectFieldTypeSelectSectionFormValues) => void;
values?: SettingsObjectFieldTypeSelectSectionFormValues;
} & Pick<SettingsObjectFieldPreviewProps, 'objectMetadataId'>;
const StyledSettingsObjectFieldTypeCard = styled(SettingsObjectFieldTypeCard)`
margin-top: ${({ theme }) => theme.spacing(4)};
`;
// TODO: remove "enum" and "relation" types for now, add them back when the backend is ready.
const { ENUM: _ENUM, RELATION: _RELATION, ...allowedDataTypes } = dataTypes;
const StyledSettingsObjectFieldPreview = styled(SettingsObjectFieldPreview)`
display: grid;
flex: 1 1 100%;
`;
const StyledRelationImage = styled.img<{ flip?: boolean }>`
transform: ${({ flip }) => (flip ? 'scaleX(-1)' : 'none')};
width: 54px;
`;
export const SettingsObjectFieldTypeSelectSection = ({
disabled,
fieldIconKey,
fieldLabel,
fieldName,
fieldType,
isObjectCustom,
objectIconKey,
objectLabelPlural,
objectNamePlural,
fieldMetadata,
relationFieldMetadataId,
objectMetadataId,
onChange,
}: SettingsObjectFieldTypeSelectSectionProps) => (
<Section>
<H2Title
title="Type and values"
description="The field's type and values."
/>
<Select
disabled={disabled}
dropdownScopeId="object-field-type-select"
value={fieldType}
onChange={onChange}
options={Object.entries(allowedDataTypes).map(([key, dataType]) => ({
value: key as FieldMetadataType,
...dataType,
}))}
/>
{['BOOLEAN', 'DATE', 'MONEY', 'NUMBER', 'TEXT', 'URL'].includes(
fieldType,
) && (
<StyledSettingsObjectFieldTypeCard
preview={
<SettingsObjectFieldPreview
fieldIconKey={fieldIconKey}
fieldLabel={fieldLabel}
fieldName={fieldName}
fieldType={fieldType}
isObjectCustom={isObjectCustom}
objectIconKey={objectIconKey}
objectLabelPlural={objectLabelPlural}
objectNamePlural={objectNamePlural}
/>
}
values,
}: SettingsObjectFieldTypeSelectSectionProps) => {
const relationFormConfig = values?.relation;
const allowedFieldTypes = Object.entries(dataTypes).filter(
([key]) => key !== FieldMetadataType.Relation,
);
return (
<Section>
<H2Title
title="Type and values"
description="The field's type and values."
/>
)}
</Section>
);
<Select
disabled={!!fieldMetadata.id}
dropdownScopeId="object-field-type-select"
value={values?.type}
onChange={(value) => onChange({ type: value })}
options={allowedFieldTypes.map(([key, dataType]) => ({
value: key as FieldMetadataType,
...dataType,
}))}
/>
{!!values?.type &&
[
FieldMetadataType.Boolean,
FieldMetadataType.Currency,
FieldMetadataType.Date,
FieldMetadataType.Link,
FieldMetadataType.Number,
FieldMetadataType.Relation,
FieldMetadataType.Text,
].includes(values.type) && (
<StyledSettingsObjectFieldTypeCard
preview={
<>
<StyledSettingsObjectFieldPreview
fieldMetadata={{
...fieldMetadata,
type: values.type,
}}
shrink={values.type === FieldMetadataType.Relation}
objectMetadataId={objectMetadataId}
relationObjectMetadataId={
relationFormConfig?.objectMetadataId
}
/>
{values.type === FieldMetadataType.Relation &&
!!relationFormConfig?.type &&
!!relationFormConfig.objectMetadataId && (
<>
<StyledRelationImage
src={relationTypes[relationFormConfig.type].imageSrc}
flip={
relationTypes[relationFormConfig.type].isImageFlipped
}
alt={relationTypes[relationFormConfig.type].label}
/>
<StyledSettingsObjectFieldPreview
fieldMetadata={{
...relationFormConfig.field,
label:
relationFormConfig.field?.label || 'Field name',
type: FieldMetadataType.Relation,
id: relationFieldMetadataId,
}}
shrink
objectMetadataId={relationFormConfig.objectMetadataId}
relationObjectMetadataId={objectMetadataId}
/>
</>
)}
</>
}
form={
values.type === FieldMetadataType.Relation && (
<SettingsObjectFieldRelationForm
disableRelationEdition={!!relationFieldMetadataId}
values={relationFormConfig}
onChange={(nextValues) =>
onChange({
relation: { ...relationFormConfig, ...nextValues },
})
}
/>
)
}
/>
)}
</Section>
);
};

View File

@ -1,91 +0,0 @@
import { MemoryRouter } from 'react-router-dom';
import { Meta, StoryObj } from '@storybook/react';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { SettingsObjectFieldPreview } from '../SettingsObjectFieldPreview';
const meta: Meta<typeof SettingsObjectFieldPreview> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldPreview',
component: SettingsObjectFieldPreview,
decorators: [ComponentDecorator],
args: {
fieldIconKey: 'IconNotes',
fieldLabel: 'Description',
fieldType: FieldMetadataType.Text,
isObjectCustom: false,
objectIconKey: 'IconBuildingSkyscraper',
objectLabelPlural: 'Companies',
objectNamePlural: 'companies',
},
};
export default meta;
type Story = StoryObj<typeof SettingsObjectFieldPreview>;
export const Text: Story = {};
export const Boolean: Story = {
args: {
fieldIconKey: 'IconHeadphones',
fieldLabel: 'Priority Support',
fieldType: FieldMetadataType.Boolean,
},
};
export const Currency: Story = {
args: {
fieldIconKey: 'IconCurrencyDollar',
fieldLabel: 'Amount',
fieldType: FieldMetadataType.Currency,
},
};
export const Date: Story = {
args: {
fieldIconKey: 'IconCalendarEvent',
fieldLabel: 'Registration Date',
fieldType: FieldMetadataType.Date,
},
};
export const Link: Story = {
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
args: {
fieldIconKey: 'IconWorldWww',
fieldLabel: 'Website',
fieldType: FieldMetadataType.Link,
},
};
export const Number: Story = {
args: {
fieldIconKey: 'IconUsers',
fieldLabel: 'Employees',
fieldType: FieldMetadataType.Number,
},
};
export const Select: Story = {
args: {
fieldIconKey: 'IconBuildingFactory2',
fieldLabel: 'Industry',
fieldType: FieldMetadataType.Enum,
},
};
export const CustomObject: Story = {
args: {
isObjectCustom: true,
objectIconKey: 'IconApps',
objectLabelPlural: 'Workspaces',
objectNamePlural: 'workspaces',
},
};

View File

@ -1,36 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { TextInput } from '@/ui/input/components/TextInput';
import { FieldMetadataType } from '~/generated/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { SettingsObjectFieldPreview } from '../SettingsObjectFieldPreview';
import { SettingsObjectFieldTypeCard } from '../SettingsObjectFieldTypeCard';
const meta: Meta<typeof SettingsObjectFieldTypeCard> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldTypeCard',
component: SettingsObjectFieldTypeCard,
decorators: [ComponentDecorator],
args: {
preview: (
<SettingsObjectFieldPreview
fieldIconKey="IconNotes"
fieldLabel="Description"
fieldType={FieldMetadataType.Text}
isObjectCustom={false}
objectIconKey="IconUser"
objectLabelPlural="People"
objectNamePlural="people"
/>
),
},
};
export default meta;
type Story = StoryObj<typeof SettingsObjectFieldTypeCard>;
export const Default: Story = {};
export const WithForm: Story = {
args: { form: <TextInput label="Lorem ipsum" placeholder="Lorem ipsum" /> },
};

View File

@ -1,7 +1,6 @@
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { SettingsObjectFieldTypeSelectSection } from '../SettingsObjectFieldTypeSelectSection';
@ -10,16 +9,7 @@ const meta: Meta<typeof SettingsObjectFieldTypeSelectSection> = {
title: 'Modules/Settings/DataModel/SettingsObjectFieldTypeSelectSection',
component: SettingsObjectFieldTypeSelectSection,
decorators: [ComponentDecorator],
args: {
fieldType: FieldMetadataType.Number,
fieldIconKey: 'IconUsers',
fieldLabel: 'Employees',
fieldName: 'employees',
isObjectCustom: false,
objectIconKey: 'IconUser',
objectLabelPlural: 'People',
objectNamePlural: 'people',
},
args: {},
};
export default meta;
@ -28,7 +18,7 @@ type Story = StoryObj<typeof SettingsObjectFieldTypeSelectSection>;
export const Default: Story = {};
export const Disabled: Story = {
args: { disabled: true },
args: {},
};
export const WithOpenSelect: Story = {

View File

@ -7,7 +7,7 @@ import {
IconMail,
IconNumbers,
IconPhone,
IconPlug,
IconRelationManyToMany,
IconTag,
IconTextSize,
IconUser,
@ -61,9 +61,12 @@ export const dataTypes: Record<
[FieldMetadataType.Currency]: {
label: 'Currency',
Icon: IconCoins,
defaultValue: { amount: 2000, currency: CurrencyCode.Usd },
defaultValue: { amountMicros: 2000000000, currencyCode: CurrencyCode.Usd },
},
[FieldMetadataType.Relation]: {
label: 'Relation',
Icon: IconRelationManyToMany,
},
[FieldMetadataType.Relation]: { label: 'Relation', Icon: IconPlug },
[FieldMetadataType.Email]: { label: 'Email', Icon: IconMail },
[FieldMetadataType.Phone]: { label: 'Phone', Icon: IconPhone },
[FieldMetadataType.Probability]: {

View File

@ -0,0 +1,34 @@
import { IconRelationOneToMany, IconRelationOneToOne } from '@/ui/display/icon';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { RelationMetadataType } from '~/generated-metadata/graphql';
import OneToManySvg from '../assets/OneToMany.svg';
import OneToOneSvg from '../assets/OneToOne.svg';
import { RelationType } from '../types/RelationType';
export const relationTypes: Record<
RelationType,
{
label: string;
Icon: IconComponent;
imageSrc: string;
isImageFlipped?: boolean;
}
> = {
[RelationMetadataType.OneToMany]: {
label: 'Has many',
Icon: IconRelationOneToMany,
imageSrc: OneToManySvg,
},
[RelationMetadataType.OneToOne]: {
label: 'Has one',
Icon: IconRelationOneToOne,
imageSrc: OneToOneSvg,
},
MANY_TO_ONE: {
label: 'Belongs to one',
Icon: IconRelationOneToMany,
imageSrc: OneToManySvg,
isImageFlipped: true,
},
};

View File

@ -0,0 +1,142 @@
import { useState } from 'react';
import { DeepPartial } from 'react-hook-form';
import { z } from 'zod';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { SettingsObjectFieldTypeSelectSectionFormValues } from '../components/SettingsObjectFieldTypeSelectSection';
type FormValues = {
description?: string;
icon: string;
label: string;
type: FieldMetadataType;
relation: SettingsObjectFieldTypeSelectSectionFormValues['relation'];
};
const defaultValues: FormValues = {
icon: 'IconUsers',
label: '',
type: FieldMetadataType.Text,
relation: {
type: RelationMetadataType.OneToMany,
},
};
const fieldSchema = z.object({
description: z.string().optional(),
icon: z.string().startsWith('Icon'),
label: z.string().min(1),
});
const relationSchema = fieldSchema.merge(
z.object({
type: z.literal(FieldMetadataType.Relation),
relation: z.object({
field: fieldSchema,
objectMetadataId: z.string().uuid(),
type: z.enum([
RelationMetadataType.OneToMany,
RelationMetadataType.OneToOne,
'MANY_TO_ONE',
]),
}),
}),
);
const { Relation: _, ...otherFieldTypes } = FieldMetadataType;
const otherFieldTypesSchema = fieldSchema.merge(
z.object({
type: z.enum(
Object.values(otherFieldTypes) as [
Exclude<FieldMetadataType, FieldMetadataType.Relation>,
...Exclude<FieldMetadataType, FieldMetadataType.Relation>[],
],
),
}),
);
const schema = z.discriminatedUnion('type', [
relationSchema,
otherFieldTypesSchema,
]);
export const useFieldMetadataForm = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [initialFormValues, setInitialFormValues] =
useState<FormValues>(defaultValues);
const [formValues, setFormValues] = useState<FormValues>(defaultValues);
const [hasFieldFormChanged, setHasFieldFormChanged] = useState(false);
const [hasRelationFormChanged, setHasRelationFormChanged] = useState(false);
const [validationResult, setValidationResult] = useState(
schema.safeParse(formValues),
);
const mergePartialValues = (
previousValues: FormValues,
nextValues: DeepPartial<FormValues>,
) => ({
...previousValues,
...nextValues,
relation: {
...previousValues.relation,
...nextValues.relation,
field: {
...previousValues.relation?.field,
...nextValues.relation?.field,
},
},
});
const initForm = (lazyInitialFormValues: DeepPartial<FormValues>) => {
if (isInitialized) return;
const mergedFormValues = mergePartialValues(
initialFormValues,
lazyInitialFormValues,
);
setInitialFormValues(mergedFormValues);
setFormValues(mergedFormValues);
setValidationResult(schema.safeParse(mergedFormValues));
setIsInitialized(true);
};
const handleFormChange = (values: DeepPartial<FormValues>) => {
const nextFormValues = mergePartialValues(formValues, values);
setFormValues(nextFormValues);
setValidationResult(schema.safeParse(nextFormValues));
const { relation: initialRelationFormValues, ...initialFieldFormValues } =
initialFormValues;
const { relation: nextRelationFormValues, ...nextFieldFormValues } =
nextFormValues;
setHasFieldFormChanged(
!isDeeplyEqual(initialFieldFormValues, nextFieldFormValues),
);
setHasRelationFormChanged(
nextFieldFormValues.type === FieldMetadataType.Relation &&
!isDeeplyEqual(initialRelationFormValues, nextRelationFormValues),
);
};
return {
formValues,
handleFormChange,
hasFieldFormChanged,
hasFormChanged: hasFieldFormChanged || hasRelationFormChanged,
hasRelationFormChanged,
initForm,
isValid: validationResult.success,
validatedFormValues: validationResult.success
? validationResult.data
: undefined,
};
};

View File

@ -0,0 +1,40 @@
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
import { useLazyLoadIcon } from '@/ui/input/hooks/useLazyLoadIcon';
import { Field } from '~/generated-metadata/graphql';
import { assertNotNull } from '~/utils/assert';
export const useFieldPreview = ({
fieldMetadata,
objectMetadataId,
}: {
fieldMetadata: Partial<Pick<Field, 'icon' | 'id' | 'type'>>;
objectMetadataId: string;
}) => {
const { findObjectMetadataItemById } = useObjectMetadataItemForSettings();
const objectMetadataItem = findObjectMetadataItemById(objectMetadataId);
const { objects } = useFindManyObjectRecords({
objectNamePlural: objectMetadataItem?.namePlural,
skip: !objectMetadataItem || !fieldMetadata.id,
});
const { Icon: ObjectIcon } = useLazyLoadIcon(objectMetadataItem?.icon ?? '');
const { Icon: FieldIcon } = useLazyLoadIcon(fieldMetadata.icon ?? '');
const [firstRecord] = objects;
const fieldName = fieldMetadata.id
? objectMetadataItem?.fields.find(({ id }) => id === fieldMetadata.id)?.name
: undefined;
const value = fieldName ? firstRecord?.[fieldName] : undefined;
return {
entityId: firstRecord?.id || `${objectMetadataId}-no-records`,
FieldIcon,
fieldName: fieldName || `${fieldMetadata.type}-new-field`,
hasValue: assertNotNull(value),
ObjectIcon,
objectMetadataItem,
value,
};
};

View File

@ -0,0 +1,29 @@
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
export const useRelationFieldPreview = ({
relationObjectMetadataId,
skipDefaultValue,
}: {
relationObjectMetadataId?: string;
skipDefaultValue: boolean;
}) => {
const { findObjectMetadataItemById } = useObjectMetadataItemForSettings();
const relationObjectMetadataItem = relationObjectMetadataId
? findObjectMetadataItemById(relationObjectMetadataId)
: undefined;
const { objects: relationObjects } = useFindManyObjectRecords({
objectNamePlural: relationObjectMetadataItem?.namePlural,
skip: skipDefaultValue || !relationObjectMetadataItem,
});
return {
defaultValue: relationObjects?.[0],
entityChipDisplayMapper: (fieldValue?: { id: string }) => ({
name: fieldValue?.id || relationObjectMetadataItem?.labelSingular || '',
avatarType: 'squared' as const,
}),
};
};

View File

@ -16,7 +16,7 @@ const StyledDataType = styled.div<{ value: FieldMetadataType }>`
padding: 0 ${({ theme }) => theme.spacing(2)};
${({ theme, value }) =>
value === 'RELATION'
value === FieldMetadataType.Relation
? css`
border-color: ${theme.color.purple20};
color: ${theme.color.purple};

View File

@ -6,7 +6,6 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useLazyLoadIcon } from '@/ui/input/hooks/useLazyLoadIcon';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { dataTypes } from '../../constants/dataTypes';
@ -31,9 +30,6 @@ const StyledIconTableCell = styled(TableCell)`
padding-right: ${({ theme }) => theme.spacing(1)};
`;
// TODO: remove "relation" type for now, add it back when the backend is ready.
const { RELATION: _, ...dataTypesWithoutRelation } = dataTypes;
export const SettingsObjectFieldItemTableRow = ({
ActionIcon,
fieldItem,
@ -42,13 +38,11 @@ export const SettingsObjectFieldItemTableRow = ({
const { Icon } = useLazyLoadIcon(fieldItem.icon ?? '');
// TODO: parse with zod and merge types with FieldType (create a subset of FieldType for example)
const fieldDataTypeIsSupported = Object.keys(
dataTypesWithoutRelation,
).includes(fieldItem.type);
const fieldDataTypeIsSupported = Object.keys(dataTypes).includes(
fieldItem.type,
);
if (!fieldDataTypeIsSupported) {
return null;
}
if (!fieldDataTypeIsSupported) return null;
return (
<StyledObjectFieldTableRow>
@ -58,9 +52,7 @@ export const SettingsObjectFieldItemTableRow = ({
</StyledNameTableCell>
<TableCell>{fieldItem.isCustom ? 'Custom' : 'Standard'}</TableCell>
<TableCell>
<SettingsObjectFieldDataType
value={fieldItem.type as FieldMetadataType}
/>
<SettingsObjectFieldDataType value={fieldItem.type} />
</TableCell>
<StyledIconTableCell>{ActionIcon}</StyledIconTableCell>
</StyledObjectFieldTableRow>

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import { MouseEvent, ReactNode } from 'react';
import styled from '@emotion/styled';
import { OverflowingTextWithTooltip } from '../../tooltip/OverflowingTextWithTooltip';
@ -28,9 +28,10 @@ type ChipProps = {
maxWidth?: string;
variant?: ChipVariant;
accent?: ChipAccent;
leftComponent?: React.ReactNode;
rightComponent?: React.ReactNode;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
className?: string;
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
};
const StyledContainer = styled.div<Partial<ChipProps>>`
@ -125,6 +126,7 @@ export const Chip = ({
accent = ChipAccent.TextPrimary,
maxWidth,
className,
onClick,
}: ChipProps) => (
<StyledContainer
data-testid="chip"
@ -135,6 +137,7 @@ export const Chip = ({
disabled={disabled}
className={className}
maxWidth={maxWidth}
onClick={onClick}
>
{leftComponent}
<StyledLabel>

View File

@ -45,31 +45,31 @@ export const EntityChip = ({
};
return isNonEmptyString(name) ? (
<div onClick={handleLinkClick}>
<Chip
label={name}
variant={
linkToEntity
? variant === EntityChipVariant.Regular
? ChipVariant.Highlighted
: ChipVariant.Regular
: ChipVariant.Transparent
}
leftComponent={
LeftIcon ? (
<LeftIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : (
<Avatar
avatarUrl={pictureUrl}
colorId={entityId}
placeholder={name}
size="sm"
type={avatarType}
/>
)
}
/>
</div>
<Chip
label={name}
variant={
linkToEntity
? variant === EntityChipVariant.Regular
? ChipVariant.Highlighted
: ChipVariant.Regular
: ChipVariant.Transparent
}
leftComponent={
LeftIcon ? (
<LeftIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : (
<Avatar
avatarUrl={pictureUrl}
colorId={entityId}
placeholder={name}
size="sm"
type={avatarType}
/>
)
}
clickable={!!linkToEntity}
onClick={handleLinkClick}
/>
) : (
<></>
);

View File

@ -69,6 +69,9 @@ export {
IconPlug,
IconPlus,
IconProgressCheck,
IconRelationManyToMany,
IconRelationOneToMany,
IconRelationOneToOne,
IconRepeat,
IconRobot,
IconSearch,

View File

@ -19,6 +19,7 @@ import { IconPickerHotkeyScope } from '../types/IconPickerHotkeyScope';
type IconPickerProps = {
disabled?: boolean;
dropdownScopeId?: string;
onChange: (params: { iconKey: string; Icon: IconComponent }) => void;
selectedIconKey?: string;
onClickOutside?: () => void;
@ -44,6 +45,7 @@ const convertIconKeyToLabel = (iconKey: string) =>
export const IconPicker = ({
disabled,
dropdownScopeId = 'icon-picker',
onChange,
selectedIconKey,
onClickOutside,
@ -53,7 +55,7 @@ export const IconPicker = ({
}: IconPickerProps) => {
const [searchString, setSearchString] = useState('');
const { closeDropdown } = useDropdown({ dropdownScopeId: 'icon-picker' });
const { closeDropdown } = useDropdown({ dropdownScopeId });
const { icons, isLoadingIcons: isLoading } = useLazyLoadIcons();
@ -75,7 +77,7 @@ export const IconPicker = ({
}, [icons, searchString, selectedIconKey]);
return (
<DropdownScope dropdownScopeId="icon-picker">
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
dropdownHotkeyScope={{ scope: IconPickerHotkeyScope.IconPicker }}
clickableComponent={

View File

@ -12,14 +12,16 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
export type SelectProps<Value extends string | number | null> = {
className?: string;
disabled?: boolean;
dropdownScopeId: string;
label?: string;
onChange?: (value: Value) => void;
options: { value: Value; label: string; Icon?: IconComponent }[];
value?: Value;
};
const StyledContainer = styled.div<{ disabled?: boolean }>`
const StyledControlContainer = styled.div<{ disabled?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
@ -34,7 +36,16 @@ const StyledContainer = styled.div<{ disabled?: boolean }>`
padding: 0 ${({ theme }) => theme.spacing(2)};
`;
const StyledLabel = styled.div`
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
const StyledControlLabel = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
@ -46,8 +57,10 @@ const StyledIconChevronDown = styled(IconChevronDown)<{ disabled?: boolean }>`
`;
export const Select = <Value extends string | number | null>({
className,
disabled,
dropdownScopeId,
label,
onChange,
options,
value,
@ -59,46 +72,49 @@ export const Select = <Value extends string | number | null>({
const { closeDropdown } = useDropdown({ dropdownScopeId });
const selectControl = (
<StyledContainer disabled={disabled}>
<StyledLabel>
{!!selectedOption.Icon && (
<StyledControlContainer disabled={disabled}>
<StyledControlLabel>
{!!selectedOption?.Icon && (
<selectedOption.Icon
color={disabled ? theme.font.color.light : theme.font.color.primary}
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
{selectedOption.label}
</StyledLabel>
{selectedOption?.label}
</StyledControlLabel>
<StyledIconChevronDown disabled={disabled} size={theme.icon.size.md} />
</StyledContainer>
</StyledControlContainer>
);
return disabled ? (
selectControl
) : (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<Dropdown
dropdownMenuWidth={176}
dropdownPlacement="bottom-start"
clickableComponent={selectControl}
dropdownComponents={
<DropdownMenuItemsContainer>
{options.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => {
onChange?.(option.value);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
<div className={className}>
{!!label && <StyledLabel>{label}</StyledLabel>}
<Dropdown
dropdownMenuWidth={176}
dropdownPlacement="bottom-start"
clickableComponent={selectControl}
dropdownComponents={
<DropdownMenuItemsContainer>
{options.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => {
onChange?.(option.value);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
</div>
</DropdownScope>
);
};

View File

@ -5,8 +5,6 @@ const StyledPanel = styled.div`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: row;
height: 100%;
overflow: auto;
width: 100%;

View File

@ -7,9 +7,10 @@ export const getEntityChipFromFieldMetadata = (
fieldDefinition: FieldDefinition<FieldRelationMetadata>,
fieldValue: any,
) => {
const { entityChipDisplayMapper } = fieldDefinition;
const { fieldName } = fieldDefinition.metadata;
const chipValue: Pick<
const defaultChipValue: Pick<
EntityChipProps,
'name' | 'pictureUrl' | 'avatarType' | 'entityId'
> = {
@ -19,15 +20,23 @@ export const getEntityChipFromFieldMetadata = (
entityId: fieldValue?.id,
};
// TODO: use every
if (fieldName === 'accountOwner' && fieldValue) {
chipValue.name = fieldValue.name.firstName + ' ' + fieldValue.name.lastName;
} else if (fieldName === 'company' && fieldValue) {
chipValue.name = fieldValue.name;
chipValue.pictureUrl = getLogoUrlFromDomainName(fieldValue.domainName);
} else if (fieldName === 'person' && fieldValue) {
chipValue.name = fieldValue.name.firstName + ' ' + fieldValue.name.lastName;
if (['accountOwner', 'person'].includes(fieldName) && fieldValue) {
return {
...defaultChipValue,
name: `${fieldValue.firstName} ${fieldValue.lastName}`,
};
}
return chipValue;
if (fieldName === 'company' && fieldValue) {
return {
...defaultChipValue,
name: fieldValue.name,
pictureUrl: getLogoUrlFromDomainName(fieldValue.domainName),
};
}
return {
...defaultChipValue,
...entityChipDisplayMapper?.(fieldValue),
};
};

View File

@ -5,9 +5,10 @@ import { FieldMetadata } from './FieldMetadata';
import { FieldType } from './FieldType';
export type FieldDefinitionRelationType =
| 'TO_ONE_OBJECT'
| 'FROM_NAMY_OBJECTS'
| 'TO_MANY_OBJECTS';
| 'FROM_MANY_OBJECTS'
| 'FROM_ONE_OBJECT'
| 'TO_MANY_OBJECTS'
| 'TO_ONE_OBJECT';
export type FieldDefinition<T extends FieldMetadata> = {
fieldMetadataId: string;

View File

@ -1,8 +1,20 @@
import { isNull, isString } from '@sniptt/guards';
import { formatToHumanReadableDate } from '~/utils';
import { FieldDateValue } from '../FieldMetadata';
// TODO: add zod
export const isFieldDateValue = (
fieldValue: unknown,
): fieldValue is FieldDateValue => isNull(fieldValue) || isString(fieldValue);
): fieldValue is FieldDateValue => {
try {
if (isNull(fieldValue)) return true;
if (isString(fieldValue)) {
formatToHumanReadableDate(fieldValue);
return true;
}
} catch {}
return false;
};