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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1184 additions and 510 deletions

View File

@ -186,7 +186,7 @@
"uuid": "^9.0.0",
"vite-tsconfig-paths": "^4.2.1",
"xlsx-ugnis": "^0.19.3",
"zod": "^3.22.2"
"zod": "3.23.8"
},
"devDependencies": {
"@babel/core": "^7.14.5",

View File

@ -46,7 +46,7 @@ export default defineManifest({
permissions: ['activeTab', 'storage', 'identity', 'sidePanel', 'cookies'],
// setting host permissions to all http connections will allow
// setting host permissions to all http connections will allow
// for people who host on their custom domain to get access to
// extension instead of white listing individual urls
host_permissions: ['https://*/*', 'http://*/*'],

View File

@ -1,3 +1,5 @@
import { isString } from '@sniptt/guards';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
@ -26,8 +28,11 @@ import { isFieldSelectValue } from '@/object-record/record-field/types/guards/is
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
import { isDefined } from '~/utils/isDefined';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
const isValueEmpty = (value: unknown) => !isDefined(value) || value === '';
const isValueEmpty = (value: unknown) =>
!isDefined(value) ||
(isString(value) && stripSimpleQuotesFromString(value) === '');
export const isFieldValueEmpty = ({
fieldDefinition,
@ -78,7 +83,8 @@ export const isFieldValueEmpty = ({
if (isFieldFullName(fieldDefinition)) {
return (
!isFieldFullNameValue(fieldValue) ||
isValueEmpty(fieldValue?.firstName + fieldValue?.lastName)
(isValueEmpty(fieldValue?.firstName) &&
isValueEmpty(fieldValue?.lastName))
);
}

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { currencyCodeSchema } from '@/object-record/record-field/validation-schemas/currencyCodeSchema';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
export const currencyFieldDefaultValueSchema = z.object({
amountMicros: z.number().nullable(),
currencyCode: simpleQuotesStringSchema.refine(
(value): value is `'${CurrencyCode}'` =>
currencyCodeSchema.safeParse(stripSimpleQuotesFromString(value)).success,
{ message: 'String is not a valid currencyCode' },
),
});

View File

@ -0,0 +1,26 @@
import { z } from 'zod';
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
export const multiSelectFieldDefaultValueSchema = (
options?: FieldMetadataItemOption[],
) => {
if (!options?.length) return z.array(simpleQuotesStringSchema).nullable();
const optionValues = options.map(({ value }) => value);
return z
.array(
simpleQuotesStringSchema.refine(
(value) => optionValues.includes(stripSimpleQuotesFromString(value)),
{
message: `String is not a valid multi-select option, available options are: ${options.join(
', ',
)}`,
},
),
)
.nullable();
};

View File

@ -0,0 +1,22 @@
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
export const selectFieldDefaultValueSchema = (
options?: FieldMetadataItemOption[],
) => {
if (!options?.length) return simpleQuotesStringSchema.nullable();
const optionValues = options.map(({ value }) => value);
return simpleQuotesStringSchema
.refine(
(value) => optionValues.includes(stripSimpleQuotesFromString(value)),
{
message: `String is not a valid select option, available options are: ${options.join(
', ',
)}`,
},
)
.nullable();
};

View File

@ -32,10 +32,7 @@ export type SettingsFieldTypeConfig = {
defaultValue?: unknown;
};
export const SETTINGS_FIELD_TYPE_CONFIGS: Record<
SettingsSupportedFieldType,
SettingsFieldTypeConfig
> = {
export const SETTINGS_FIELD_TYPE_CONFIGS = {
[FieldMetadataType.Uuid]: {
label: 'Unique ID',
Icon: IconKey,
@ -137,6 +134,9 @@ export const SETTINGS_FIELD_TYPE_CONFIGS: Record<
[FieldMetadataType.RawJson]: {
label: 'JSON',
Icon: IconJson,
defaultValue: `{ "key": "value" }`,
defaultValue: { key: 'value' },
},
};
} as const satisfies Record<
SettingsSupportedFieldType,
SettingsFieldTypeConfig
>;

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);
};

View File

@ -1,176 +0,0 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
mockedCompanyObjectMetadataItem,
mockedOpportunityObjectMetadataItem,
mockedPersonObjectMetadataItem,
} from '~/testing/mock-data/metadata';
import { getFieldDefaultPreviewValue } from '../getFieldDefaultPreviewValue';
describe('getFieldDefaultPreviewValue', () => {
describe('SELECT field', () => {
it('returns the default select option value', () => {
// Given
const objectMetadataItem = mockedOpportunityObjectMetadataItem;
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name }) => name === 'stage',
)!;
// When
const result = getFieldDefaultPreviewValue({
objectMetadataItem,
fieldMetadataItem: { ...fieldMetadataItem, defaultValue: "'MEETING'" },
});
// Then
expect(result).toEqual('MEETING');
});
it('returns the first select option if no default option was found', () => {
// Given
const objectMetadataItem = mockedOpportunityObjectMetadataItem;
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name }) => name === 'stage',
)!;
// When
const result = getFieldDefaultPreviewValue({
objectMetadataItem,
fieldMetadataItem: { ...fieldMetadataItem, defaultValue: null },
});
// Then
expect(result).toEqual(fieldMetadataItem.options![0].value);
});
});
describe('RELATION field', () => {
it('returns a record with a default label identifier (if relation label identifier type !== TEXT)', () => {
// Given
const objectMetadataItem = mockedCompanyObjectMetadataItem;
const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ name }) => name === 'people',
)!;
const relationObjectMetadataItem = mockedPersonObjectMetadataItem;
// When
const result = getFieldDefaultPreviewValue({
objectMetadataItem,
fieldMetadataItem,
relationObjectMetadataItem,
});
// Then
expect(result).toEqual({
name: {
firstName: 'John',
lastName: 'Doe',
},
});
});
it('returns a record with the relation object label singular as label identifier (if relation label identifier type === TEXT)', () => {
// Given
const objectMetadataItem = mockedPersonObjectMetadataItem;
const fieldMetadataItem = mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === 'company',
)!;
const relationObjectMetadataItem = mockedCompanyObjectMetadataItem;
// When
const result = getFieldDefaultPreviewValue({
objectMetadataItem,
fieldMetadataItem,
relationObjectMetadataItem,
});
// Then
expect(result).toEqual({
name: 'Company',
});
});
it('returns null if the relation object does not have a label identifier field', () => {
// Given
const objectMetadataItem = mockedPersonObjectMetadataItem;
const fieldMetadataItem = mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === 'company',
)!;
const relationObjectMetadataItem: ObjectMetadataItem = {
...mockedCompanyObjectMetadataItem,
labelIdentifierFieldMetadataId: null,
fields: mockedCompanyObjectMetadataItem.fields.filter(
({ name }) => name !== 'name',
),
};
// When
const result = getFieldDefaultPreviewValue({
objectMetadataItem,
fieldMetadataItem,
relationObjectMetadataItem,
});
// Then
expect(result).toBeNull();
});
});
describe('Other fields', () => {
it('returns the object singular name as default value for the label identifier field (type TEXT)', () => {
// Given
const objectMetadataItem = mockedCompanyObjectMetadataItem;
const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ name }) => name === 'name',
)!;
// When
const result = getFieldDefaultPreviewValue({
objectMetadataItem,
fieldMetadataItem,
});
// Then
expect(result).toBe('Company');
});
it('returns a default value for the label identifier field (type FULL_NAME)', () => {
// Given
const objectMetadataItem = mockedPersonObjectMetadataItem;
const fieldMetadataItem = mockedPersonObjectMetadataItem.fields.find(
({ name }) => name === 'name',
)!;
// When
const result = getFieldDefaultPreviewValue({
objectMetadataItem,
fieldMetadataItem,
});
// Then
expect(result).toEqual({
firstName: 'John',
lastName: 'Doe',
});
});
it('returns a default value for other field types', () => {
// Given
const objectMetadataItem = mockedCompanyObjectMetadataItem;
const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find(
({ name }) => name === 'domainName',
)!;
// When
const result = getFieldDefaultPreviewValue({
objectMetadataItem,
fieldMetadataItem,
});
// Then
expect(result).toBe(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum magna enim, dapibus non enim in, lacinia faucibus nunc. Sed interdum ante sed felis facilisis, eget ultricies neque molestie. Mauris auctor, justo eu volutpat cursus, libero erat tempus nulla, non sodales lorem lacus a est.',
);
});
});
});

View File

@ -1,89 +0,0 @@
import { isString } from '@sniptt/guards';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
export const getFieldDefaultPreviewValue = ({
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataItem,
}: {
fieldMetadataItem: Pick<
FieldMetadataItem,
'type' | 'defaultValue' | 'options'
> & {
id?: string;
name?: string;
};
objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataItem?: ObjectMetadataItem;
}) => {
if (fieldMetadataItem.type === FieldMetadataType.Select) {
const defaultValue = isString(fieldMetadataItem.defaultValue)
? stripSimpleQuotesFromString(fieldMetadataItem.defaultValue)
: null;
return defaultValue ?? fieldMetadataItem.options?.[0]?.value ?? null;
}
if (fieldMetadataItem.type === FieldMetadataType.MultiSelect) {
const defaultValues = Array.isArray(fieldMetadataItem.defaultValue)
? fieldMetadataItem.defaultValue?.map((defaultValue: `'${string}'`) =>
stripSimpleQuotesFromString(defaultValue),
)
: null;
return defaultValues?.length
? defaultValues
: fieldMetadataItem.options?.map(({ value }) => value) ?? null;
}
if (
fieldMetadataItem.type === FieldMetadataType.Relation &&
isDefined(relationObjectMetadataItem)
) {
const relationLabelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(relationObjectMetadataItem);
if (!relationLabelIdentifierFieldMetadataItem) return null;
const { type: relationLabelIdentifierFieldType } =
relationLabelIdentifierFieldMetadataItem;
const relationFieldTypeConfig = getSettingsFieldTypeConfig(
relationLabelIdentifierFieldType,
);
const defaultRelationLabelIdentifierFieldValue =
relationLabelIdentifierFieldType === FieldMetadataType.Text
? relationObjectMetadataItem.labelSingular
: relationFieldTypeConfig?.defaultValue;
const defaultRelationRecord = {
[relationLabelIdentifierFieldMetadataItem.name]:
defaultRelationLabelIdentifierFieldValue,
};
return defaultRelationRecord;
}
const isLabelIdentifier =
!!fieldMetadataItem.id &&
!!fieldMetadataItem.name &&
isLabelIdentifierField({
fieldMetadataItem: {
id: fieldMetadataItem.id,
name: fieldMetadataItem.name,
},
objectMetadataItem,
});
const fieldTypeConfig = getSettingsFieldTypeConfig(fieldMetadataItem.type);
return isLabelIdentifier && fieldMetadataItem.type === FieldMetadataType.Text
? objectMetadataItem.labelSingular
: fieldTypeConfig?.defaultValue;
};

View File

@ -1,8 +1,13 @@
import { SETTINGS_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsFieldTypeConfigs';
import { SettingsSupportedFieldType } from '@/settings/data-model/types/SettingsSupportedFieldType';
import { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const getSettingsFieldTypeConfig = (fieldType: FieldMetadataType) =>
isFieldTypeSupportedInSettings(fieldType)
export const getSettingsFieldTypeConfig = <T extends FieldMetadataType>(
fieldType: T,
) =>
(isFieldTypeSupportedInSettings(fieldType)
? SETTINGS_FIELD_TYPE_CONFIGS[fieldType]
: undefined) as T extends SettingsSupportedFieldType
? (typeof SETTINGS_FIELD_TYPE_CONFIGS)[T]
: undefined;

View File

@ -1,4 +1,4 @@
import { id } from 'date-fns/locale';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import {
FieldMetadataType,
ObjectEdge,
@ -3787,10 +3787,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
isNullable: true,
createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: {
lastName: "''",
firstName: "''",
},
defaultValue: null,
relationDefinition: null,
fromRelationMetadata: null,
toRelationMetadata: null,
@ -3876,10 +3873,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
isNullable: true,
createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: {
url: "''",
label: "''",
},
defaultValue: null,
relationDefinition: null,
fromRelationMetadata: null,
toRelationMetadata: null,
@ -10562,7 +10556,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: {
amountMicros: null,
currencyCode: "''",
currencyCode: `'${CurrencyCode.USD}'`,
},
relationDefinition: null,
fromRelationMetadata: null,
@ -10822,10 +10816,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
isNullable: true,
createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: {
url: "''",
label: "''",
},
defaultValue: null,
relationDefinition: null,
fromRelationMetadata: null,
toRelationMetadata: null,
@ -12259,7 +12250,7 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery =
nameSingular: 'company',
namePlural: 'companies',
isSystem: false,
isRemote: false,
isRemote: false,
},
},
},
@ -12345,7 +12336,7 @@ isRemote: false,
nameSingular: 'opportunity',
namePlural: 'opportunities',
isSystem: false,
isRemote: false,
isRemote: false,
},
},
},
@ -12408,7 +12399,7 @@ isRemote: false,
nameSingular: 'listing',
namePlural: 'listings',
isSystem: false,
isRemote: false,
isRemote: false,
},
},
},
@ -13027,7 +13018,7 @@ isRemote: false,
nameSingular: 'opportunity',
namePlural: 'opportunities',
isSystem: false,
isRemote: false,
isRemote: false,
},
},
},
@ -13218,7 +13209,7 @@ isRemote: false,
nameSingular: 'company',
namePlural: 'companies',
isSystem: false,
isRemote: false,
isRemote: false,
},
},
relationDefinition: {

View File

@ -1,6 +1,7 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapPaginatedObjectMetadataItemsToObjectMetadataItems } from '@/object-metadata/utils/mapPaginatedObjectMetadataItemsToObjectMetadataItems';
import {
FieldMetadataType,
ObjectEdge,
ObjectMetadataItemsQuery,
} from '~/generated-metadata/graphql';
@ -237,6 +238,48 @@ const customObjectMetadataItemEdge: ObjectEdge = {
toRelationMetadata: null,
},
},
{
__typename: 'fieldEdge',
node: {
__typename: 'field',
id: 'e07fcc3f-beec-4d91-8488-9d1d2cfa5f99',
type: FieldMetadataType.Select,
name: 'priority',
label: 'Priority',
description: 'A custom Select example',
icon: 'IconWarning',
isCustom: true,
isActive: true,
isSystem: false,
options: [
{
id: '2b98dc02-0d99-4f3e-890e-e2e6b8f3196c',
value: 'LOW',
label: 'Low',
color: 'turquoise',
},
{
id: 'd925a8de-d8ec-4b59-a079-64f4012e3311',
value: 'MEDIUM',
label: 'Medium',
color: 'yellow',
},
{
id: '3',
value: 'HIGH',
label: 'High',
color: 'red',
},
],
isNullable: true,
createdAt: '2024-04-08T12:48:49.538Z',
updatedAt: '2024-04-08T12:48:49.538Z',
defaultValue: null,
relationDefinition: null,
fromRelationMetadata: null,
toRelationMetadata: null,
},
},
],
},
},

View File

@ -8,6 +8,8 @@ import { logError } from './logError';
export const DEFAULT_DATE_LOCALE = 'en-EN';
export const parseDate = (dateToParse: Date | string | number) => {
if (dateToParse === 'now') return DateTime.fromJSDate(new Date());
let formattedDate: DateTime | null = null;
if (!dateToParse) {

View File

@ -46640,7 +46640,7 @@ __metadata:
vite-tsconfig-paths: "npm:^4.2.1"
vitest: "npm:1.4.0"
xlsx-ugnis: "npm:^0.19.3"
zod: "npm:^3.22.2"
zod: "npm:3.23.8"
languageName: unknown
linkType: soft
@ -49717,7 +49717,14 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.20.2, zod@npm:^3.22.2":
"zod@npm:3.23.8":
version: 3.23.8
resolution: "zod@npm:3.23.8"
checksum: 8f14c87d6b1b53c944c25ce7a28616896319d95bc46a9660fe441adc0ed0a81253b02b5abdaeffedbeb23bdd25a0bf1c29d2c12dd919aef6447652dd295e3e69
languageName: node
linkType: hard
"zod@npm:^3.20.2":
version: 3.22.4
resolution: "zod@npm:3.22.4"
checksum: 7578ab283dac0eee66a0ad0fc4a7f28c43e6745aadb3a529f59a4b851aa10872b3890398b3160f257f4b6817b4ce643debdda4fb21a2c040adda7862cab0a587