feat: add Object Edit Settings section with Object preview (#4216)

* feat: add Object Edit Settings section with Object preview

Closes #3834

* fix: fix preview card stories

* test: improve getFieldDefaultPreviewValue tests

* test: add getFieldPreviewValueFromRecord tests

* test: add useFieldPreview tests

* refactor: rename and move components

* fix: restore RecordStoreDecorator
This commit is contained in:
Thaïs
2024-02-29 11:23:56 -03:00
committed by GitHub
parent 6ad3880696
commit a892d0f653
43 changed files with 1665 additions and 937 deletions

View File

@ -0,0 +1,158 @@
import styled from '@emotion/styled';
import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import {
SettingsObjectFieldCurrencyForm,
SettingsObjectFieldCurrencyFormValues,
} from '@/settings/data-model/components/SettingsObjectFieldCurrencyForm';
import {
SettingsObjectFieldRelationForm,
SettingsObjectFieldRelationFormValues,
} from '@/settings/data-model/components/SettingsObjectFieldRelationForm';
import {
SettingsObjectFieldSelectForm,
SettingsObjectFieldSelectFormValues,
} from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import {
SettingsDataModelFieldPreviewCard,
SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export type SettingsDataModelFieldSettingsFormValues = {
currency: SettingsObjectFieldCurrencyFormValues;
relation: SettingsObjectFieldRelationFormValues;
select: SettingsObjectFieldSelectFormValues;
};
type SettingsDataModelFieldSettingsFormCardProps = {
disableCurrencyForm?: boolean;
onChange: (values: Partial<SettingsDataModelFieldSettingsFormValues>) => void;
relationFieldMetadataItem?: Pick<
FieldMetadataItem,
'id' | 'isCustom' | 'name'
>;
values: SettingsDataModelFieldSettingsFormValues;
} & Pick<
SettingsDataModelFieldPreviewCardProps,
'fieldMetadataItem' | 'objectMetadataItem'
>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
display: grid;
flex: 1 1 100%;
`;
const StyledPreviewContent = styled.div`
display: flex;
gap: 6px;
`;
const StyledRelationImage = styled.img<{ flip?: boolean }>`
transform: ${({ flip }) => (flip ? 'scaleX(-1)' : 'none')};
width: 54px;
`;
const previewableTypes = [
FieldMetadataType.Boolean,
FieldMetadataType.Currency,
FieldMetadataType.DateTime,
FieldMetadataType.Select,
FieldMetadataType.Link,
FieldMetadataType.Number,
FieldMetadataType.Rating,
FieldMetadataType.Relation,
FieldMetadataType.Text,
];
export const SettingsDataModelFieldSettingsFormCard = ({
disableCurrencyForm,
fieldMetadataItem,
objectMetadataItem,
onChange,
relationFieldMetadataItem,
values,
}: SettingsDataModelFieldSettingsFormCardProps) => {
const { findObjectMetadataItemById } = useObjectMetadataItemForSettings();
if (!previewableTypes.includes(fieldMetadataItem.type)) return null;
const relationObjectMetadataItem = findObjectMetadataItemById(
values.relation.objectMetadataId,
);
const relationTypeConfig = RELATION_TYPES[values.relation.type];
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledPreviewContent>
<StyledFieldPreviewCard
fieldMetadataItem={fieldMetadataItem}
shrink={fieldMetadataItem.type === FieldMetadataType.Relation}
objectMetadataItem={objectMetadataItem}
relationObjectMetadataItem={relationObjectMetadataItem}
selectOptions={values.select}
/>
{fieldMetadataItem.type === FieldMetadataType.Relation &&
!!relationObjectMetadataItem && (
<>
<StyledRelationImage
src={relationTypeConfig.imageSrc}
flip={relationTypeConfig.isImageFlipped}
alt={relationTypeConfig.label}
/>
<StyledFieldPreviewCard
fieldMetadataItem={{
icon: values.relation.field.icon,
label: values.relation.field.label || 'Field name',
type: FieldMetadataType.Relation,
name: relationFieldMetadataItem?.name,
id: relationFieldMetadataItem?.id,
}}
shrink
objectMetadataItem={relationObjectMetadataItem}
relationObjectMetadataItem={objectMetadataItem}
/>
</>
)}
</StyledPreviewContent>
}
form={
fieldMetadataItem.type === FieldMetadataType.Currency ? (
<SettingsObjectFieldCurrencyForm
disabled={disableCurrencyForm}
values={values.currency}
onChange={(nextCurrencyValues) =>
onChange({
currency: { ...values.currency, ...nextCurrencyValues },
})
}
/>
) : fieldMetadataItem.type === FieldMetadataType.Relation ? (
<SettingsObjectFieldRelationForm
disableFieldEdition={
relationFieldMetadataItem && !relationFieldMetadataItem.isCustom
}
disableRelationEdition={!!relationFieldMetadataItem}
values={values.relation}
onChange={(nextRelationValues) =>
onChange({
relation: { ...values.relation, ...nextRelationValues },
})
}
/>
) : fieldMetadataItem.type === FieldMetadataType.Select ? (
<SettingsObjectFieldSelectForm
values={values.select}
onChange={(nextSelectValues) =>
onChange({ select: nextSelectValues })
}
/>
) : undefined
}
/>
);
};

View File

@ -0,0 +1,39 @@
import { SETTINGS_FIELD_METADATA_TYPES } from '@/settings/data-model/constants/SettingsFieldMetadataTypes';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type SettingsDataModelFieldTypeSelectProps = {
className?: string;
disabled?: boolean;
excludedFieldTypes?: FieldMetadataType[];
onChange?: ({ type }: { type: FieldMetadataType }) => void;
value?: FieldMetadataType;
};
export const SettingsDataModelFieldTypeSelect = ({
className,
disabled,
excludedFieldTypes,
onChange,
value,
}: SettingsDataModelFieldTypeSelectProps) => {
const fieldTypeOptions = Object.entries(SETTINGS_FIELD_METADATA_TYPES)
.filter(([key]) => !excludedFieldTypes?.includes(key as FieldMetadataType))
.map<SelectOption<FieldMetadataType>>(([key, dataTypeConfig]) => ({
Icon: dataTypeConfig.Icon,
label: dataTypeConfig.label,
value: key as FieldMetadataType,
}));
return (
<Select
className={className}
fullWidth
disabled={disabled}
dropdownId="object-field-type-select"
value={value}
onChange={(value) => onChange?.({ type: value })}
options={fieldTypeOptions}
/>
);
};

View File

@ -0,0 +1,115 @@
import { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import {
mockedCompanyObjectMetadataItem,
mockedPersonObjectMetadataItem,
} from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { fieldMetadataFormDefaultValues } from '@/settings/data-model/fields/forms/hooks/useFieldMetadataForm';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsDataModelFieldSettingsFormCard } from '../SettingsDataModelFieldSettingsFormCard';
const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.Text,
)!;
const defaultValues = {
currency: fieldMetadataFormDefaultValues.currency,
relation: fieldMetadataFormDefaultValues.relation,
select: fieldMetadataFormDefaultValues.select,
};
const meta: Meta<typeof SettingsDataModelFieldSettingsFormCard> = {
title:
'Modules/Settings/DataModel/Fields/Forms/SettingsDataModelFieldSettingsFormCard',
component: SettingsDataModelFieldSettingsFormCard,
decorators: [
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
],
args: {
fieldMetadataItem,
objectMetadataItem: mockedCompanyObjectMetadataItem,
onChange: fn(),
values: defaultValues,
},
parameters: {
container: { width: 512 },
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof SettingsDataModelFieldSettingsFormCard>;
export const Default: Story = {};
const relationFieldMetadataItem = mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === 'company',
)!;
export const WithRelationForm: Story = {
decorators: [MemoryRouterDecorator],
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ name }) => name === 'people',
),
relationFieldMetadataItem,
values: {
...defaultValues,
relation: {
field: relationFieldMetadataItem,
objectMetadataId: mockedPersonObjectMetadataItem.id,
type: RelationMetadataType.OneToMany,
},
},
},
};
export const WithSelectForm: Story = {
args: {
fieldMetadataItem: {
label: 'Industry',
icon: 'IconBuildingFactory2',
type: FieldMetadataType.Select,
},
values: {
...defaultValues,
select: [
{
color: 'pink',
isDefault: true,
label: '💊 Health',
value: 'HEALTH',
},
{
color: 'purple',
label: '🏭 Industry',
value: 'INDUSTRY',
},
{ color: 'sky', label: '🤖 SaaS', value: 'SAAS' },
{
color: 'turquoise',
label: '🌿 Green tech',
value: 'GREEN_TECH',
},
{
color: 'yellow',
label: '🚲 Mobility',
value: 'MOBILITY',
},
{ color: 'green', label: '🌏 NGO', value: 'NGO' },
],
},
},
};

View File

@ -0,0 +1,67 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsDataModelFieldTypeSelect } from '../SettingsDataModelFieldTypeSelect';
const meta: Meta<typeof SettingsDataModelFieldTypeSelect> = {
title:
'Modules/Settings/DataModel/Fields/Forms/SettingsDataModelFieldTypeSelect',
component: SettingsDataModelFieldTypeSelect,
decorators: [ComponentDecorator],
args: {
onChange: fn(),
value: FieldMetadataType.Text,
},
parameters: {
container: { width: 512 },
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof SettingsDataModelFieldTypeSelect>;
export const Default: Story = {};
export const Disabled: Story = {
args: {
disabled: true,
},
};
export const WithOpenSelect: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const inputField = await canvas.findByText('Text');
await userEvent.click(inputField);
const input = await canvas.findByText('Unique ID');
await userEvent.click(input);
await userEvent.click(inputField);
},
};
export const WithExcludedFieldTypes: Story = {
args: {
excludedFieldTypes: [FieldMetadataType.Uuid, FieldMetadataType.Numeric],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const inputField = await canvas.findByText('Text');
await userEvent.click(inputField);
await canvas.findByText('Number');
expect(canvas.queryByText('Unique ID')).toBeNull();
expect(canvas.queryByText('Numeric')).toBeNull();
},
};

View File

@ -0,0 +1,51 @@
import { act, renderHook } from '@testing-library/react';
import { useFieldMetadataForm } from '../useFieldMetadataForm';
describe('useFieldMetadataForm', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useFieldMetadataForm());
expect(result.current.isInitialized).toBe(false);
act(() => {
result.current.initForm({});
});
expect(result.current.isInitialized).toBe(true);
expect(result.current.formValues).toEqual({
icon: 'IconUsers',
label: '',
type: 'TEXT',
currency: { currencyCode: 'USD' },
relation: {
type: 'ONE_TO_MANY',
objectMetadataId: '',
field: { label: '' },
},
select: [
{ color: 'green', label: 'Option 1', value: expect.any(String) },
],
});
});
it('should handle form changes', () => {
const { result } = renderHook(() => useFieldMetadataForm());
act(() => {
result.current.initForm({});
});
expect(result.current.hasFieldFormChanged).toBe(false);
expect(result.current.hasRelationFormChanged).toBe(false);
expect(result.current.hasSelectFormChanged).toBe(false);
act(() => {
result.current.handleFormChange({ label: 'New Label' });
});
expect(result.current.hasFieldFormChanged).toBe(true);
expect(result.current.hasRelationFormChanged).toBe(false);
expect(result.current.hasSelectFormChanged).toBe(false);
});
});

View File

@ -0,0 +1,215 @@
import { useState } from 'react';
import { DeepPartial } from 'react-hook-form';
import { v4 } from 'uuid';
import { z } from 'zod';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { SettingsDataModelFieldSettingsFormValues } from '../components/SettingsDataModelFieldSettingsFormCard';
type FormValues = {
description?: string;
icon: string;
label: string;
type: FieldMetadataType;
} & SettingsDataModelFieldSettingsFormValues;
export const fieldMetadataFormDefaultValues: FormValues = {
icon: 'IconUsers',
label: '',
type: FieldMetadataType.Text,
currency: { currencyCode: CurrencyCode.USD },
relation: {
type: RelationMetadataType.OneToMany,
objectMetadataId: '',
field: { label: '' },
},
select: [{ color: 'green', label: 'Option 1', value: v4() }],
};
const fieldSchema = z.object({
description: z.string().optional(),
icon: z.string().startsWith('Icon'),
label: z.string().min(1),
});
const currencySchema = fieldSchema.merge(
z.object({
type: z.literal(FieldMetadataType.Currency),
currency: z.object({
currencyCode: z.nativeEnum(CurrencyCode),
}),
}),
);
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 selectSchema = fieldSchema.merge(
z.object({
type: z.literal(FieldMetadataType.Select),
select: z
.array(
z.object({
color: themeColorSchema,
id: z.string().optional(),
isDefault: z.boolean().optional(),
label: z.string().min(1),
}),
)
.nonempty(),
}),
);
const {
Currency: _Currency,
Relation: _Relation,
Select: _Select,
...otherFieldTypes
} = FieldMetadataType;
type OtherFieldType = Exclude<
FieldMetadataType,
| FieldMetadataType.Currency
| FieldMetadataType.Relation
| FieldMetadataType.Select
>;
const otherFieldTypesSchema = fieldSchema.merge(
z.object({
type: z.enum(
Object.values(otherFieldTypes) as [OtherFieldType, ...OtherFieldType[]],
),
}),
);
const schema = z.discriminatedUnion('type', [
currencySchema,
relationSchema,
selectSchema,
otherFieldTypesSchema,
]);
type PartialFormValues = Partial<Omit<FormValues, 'relation'>> &
DeepPartial<Pick<FormValues, 'relation'>>;
export const useFieldMetadataForm = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [initialFormValues, setInitialFormValues] = useState<FormValues>(
fieldMetadataFormDefaultValues,
);
const [formValues, setFormValues] = useState<FormValues>(
fieldMetadataFormDefaultValues,
);
const [hasFieldFormChanged, setHasFieldFormChanged] = useState(false);
const [hasCurrencyFormChanged, setHasCurrencyFormChanged] = useState(false);
const [hasRelationFormChanged, setHasRelationFormChanged] = useState(false);
const [hasSelectFormChanged, setHasSelectFormChanged] = useState(false);
const [validationResult, setValidationResult] = useState(
schema.safeParse(formValues),
);
const mergePartialValues = (
previousValues: FormValues,
nextValues: PartialFormValues,
): FormValues => ({
...previousValues,
...nextValues,
currency: { ...previousValues.currency, ...nextValues.currency },
relation: {
...previousValues.relation,
...nextValues.relation,
field: {
...previousValues.relation?.field,
...nextValues.relation?.field,
},
},
});
const initForm = (lazyInitialFormValues: PartialFormValues) => {
if (isInitialized) return;
const mergedFormValues = mergePartialValues(
initialFormValues,
lazyInitialFormValues,
);
setInitialFormValues(mergedFormValues);
setFormValues(mergedFormValues);
setValidationResult(schema.safeParse(mergedFormValues));
setIsInitialized(true);
};
const handleFormChange = (values: PartialFormValues) => {
const nextFormValues = mergePartialValues(formValues, values);
setFormValues(nextFormValues);
setValidationResult(schema.safeParse(nextFormValues));
const {
currency: initialCurrencyFormValues,
relation: initialRelationFormValues,
select: initialSelectFormValues,
...initialFieldFormValues
} = initialFormValues;
const {
currency: nextCurrencyFormValues,
relation: nextRelationFormValues,
select: nextSelectFormValues,
...nextFieldFormValues
} = nextFormValues;
setHasFieldFormChanged(
!isDeeplyEqual(initialFieldFormValues, nextFieldFormValues),
);
setHasCurrencyFormChanged(
nextFieldFormValues.type === FieldMetadataType.Currency &&
!isDeeplyEqual(initialCurrencyFormValues, nextCurrencyFormValues),
);
setHasRelationFormChanged(
nextFieldFormValues.type === FieldMetadataType.Relation &&
!isDeeplyEqual(initialRelationFormValues, nextRelationFormValues),
);
setHasSelectFormChanged(
nextFieldFormValues.type === FieldMetadataType.Select &&
!isDeeplyEqual(initialSelectFormValues, nextSelectFormValues),
);
};
return {
formValues,
handleFormChange,
hasFieldFormChanged,
hasFormChanged:
hasFieldFormChanged ||
hasCurrencyFormChanged ||
hasRelationFormChanged ||
hasSelectFormChanged,
hasRelationFormChanged,
hasSelectFormChanged,
initForm,
isInitialized,
isValid: validationResult.success,
validatedFormValues: validationResult.success
? validationResult.data
: undefined,
};
};

View File

@ -0,0 +1,125 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { parseFieldType } from '@/object-metadata/utils/parseFieldType';
import { FieldDisplay } from '@/object-record/record-field/components/FieldDisplay';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { BooleanFieldInput } from '@/object-record/record-field/meta-types/input/components/BooleanFieldInput';
import { RatingFieldInput } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput';
import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { SettingsDataModelSetFieldValueEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect';
import { SettingsDataModelSetRecordEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect';
import { useFieldPreview } from '@/settings/data-model/fields/preview/hooks/useFieldPreview';
import { useIcons } from '@/ui/display/icon/hooks/useIcons';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export type SettingsDataModelFieldPreviewProps = {
fieldMetadataItem: Pick<FieldMetadataItem, 'icon' | 'label' | 'type'> & {
id?: string;
name?: string;
};
objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataItem?: ObjectMetadataItem;
selectOptions?: SettingsObjectFieldSelectFormValues;
shrink?: boolean;
withFieldLabel?: boolean;
};
const StyledFieldPreview = styled.div<{ shrink?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
height: ${({ theme }) => theme.spacing(8)};
overflow: hidden;
padding: 0
${({ shrink, theme }) => (shrink ? theme.spacing(1) : theme.spacing(2))};
white-space: nowrap;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledFieldLabel = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsDataModelFieldPreview = ({
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataItem,
selectOptions,
shrink,
withFieldLabel = true,
}: SettingsDataModelFieldPreviewProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const FieldIcon = getIcon(fieldMetadataItem.icon);
const { entityId, fieldName, fieldPreviewValue, isLabelIdentifier, record } =
useFieldPreview({
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataItem,
selectOptions,
});
return (
<>
{record ? (
<SettingsDataModelSetRecordEffect record={record} />
) : (
<SettingsDataModelSetFieldValueEffect
entityId={entityId}
fieldName={fieldName}
value={fieldPreviewValue}
/>
)}
<StyledFieldPreview shrink={shrink}>
{!!withFieldLabel && (
<StyledFieldLabel>
<FieldIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
{fieldMetadataItem.label}:
</StyledFieldLabel>
)}
<FieldContext.Provider
value={{
entityId,
isLabelIdentifier,
fieldDefinition: {
type: parseFieldType(fieldMetadataItem.type),
iconName: 'FieldIcon',
fieldMetadataId: fieldMetadataItem.id || '',
label: fieldMetadataItem.label,
metadata: {
fieldName,
objectMetadataNameSingular: objectMetadataItem.nameSingular,
relationObjectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular,
options: selectOptions,
},
},
hotkeyScope: 'field-preview',
}}
>
{fieldMetadataItem.type === FieldMetadataType.Boolean ? (
<BooleanFieldInput readonly />
) : fieldMetadataItem.type === FieldMetadataType.Rating ? (
<RatingFieldInput readonly />
) : (
<FieldDisplay />
)}
</FieldContext.Provider>
</StyledFieldPreview>
</>
);
};

View File

@ -0,0 +1,48 @@
import styled from '@emotion/styled';
import {
SettingsDataModelFieldPreview,
SettingsDataModelFieldPreviewProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview';
import { SettingsDataModelObjectSummary } from '@/settings/data-model/objects/SettingsDataModelObjectSummary';
import { Card } from '@/ui/layout/card/components/Card';
import { CardContent } from '@/ui/layout/card/components/CardContent';
export type SettingsDataModelFieldPreviewCardProps =
SettingsDataModelFieldPreviewProps & {
className?: string;
};
const StyledCard = styled(Card)`
border-radius: ${({ theme }) => theme.border.radius.md};
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledCardContent = styled(CardContent)`
display: grid;
padding: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsDataModelFieldPreviewCard = ({
className,
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataItem,
selectOptions,
shrink,
withFieldLabel = true,
}: SettingsDataModelFieldPreviewCardProps) => (
<StyledCard className={className} fullWidth>
<StyledCardContent>
<SettingsDataModelObjectSummary objectMetadataItem={objectMetadataItem} />
<SettingsDataModelFieldPreview
objectMetadataItem={objectMetadataItem}
fieldMetadataItem={fieldMetadataItem}
relationObjectMetadataItem={relationObjectMetadataItem}
selectOptions={selectOptions}
shrink={shrink}
withFieldLabel={withFieldLabel}
/>
</StyledCardContent>
</StyledCard>
);

View File

@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
type SettingsDataModelSetFieldValueEffectProps = {
entityId: string;
fieldName: string;
value: unknown;
};
export const SettingsDataModelSetFieldValueEffect = ({
entityId,
fieldName,
value,
}: SettingsDataModelSetFieldValueEffectProps) => {
const setFieldValue = useSetRecoilState(
recordStoreFamilySelector({
recordId: entityId,
fieldName,
}),
);
useEffect(() => {
setFieldValue(value);
}, [value, setFieldValue]);
return null;
};

View File

@ -0,0 +1,20 @@
import { useEffect } from 'react';
import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
type SettingsDataModelSetRecordEffectProps = {
record: ObjectRecord;
};
export const SettingsDataModelSetRecordEffect = ({
record,
}: SettingsDataModelSetRecordEffectProps) => {
const { setRecords: setRecordsInStore } = useSetRecordInStore();
useEffect(() => {
setRecordsInStore([record]);
}, [record, setRecordsInStore]);
return null;
};

View File

@ -0,0 +1,111 @@
import { Meta, StoryObj } from '@storybook/react';
import {
mockedCompanyObjectMetadataItem,
mockedPersonObjectMetadataItem,
} from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { SettingsDataModelFieldPreviewCard } from '../SettingsDataModelFieldPreviewCard';
const meta: Meta<typeof SettingsDataModelFieldPreviewCard> = {
title:
'Modules/Settings/DataModel/Fields/Preview/SettingsDataModelFieldPreviewCard',
component: SettingsDataModelFieldPreviewCard,
decorators: [
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
],
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.Text,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
},
parameters: {
container: { width: 480 },
msw: graphqlMocks,
},
};
export default meta;
type Story = StoryObj<typeof SettingsDataModelFieldPreviewCard>;
export const Text: Story = {};
export const Boolean: Story = {
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.Boolean,
),
},
};
export const Currency: Story = {
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.Currency,
),
},
};
export const Date: Story = {
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.DateTime,
),
},
};
export const Link: Story = {
decorators: [MemoryRouterDecorator],
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.Link,
),
},
};
export const Number: Story = {
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.Number,
),
},
};
export const Rating: Story = {
args: {
fieldMetadataItem: {
icon: 'IconHandClick',
label: 'Engagement',
type: FieldMetadataType.Rating,
},
},
};
export const Relation: Story = {
decorators: [MemoryRouterDecorator],
args: {
fieldMetadataItem: mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === 'company',
),
objectMetadataItem: mockedPersonObjectMetadataItem,
relationObjectMetadataItem: mockedCompanyObjectMetadataItem,
},
};
export const CustomObject: Story = {
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ isCustom }) => isCustom,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
},
};

View File

@ -0,0 +1,67 @@
import { ReactNode } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { mockedCompanyObjectMetadataItem } from '@/object-record/record-field/__mocks__/fieldDefinitions';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { useFieldPreview } from '../useFieldPreview';
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider addTypename={false}>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
{children}
</SnackBarProviderScope>
</MockedProvider>
</RecoilRoot>
);
describe('useFieldPreview', () => {
it('returns default preview data if no records are found', () => {
// Given
const objectMetadataItem = mockedCompanyObjectMetadataItem;
const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ name }) => name === 'linkedinLink',
)!;
// When
const { result } = renderHook(
() => useFieldPreview({ fieldMetadataItem, objectMetadataItem }),
{ wrapper: Wrapper },
);
// Then
expect(result.current).toEqual({
entityId: 'company-linkedinLink-preview-field-form',
fieldName: 'linkedinLink',
fieldPreviewValue: { label: '', url: 'www.twenty.com' },
isLabelIdentifier: false,
record: null,
});
});
it('returns default preview data for a label identifier field if no records are found', () => {
// Given
const objectMetadataItem = mockedCompanyObjectMetadataItem;
const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ name }) => name === 'name',
)!;
// When
const { result } = renderHook(
() => useFieldPreview({ fieldMetadataItem, objectMetadataItem }),
{ wrapper: Wrapper },
);
// Then
expect(result.current).toEqual({
entityId: 'company-name-preview-field-form',
fieldName: 'name',
fieldPreviewValue: 'Company',
isLabelIdentifier: true,
record: null,
});
});
});

View File

@ -0,0 +1,101 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { parseFieldType } from '@/object-metadata/utils/parseFieldType';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { getFieldDefaultPreviewValue } from '@/settings/data-model/utils/getFieldDefaultPreviewValue';
import { getFieldPreviewValueFromRecord } from '@/settings/data-model/utils/getFieldPreviewValueFromRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type UseFieldPreviewParams = {
fieldMetadataItem: Pick<FieldMetadataItem, 'icon' | 'type'> & {
id?: string;
name?: string;
};
objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataItem?: ObjectMetadataItem;
selectOptions?: SettingsObjectFieldSelectFormValues;
};
export const useFieldPreview = ({
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataItem,
selectOptions,
}: UseFieldPreviewParams) => {
const isLabelIdentifier =
!!fieldMetadataItem.id &&
!!fieldMetadataItem.name &&
isLabelIdentifierField({
fieldMetadataItem: {
id: fieldMetadataItem.id,
name: fieldMetadataItem.name,
},
objectMetadataItem,
});
const { records } = useFindManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
limit: 1,
skip: !fieldMetadataItem.name,
});
const [firstRecord] = records;
const fieldPreviewValueFromFirstRecord =
firstRecord && fieldMetadataItem.name
? getFieldPreviewValueFromRecord({
record: firstRecord,
fieldMetadataItem: {
name: fieldMetadataItem.name,
type: fieldMetadataItem.type,
},
selectOptions,
})
: null;
const isValueFromFirstRecord =
firstRecord &&
!isFieldValueEmpty({
fieldDefinition: { type: parseFieldType(fieldMetadataItem.type) },
fieldValue: fieldPreviewValueFromFirstRecord,
});
const { records: relationRecords } = useFindManyRecords({
objectNameSingular:
relationObjectMetadataItem?.nameSingular ||
CoreObjectNameSingular.Company,
limit: 1,
skip:
!relationObjectMetadataItem ||
fieldMetadataItem.type !== FieldMetadataType.Relation ||
isValueFromFirstRecord,
});
const [firstRelationRecord] = relationRecords;
const fieldPreviewValue = isValueFromFirstRecord
? fieldPreviewValueFromFirstRecord
: firstRelationRecord ??
getFieldDefaultPreviewValue({
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataItem,
selectOptions,
});
const fieldName =
fieldMetadataItem.name || `${fieldMetadataItem.type}-new-field`;
const entityId = isValueFromFirstRecord
? firstRecord.id
: `${objectMetadataItem.nameSingular}-${fieldMetadataItem.name}-preview-field-form`;
return {
entityId,
fieldName,
fieldPreviewValue,
isLabelIdentifier,
record: isValueFromFirstRecord ? firstRecord : null,
};
};