feat: simplify field preview logic in Settings (#5541)

Closes #5382

TODO:

- [x] Test all field previews in app
- [x] Fix tests
- [x] Fix JSON preview
This commit is contained in:
Thaïs
2024-05-24 18:06:57 +02:00
committed by GitHub
parent 1ae7fbe90d
commit c7d61e183a
33 changed files with 1184 additions and 510 deletions

View File

@ -80,21 +80,22 @@ const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
`;
const previewableTypes = [
FieldMetadataType.Address,
FieldMetadataType.Boolean,
FieldMetadataType.Currency,
FieldMetadataType.DateTime,
FieldMetadataType.Date,
FieldMetadataType.Select,
FieldMetadataType.MultiSelect,
FieldMetadataType.DateTime,
FieldMetadataType.FullName,
FieldMetadataType.Link,
FieldMetadataType.Links,
FieldMetadataType.MultiSelect,
FieldMetadataType.Number,
FieldMetadataType.Rating,
FieldMetadataType.Relation,
FieldMetadataType.Text,
FieldMetadataType.Address,
FieldMetadataType.RawJson,
FieldMetadataType.Phone,
FieldMetadataType.Rating,
FieldMetadataType.RawJson,
FieldMetadataType.Relation,
FieldMetadataType.Select,
FieldMetadataType.Text,
];
export const SettingsDataModelFieldSettingsFormCard = ({

View File

@ -2,25 +2,15 @@ import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { currencyCodeSchema } from '@/object-record/record-field/validation-schemas/currencyCodeSchema';
import { currencyFieldDefaultValueSchema } from '@/object-record/record-field/validation-schemas/currencyFieldDefaultValueSchema';
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes';
import { useCurrencySettingsFormInitialValues } from '@/settings/data-model/fields/forms/currency/hooks/useCurrencySettingsFormInitialValues';
import { Select } from '@/ui/input/components/Select';
import { CardContent } from '@/ui/layout/card/components/CardContent';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
export const settingsDataModelFieldCurrencyFormSchema = z.object({
defaultValue: z.object({
amountMicros: z.number().nullable(),
currencyCode: simpleQuotesStringSchema.refine(
(value) =>
currencyCodeSchema.safeParse(stripSimpleQuotesFromString(value))
.success,
{ message: 'String is not a valid currencyCode' },
),
}),
defaultValue: currencyFieldDefaultValueSchema,
});
export type SettingsDataModelFieldCurrencyFormValues = z.infer<

View File

@ -9,6 +9,8 @@ import {
FieldMetadataItemOption,
} from '@/object-metadata/types/FieldMetadataItem';
import { selectOptionsSchema } from '@/object-metadata/validation-schemas/selectOptionsSchema';
import { multiSelectFieldDefaultValueSchema } from '@/object-record/record-field/validation-schemas/multiSelectFieldDefaultValueSchema';
import { selectFieldDefaultValueSchema } from '@/object-record/record-field/validation-schemas/selectFieldDefaultValueSchema';
import { useSelectSettingsFormInitialValues } from '@/settings/data-model/fields/forms/select/hooks/useSelectSettingsFormInitialValues';
import { generateNewSelectOption } from '@/settings/data-model/fields/forms/select/utils/generateNewSelectOption';
import { isSelectOptionDefaultValue } from '@/settings/data-model/utils/isSelectOptionDefaultValue';
@ -21,17 +23,16 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { toSpliced } from '~/utils/array/toSpliced';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
import { SettingsDataModelFieldSelectFormOptionRow } from './SettingsDataModelFieldSelectFormOptionRow';
export const settingsDataModelFieldSelectFormSchema = z.object({
defaultValue: simpleQuotesStringSchema.nullable(),
defaultValue: selectFieldDefaultValueSchema(),
options: selectOptionsSchema,
});
export const settingsDataModelFieldMultiSelectFormSchema = z.object({
defaultValue: z.array(simpleQuotesStringSchema).nullable(),
defaultValue: multiSelectFieldDefaultValueSchema(),
options: selectOptionsSchema,
});

View File

@ -4,13 +4,15 @@ import { useIcons } from 'twenty-ui';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
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 { 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 { useFieldPreviewValue } from '@/settings/data-model/fields/preview/hooks/useFieldPreviewValue';
import { usePreviewRecord } from '@/settings/data-model/fields/preview/hooks/usePreviewRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export type SettingsDataModelFieldPreviewProps = {
@ -61,17 +63,40 @@ export const SettingsDataModelFieldPreview = ({
const { getIcon } = useIcons();
const FieldIcon = getIcon(fieldMetadataItem.icon);
const { entityId, fieldName, fieldPreviewValue, isLabelIdentifier, record } =
useFieldPreview({
fieldMetadataItem,
// id and name are undefined in create mode (field does not exist yet)
// and defined in edit mode.
const isLabelIdentifier =
!!fieldMetadataItem.id &&
!!fieldMetadataItem.name &&
isLabelIdentifierField({
fieldMetadataItem: {
id: fieldMetadataItem.id,
name: fieldMetadataItem.name,
},
objectMetadataItem,
relationObjectMetadataItem,
});
const previewRecord = usePreviewRecord({
objectMetadataItem,
skip: !isLabelIdentifier,
});
const fieldPreviewValue = useFieldPreviewValue({
fieldMetadataItem,
relationObjectMetadataItem,
skip: isLabelIdentifier,
});
const fieldName =
fieldMetadataItem.name || `${fieldMetadataItem.type}-new-field`;
const entityId =
previewRecord?.id ??
`${objectMetadataItem.nameSingular}-${fieldName}-preview`;
return (
<>
{record ? (
<SettingsDataModelSetRecordEffect record={record} />
{previewRecord ? (
<SettingsDataModelSetRecordEffect record={previewRecord} />
) : (
<SettingsDataModelSetFieldValueEffect
entityId={entityId}

View File

@ -8,6 +8,7 @@ import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import {
mockedCompanyObjectMetadataItem,
mockedOpportunityObjectMetadataItem,
mockedPersonObjectMetadataItem,
} from '~/testing/mock-data/metadata';
@ -24,10 +25,7 @@ const meta: Meta<typeof SettingsDataModelFieldPreviewCard> = {
SnackBarDecorator,
],
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.Text,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
objectMetadataItem: mockedPersonObjectMetadataItem,
},
parameters: {
container: { width: 480 },
@ -38,21 +36,41 @@ const meta: Meta<typeof SettingsDataModelFieldPreviewCard> = {
export default meta;
type Story = StoryObj<typeof SettingsDataModelFieldPreviewCard>;
export const Text: Story = {};
export const LabelIdentifier: Story = {
args: {
fieldMetadataItem: mockedPersonObjectMetadataItem.fields.find(
({ name, type }) =>
name === 'name' && type === FieldMetadataType.FullName,
),
},
};
export const Text: Story = {
args: {
fieldMetadataItem: mockedPersonObjectMetadataItem.fields.find(
({ name, type }) => name === 'city' && type === FieldMetadataType.Text,
),
},
};
export const Boolean: Story = {
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.Boolean,
({ name, type }) =>
name === 'idealCustomerProfile' && type === FieldMetadataType.Boolean,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
},
};
export const Currency: Story = {
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.Currency,
({ name, type }) =>
name === 'annualRecurringRevenue' &&
type === FieldMetadataType.Currency,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
},
};
@ -61,14 +79,27 @@ export const Date: Story = {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.DateTime,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
},
};
export const Link: Story = {
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.Link,
({ name, type }) =>
name === 'linkedinLink' && type === FieldMetadataType.Link,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
},
};
export const Links: Story = {
args: {
...Link.args,
fieldMetadataItem: {
...Link.args!.fieldMetadataItem!,
type: FieldMetadataType.Links,
},
},
};
@ -77,6 +108,7 @@ export const Number: Story = {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.Number,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
},
};
@ -95,7 +127,27 @@ export const Relation: Story = {
fieldMetadataItem: mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === 'company',
),
objectMetadataItem: mockedPersonObjectMetadataItem,
relationObjectMetadataItem: mockedCompanyObjectMetadataItem,
},
};
export const Select: Story = {
args: {
fieldMetadataItem: mockedOpportunityObjectMetadataItem.fields.find(
({ name, type }) => name === 'stage' && type === FieldMetadataType.Select,
),
objectMetadataItem: mockedOpportunityObjectMetadataItem,
},
};
export const MultiSelect: Story = {
args: {
...Select.args,
fieldMetadataItem: {
...Select.args!.fieldMetadataItem!,
defaultValue: null,
label: 'Stages',
type: FieldMetadataType.MultiSelect,
},
},
};

View File

@ -1,67 +0,0 @@
import { ReactNode } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { mockedCompanyObjectMetadataItem } from '~/testing/mock-data/metadata';
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,189 @@
import { ReactNode } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider';
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { FieldMetadataType } from '~/generated/graphql';
import {
mockedCompanyObjectMetadataItem,
mockedOpportunityObjectMetadataItem,
mockedPersonObjectMetadataItem,
} from '~/testing/mock-data/metadata';
import { useFieldPreviewValue } from '../useFieldPreviewValue';
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider>
<SnackBarProviderScope snackBarManagerScopeId="snack-bar-manager">
<ObjectMetadataItemsProvider>{children}</ObjectMetadataItemsProvider>
</SnackBarProviderScope>
</MockedProvider>
</RecoilRoot>
);
describe('useFieldPreviewValue', () => {
it('returns null if skip is true', () => {
// Given
const fieldName = 'amount';
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name, type }) =>
name === fieldName && type === FieldMetadataType.Currency,
);
const skip = true;
if (!fieldMetadataItem) {
throw new Error(`Field ${fieldName} not found`);
}
// When
const { result } = renderHook(
() => useFieldPreviewValue({ fieldMetadataItem, skip }),
{ wrapper: Wrapper },
);
// Then
expect(result.current).toBeNull();
});
it("returns the field's preview value for a Currency field", () => {
// Given
const fieldName = 'amount';
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name, type }) =>
name === fieldName && type === FieldMetadataType.Currency,
);
if (!fieldMetadataItem) {
throw new Error(`Field ${fieldName} not found`);
}
// When
const { result } = renderHook(
() => useFieldPreviewValue({ fieldMetadataItem }),
{ wrapper: Wrapper },
);
// Then
expect(result.current).toEqual({
amountMicros: 2000000000,
currencyCode: 'USD',
});
});
it("returns the relation object's label identifier preview value for a Relation field", () => {
// Given
const fieldMetadataItem = {
name: 'people',
type: FieldMetadataType.Relation,
};
const relationObjectMetadataItem = mockedPersonObjectMetadataItem;
// When
const { result } = renderHook(
() =>
useFieldPreviewValue({
fieldMetadataItem,
relationObjectMetadataItem,
}),
{ wrapper: Wrapper },
);
// Then
expect(result.current).toEqual({
__typename: 'Person',
id: '',
name: {
firstName: 'John',
lastName: 'Doe',
},
});
});
it("returns the field's preview value for a Select field", () => {
// Given
const fieldName = 'stage';
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name, type }) =>
name === fieldName && type === FieldMetadataType.Select,
);
if (!fieldMetadataItem) {
throw new Error(`Field ${fieldName} not found`);
}
// When
const { result } = renderHook(
() => useFieldPreviewValue({ fieldMetadataItem }),
{ wrapper: Wrapper },
);
// Then
expect(result.current).toBe('NEW');
});
it("returns the field's preview value for a Multi-Select field", () => {
// Given
const options: FieldMetadataItemOption[] = [
{
color: 'blue',
label: 'Blue',
value: 'BLUE',
id: '1',
position: 0,
},
{
color: 'red',
label: 'Red',
value: 'RED',
id: '2',
position: 1,
},
{
color: 'green',
label: 'Green',
value: 'GREEN',
id: '3',
position: 2,
},
];
const fieldMetadataItem = {
name: 'industry',
type: FieldMetadataType.MultiSelect,
options,
};
// When
const { result } = renderHook(
() => useFieldPreviewValue({ fieldMetadataItem }),
{ wrapper: Wrapper },
);
// Then
expect(result.current).toEqual(options.map(({ value }) => value));
});
it("returns the field's preview value for other field types", () => {
// Given
const fieldName = 'employees';
const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ name }) => name === fieldName,
);
if (!fieldMetadataItem) {
throw new Error(`Field ${fieldName} not found`);
}
// When
const { result } = renderHook(
() => useFieldPreviewValue({ fieldMetadataItem }),
{ wrapper: Wrapper },
);
// Then
expect(result.current).toBe(2000);
});
});

View File

@ -1,106 +0,0 @@
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 { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
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' | 'options' | 'defaultValue'
> & {
// id and name are undefined in create mode (field does not exist yet)
// and are defined in edit mode.
id?: string;
name?: string;
};
objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataItem?: ObjectMetadataItem;
};
export const useFieldPreview = ({
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataItem,
}: 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,
orderBy: {
[fieldMetadataItem.name ?? '']: 'AscNullsLast',
},
});
const [firstRecord] = records;
const fieldPreviewValueFromFirstRecord =
firstRecord && fieldMetadataItem.name
? getFieldPreviewValueFromRecord({
record: firstRecord,
fieldMetadataItem: {
name: fieldMetadataItem.name,
type: fieldMetadataItem.type,
},
})
: null;
const isValueFromFirstRecord =
firstRecord &&
!isFieldValueEmpty({
fieldDefinition: { type: fieldMetadataItem.type },
fieldValue: fieldPreviewValueFromFirstRecord,
selectOptionValues: fieldMetadataItem.options?.map(
(option) => option.value,
),
});
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,
});
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,
};
};

View File

@ -0,0 +1,51 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRelationFieldPreviewValue } from '@/settings/data-model/fields/preview/hooks/useRelationFieldPreviewValue';
import { getCurrencyFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getCurrencyFieldPreviewValue';
import { getFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getFieldPreviewValue';
import { getMultiSelectFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getMultiSelectFieldPreviewValue';
import { getSelectFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getSelectFieldPreviewValue';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type UseFieldPreviewParams = {
fieldMetadataItem: Pick<
FieldMetadataItem,
'type' | 'options' | 'defaultValue'
>;
relationObjectMetadataItem?: ObjectMetadataItem;
skip?: boolean;
};
export const useFieldPreviewValue = ({
fieldMetadataItem,
relationObjectMetadataItem,
skip,
}: UseFieldPreviewParams) => {
const relationFieldPreviewValue = useRelationFieldPreviewValue({
relationObjectMetadataItem: relationObjectMetadataItem ?? {
fields: [],
labelSingular: '',
nameSingular: CoreObjectNameSingular.Company,
},
skip:
skip ||
fieldMetadataItem.type !== FieldMetadataType.Relation ||
!relationObjectMetadataItem,
});
if (skip === true) return null;
switch (fieldMetadataItem.type) {
case FieldMetadataType.Currency:
return getCurrencyFieldPreviewValue({ fieldMetadataItem });
case FieldMetadataType.Relation:
return relationFieldPreviewValue;
case FieldMetadataType.Select:
return getSelectFieldPreviewValue({ fieldMetadataItem });
case FieldMetadataType.MultiSelect:
return getMultiSelectFieldPreviewValue({ fieldMetadataItem });
default:
return getFieldPreviewValue({ fieldMetadataItem });
}
};

View File

@ -0,0 +1,65 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getFieldPreviewValue';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
import { pascalCase } from '~/utils/string/pascalCase';
type UsePreviewRecordParams = {
objectMetadataItem: Pick<
ObjectMetadataItem,
| 'fields'
| 'labelIdentifierFieldMetadataId'
| 'labelSingular'
| 'nameSingular'
>;
skip?: boolean;
};
export const usePreviewRecord = ({
objectMetadataItem,
skip: skipFromProps,
}: UsePreviewRecordParams): ObjectRecord | null => {
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
const skip = skipFromProps || !labelIdentifierFieldMetadataItem;
const { records } = useFindManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
limit: 1,
skip,
});
if (skip) return null;
const [firstRecord] = records;
if (
isDefined(firstRecord) &&
!isFieldValueEmpty({
fieldDefinition: { type: labelIdentifierFieldMetadataItem.type },
fieldValue: firstRecord?.[labelIdentifierFieldMetadataItem.name],
})
) {
return firstRecord;
}
const fieldPreviewValue =
labelIdentifierFieldMetadataItem.type === FieldMetadataType.Text
? objectMetadataItem.labelSingular
: getFieldPreviewValue({
fieldMetadataItem: labelIdentifierFieldMetadataItem,
});
const placeholderRecord = {
__typename: pascalCase(objectMetadataItem.nameSingular),
id: '',
[labelIdentifierFieldMetadataItem.name]: fieldPreviewValue,
};
// If no record was found, or if the label identifier field value is empty, display a placeholder record
return placeholderRecord;
};

View File

@ -0,0 +1,22 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { usePreviewRecord } from '@/settings/data-model/fields/preview/hooks/usePreviewRecord';
type UseRelationFieldPreviewParams = {
relationObjectMetadataItem: Pick<
ObjectMetadataItem,
| 'fields'
| 'labelIdentifierFieldMetadataId'
| 'labelSingular'
| 'nameSingular'
>;
skip?: boolean;
};
export const useRelationFieldPreviewValue = ({
relationObjectMetadataItem,
skip,
}: UseRelationFieldPreviewParams) =>
usePreviewRecord({
objectMetadataItem: relationObjectMetadataItem,
skip,
});

View File

@ -0,0 +1,126 @@
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
mockedCompanyObjectMetadataItem,
mockedOpportunityObjectMetadataItem,
} from '~/testing/mock-data/metadata';
import { getCurrencyFieldPreviewValue } from '../getCurrencyFieldPreviewValue';
describe('getCurrencyFieldPreviewValue', () => {
it('returns null if the field is not a Currency field', () => {
// Given
const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type !== FieldMetadataType.Currency,
);
if (!fieldMetadataItem) {
throw new Error('Field not found');
}
// When
const previewValue = getCurrencyFieldPreviewValue({ fieldMetadataItem });
// Then
expect(previewValue).toBeNull();
});
const fieldName = 'amount';
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name, type }) =>
name === fieldName && type === FieldMetadataType.Currency,
);
if (!fieldMetadataItem) {
throw new Error(`Field '${fieldName}' not found`);
}
it("returns the parsed defaultValue if a valid defaultValue is found in the field's metadata", () => {
// Given
const defaultValue = {
amountMicros: 3000000000,
currencyCode: `'${CurrencyCode.EUR}'`,
};
const fieldMetadataItemWithDefaultValue = {
...fieldMetadataItem,
defaultValue,
};
// When
const previewValue = getCurrencyFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithDefaultValue,
});
// Then
expect(previewValue).toEqual({
amountMicros: defaultValue.amountMicros,
currencyCode: CurrencyCode.EUR,
});
});
it("returns a placeholder amountMicros if it is empty in the field's metadata defaultValue", () => {
// Given
const defaultValue = {
amountMicros: null,
currencyCode: `'${CurrencyCode.EUR}'`,
};
const fieldMetadataItemWithDefaultValue = {
...fieldMetadataItem,
defaultValue,
};
// When
const previewValue = getCurrencyFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithDefaultValue,
});
// Then
expect(previewValue).toEqual({
amountMicros: 2000000000,
currencyCode: CurrencyCode.EUR,
});
});
it("returns a placeholder default value if the defaultValue found in the field's metadata is invalid", () => {
// Given
const defaultValue = {
amountMicros: null,
currencyCode: "''",
};
const fieldMetadataItemWithDefaultValue = {
...fieldMetadataItem,
defaultValue,
};
// When
const previewValue = getCurrencyFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithDefaultValue,
});
// Then
expect(previewValue).toEqual({
amountMicros: 2000000000,
currencyCode: CurrencyCode.USD,
});
});
it("returns a placeholder default value if no defaultValue is found in the field's metadata", () => {
// Given
const defaultValue = null;
const fieldMetadataItemWithDefaultValue = {
...fieldMetadataItem,
defaultValue,
};
// When
const previewValue = getCurrencyFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithDefaultValue,
});
// Then
expect(previewValue).toEqual({
amountMicros: 2000000000,
currencyCode: CurrencyCode.USD,
});
});
});

View File

@ -0,0 +1,85 @@
import { getFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getFieldPreviewValue';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
mockedCompanyObjectMetadataItem,
mockedCustomObjectMetadataItem,
mockedPersonObjectMetadataItem,
} from '~/testing/mock-data/metadata';
describe('getFieldPreviewValue', () => {
it("returns the field's defaultValue from metadata if it exists", () => {
// Given
const fieldName = 'idealCustomerProfile';
const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ name }) => name === fieldName,
);
if (!fieldMetadataItem) {
throw new Error(`Field '${fieldName}' not found`);
}
// When
const result = getFieldPreviewValue({ fieldMetadataItem });
// Then
expect(result).toBe(false);
});
it('returns a placeholder defaultValue if the field metadata does not have a defaultValue', () => {
// Given
const fieldName = 'employees';
const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ name }) => name === fieldName,
);
if (!fieldMetadataItem) {
throw new Error(`Field '${fieldName}' not found`);
}
// When
const result = getFieldPreviewValue({ fieldMetadataItem });
// Then
expect(result).toBe(2000);
expect(result).toBe(
getSettingsFieldTypeConfig(FieldMetadataType.Number)?.defaultValue,
);
});
it('returns null if the field is supported in Settings but has no pre-configured placeholder defaultValue', () => {
// Given
const fieldName = 'company';
const fieldMetadataItem = mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === fieldName,
);
if (!fieldMetadataItem) {
throw new Error(`Field '${fieldName}' not found`);
}
// When
const result = getFieldPreviewValue({ fieldMetadataItem });
// Then
expect(result).toBeNull();
});
it('returns null if the field is not supported in Settings', () => {
// Given
const fieldName = 'position';
const fieldMetadataItem = mockedCustomObjectMetadataItem.fields.find(
({ name }) => name === fieldName,
);
if (!fieldMetadataItem) {
throw new Error(`Field '${fieldName}' not found`);
}
// When
const result = getFieldPreviewValue({ fieldMetadataItem });
// Then
expect(result).toBeNull();
});
});

View File

@ -0,0 +1,129 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
mockedCompanyObjectMetadataItem,
mockedCustomObjectMetadataItem,
} from '~/testing/mock-data/metadata';
import { getMultiSelectFieldPreviewValue } from '../getMultiSelectFieldPreviewValue';
describe('getMultiSelectFieldPreviewValue', () => {
it('returns null if the field is not a Multi-Select field', () => {
// Given
const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type !== FieldMetadataType.MultiSelect,
);
if (!fieldMetadataItem) {
throw new Error('Field not found');
}
// When
const previewValue = getMultiSelectFieldPreviewValue({ fieldMetadataItem });
// Then
expect(previewValue).toBeNull();
});
const fieldName = 'priority';
const selectFieldMetadataItem = mockedCustomObjectMetadataItem.fields.find(
({ name, type }) => name === fieldName && type === FieldMetadataType.Select,
);
if (!selectFieldMetadataItem) {
throw new Error(`Field '${fieldName}' not found`);
}
const fieldMetadataItem = {
...selectFieldMetadataItem,
type: FieldMetadataType.MultiSelect,
};
it("returns the defaultValue as an option value if a valid defaultValue is found in the field's metadata", () => {
// Given
const defaultValue = ["'MEDIUM'", "'LOW'"];
const fieldMetadataItemWithDefaultValue = {
...fieldMetadataItem,
defaultValue,
};
// When
const previewValue = getMultiSelectFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithDefaultValue,
});
// Then
expect(previewValue).toEqual(['MEDIUM', 'LOW']);
});
it("returns all option values if no defaultValue was found in the field's metadata", () => {
// Given
const defaultValue = null;
const fieldMetadataItemWithDefaultValue = {
...fieldMetadataItem,
defaultValue,
};
// When
const previewValue = getMultiSelectFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithDefaultValue,
});
// Then
expect(previewValue).toEqual(['LOW', 'MEDIUM', 'HIGH']);
expect(previewValue).toEqual(
fieldMetadataItemWithDefaultValue.options?.map(({ value }) => value),
);
});
it("returns the first option value if the defaultValue found in the field's metadata is invalid", () => {
// Given
const defaultValue = false;
const fieldMetadataItemWithDefaultValue = {
...fieldMetadataItem,
defaultValue,
};
// When
const previewValue = getMultiSelectFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithDefaultValue,
});
// Then
expect(previewValue).toEqual(['LOW', 'MEDIUM', 'HIGH']);
expect(previewValue).toEqual(
fieldMetadataItemWithDefaultValue.options?.map(({ value }) => value),
);
});
it('returns null if options are not defined', () => {
// Given
const fieldMetadataItemWithNoOptions = {
...fieldMetadataItem,
options: undefined,
};
// When
const previewValue = getMultiSelectFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithNoOptions,
});
// Then
expect(previewValue).toBeNull();
});
it('returns null if options array is empty', () => {
// Given
const fieldMetadataItemWithEmptyOptions = {
...fieldMetadataItem,
options: [],
};
// When
const previewValue = getMultiSelectFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithEmptyOptions,
});
// Then
expect(previewValue).toBeNull();
});
});

View File

@ -0,0 +1,124 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
mockedCompanyObjectMetadataItem,
mockedCustomObjectMetadataItem,
} from '~/testing/mock-data/metadata';
import { getSelectFieldPreviewValue } from '../getSelectFieldPreviewValue';
describe('getSelectFieldPreviewValue', () => {
it('returns null if the field is not a Select field', () => {
// Given
const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type !== FieldMetadataType.Select,
);
if (!fieldMetadataItem) {
throw new Error('Field not found');
}
// When
const previewValue = getSelectFieldPreviewValue({ fieldMetadataItem });
// Then
expect(previewValue).toBeNull();
});
const fieldName = 'priority';
const fieldMetadataItem = mockedCustomObjectMetadataItem.fields.find(
({ name, type }) => name === fieldName && type === FieldMetadataType.Select,
);
if (!fieldMetadataItem) {
throw new Error(`Field '${fieldName}' not found`);
}
it("returns the defaultValue as an option value if a valid defaultValue is found in the field's metadata", () => {
// Given
const defaultValue = "'MEDIUM'";
const fieldMetadataItemWithDefaultValue = {
...fieldMetadataItem,
defaultValue,
};
// When
const previewValue = getSelectFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithDefaultValue,
});
// Then
expect(previewValue).toBe('MEDIUM');
});
it("returns the first option value if no defaultValue was found in the field's metadata", () => {
// Given
const defaultValue = null;
const fieldMetadataItemWithDefaultValue = {
...fieldMetadataItem,
defaultValue,
};
// When
const previewValue = getSelectFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithDefaultValue,
});
// Then
expect(previewValue).toBe('LOW');
expect(previewValue).toBe(
fieldMetadataItemWithDefaultValue.options?.[0]?.value,
);
});
it("returns the first option value if the defaultValue found in the field's metadata is invalid", () => {
// Given
const defaultValue = false;
const fieldMetadataItemWithDefaultValue = {
...fieldMetadataItem,
defaultValue,
};
// When
const previewValue = getSelectFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithDefaultValue,
});
// Then
expect(previewValue).toBe('LOW');
expect(previewValue).toBe(
fieldMetadataItemWithDefaultValue.options?.[0]?.value,
);
});
it('returns null if options are not defined', () => {
// Given
const fieldMetadataItemWithNoOptions = {
...fieldMetadataItem,
options: undefined,
};
// When
const previewValue = getSelectFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithNoOptions,
});
// Then
expect(previewValue).toBeNull();
});
it('returns null if options array is empty', () => {
// Given
const fieldMetadataItemWithEmptyOptions = {
...fieldMetadataItem,
options: [],
};
// When
const previewValue = getSelectFieldPreviewValue({
fieldMetadataItem: fieldMetadataItemWithEmptyOptions,
});
// Then
expect(previewValue).toBeNull();
});
});

View File

@ -0,0 +1,32 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata';
import { currencyFieldDefaultValueSchema } from '@/object-record/record-field/validation-schemas/currencyFieldDefaultValueSchema';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
export const getCurrencyFieldPreviewValue = ({
fieldMetadataItem,
}: {
fieldMetadataItem: Pick<
FieldMetadataItem,
'defaultValue' | 'options' | 'type'
>;
}): FieldCurrencyValue | null => {
if (fieldMetadataItem.type !== FieldMetadataType.Currency) return null;
const placeholderDefaultValue = getSettingsFieldTypeConfig(
FieldMetadataType.Currency,
).defaultValue;
return currencyFieldDefaultValueSchema
.transform((value) => ({
amountMicros: value.amountMicros || placeholderDefaultValue.amountMicros,
currencyCode: stripSimpleQuotesFromString(
value.currencyCode,
) as CurrencyCode,
}))
.catch(placeholderDefaultValue)
.parse(fieldMetadataItem.defaultValue);
};

View File

@ -0,0 +1,34 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings';
import { isDefined } from '~/utils/isDefined';
export const getFieldPreviewValue = ({
fieldMetadataItem,
}: {
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'defaultValue'>;
}) => {
if (!isFieldTypeSupportedInSettings(fieldMetadataItem.type)) return null;
if (
!isFieldValueEmpty({
fieldDefinition: { type: fieldMetadataItem.type },
fieldValue: fieldMetadataItem.defaultValue,
})
) {
return fieldMetadataItem.defaultValue;
}
const fieldTypeConfig = getSettingsFieldTypeConfig(fieldMetadataItem.type);
if (
isDefined(fieldTypeConfig) &&
'defaultValue' in fieldTypeConfig &&
isDefined(fieldTypeConfig.defaultValue)
) {
return fieldTypeConfig.defaultValue;
}
return null;
};

View File

@ -0,0 +1,36 @@
import { isNonEmptyArray } from '@apollo/client/utilities';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata';
import { multiSelectFieldDefaultValueSchema } from '@/object-record/record-field/validation-schemas/multiSelectFieldDefaultValueSchema';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
export const getMultiSelectFieldPreviewValue = ({
fieldMetadataItem,
}: {
fieldMetadataItem: Pick<
FieldMetadataItem,
'defaultValue' | 'options' | 'type'
>;
}): FieldMultiSelectValue => {
if (
fieldMetadataItem.type !== FieldMetadataType.MultiSelect ||
!fieldMetadataItem.options?.length
) {
return null;
}
const allOptionValues = fieldMetadataItem.options.map(({ value }) => value);
return multiSelectFieldDefaultValueSchema(fieldMetadataItem.options)
.refine(isDefined)
.transform((value) =>
value.map(stripSimpleQuotesFromString).filter(isNonEmptyString),
)
.refine(isNonEmptyArray)
.catch(allOptionValues)
.parse(fieldMetadataItem.defaultValue);
};

View File

@ -0,0 +1,33 @@
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldSelectValue } from '@/object-record/record-field/types/FieldMetadata';
import { selectFieldDefaultValueSchema } from '@/object-record/record-field/validation-schemas/selectFieldDefaultValueSchema';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
export const getSelectFieldPreviewValue = ({
fieldMetadataItem,
}: {
fieldMetadataItem: Pick<
FieldMetadataItem,
'defaultValue' | 'options' | 'type'
>;
}): FieldSelectValue => {
if (
fieldMetadataItem.type !== FieldMetadataType.Select ||
!fieldMetadataItem.options?.length
) {
return null;
}
const firstOptionValue = fieldMetadataItem.options[0].value;
return selectFieldDefaultValueSchema(fieldMetadataItem.options)
.refine(isDefined)
.transform(stripSimpleQuotesFromString)
.refine(isNonEmptyString)
.catch(firstOptionValue)
.parse(fieldMetadataItem.defaultValue);
};