Refactored all FieldDisplay types for performance optimization (#5768)

This PR is the second part of
https://github.com/twentyhq/twenty/pull/5693.

It optimizes all remaining field types.

The observed improvements are :
- x2 loading time improvement on table rows
- more consistent render time

Here's a summary of measured improvements, what's given here is the
average of hundreds of renders with a React Profiler component. (in our
Storybook performance stories)

| Component | Before (µs) | After (µs) |
| ----- | ------------- | --- |
| TextFieldDisplay | 127 | 83 |
| EmailFieldDisplay | 117 | 83 |
| NumberFieldDisplay | 97 | 56 |
| DateFieldDisplay | 240 | 52 |
| CurrencyFieldDisplay | 236 | 110 |
| FullNameFieldDisplay | 131 | 85 |
| AddressFieldDisplay | 118 | 81 |
| BooleanFieldDisplay | 130 | 100 |
| JSONFieldDisplay | 248 | 49 |
| LinksFieldDisplay | 1180 | 140 |
| LinkFieldDisplay | 140 | 78 |
| MultiSelectFieldDisplay | 770 | 130 |
| SelectFieldDisplay | 230 | 87 |
This commit is contained in:
Lucas Bordeau
2024-06-12 18:36:25 +02:00
committed by GitHub
parent 007e0e8b0e
commit 03b3c8a67a
101 changed files with 17167 additions and 15795 deletions

View File

@ -3,10 +3,12 @@ import {
mockedObjectMetadataItems,
mockedPersonObjectMetadataItem,
} from '~/testing/mock-data/metadata';
import { mockedPeopleData } from '~/testing/mock-data/people';
import { getPeopleMock } from '~/testing/mock-data/people';
import { getRecordNodeFromRecord } from '../getRecordNodeFromRecord';
const peopleMock = getPeopleMock();
describe('getRecordNodeFromRecord', () => {
it('computes relation records cache references by default', () => {
// Given
@ -19,7 +21,7 @@ describe('getRecordNodeFromRecord', () => {
name: true,
company: true,
};
const record = mockedPeopleData[0];
const record = peopleMock[0];
// When
const result = getRecordNodeFromRecord({
@ -33,12 +35,12 @@ describe('getRecordNodeFromRecord', () => {
expect(result).toEqual({
__typename: 'Person',
company: {
__ref: 'Company:5c21e19e-e049-4393-8c09-3e3f8fb09ecb',
__ref: `Company:${record.company.id}`,
},
name: {
__typename: 'FullName',
firstName: 'Alexandre',
lastName: 'Prot',
firstName: record.name.firstName,
lastName: record.name.lastName,
},
});
});
@ -54,7 +56,7 @@ describe('getRecordNodeFromRecord', () => {
name: true,
company: true,
};
const record = mockedPeopleData[0];
const record = peopleMock[0];
const computeReferences = false;
// When
@ -72,8 +74,8 @@ describe('getRecordNodeFromRecord', () => {
company: record.company,
name: {
__typename: 'FullName',
firstName: 'Alexandre',
lastName: 'Prot',
firstName: record.name.firstName,
lastName: record.name.lastName,
},
});
});

View File

@ -1,5 +1,7 @@
import { gql } from '@apollo/client';
import { mockedPeopleData } from '~/testing/mock-data/people';
import { getPeopleMock } from '~/testing/mock-data/people';
const peopleMock = getPeopleMock();
export const query = gql`
query FindDuplicatePerson($id: ID!) {
@ -49,11 +51,11 @@ export const responseData = {
personDuplicates: {
edges: [
{
node: { ...mockedPeopleData[0], updatedAt: '' },
node: { ...peopleMock[0], updatedAt: '' },
cursor: 'cursor1',
},
{
node: { ...mockedPeopleData[1], updatedAt: '' },
node: { ...peopleMock[1], updatedAt: '' },
cursor: 'cursor2',
},
],

View File

@ -1,8 +1,10 @@
import { useAddressField } from '@/object-record/record-field/meta-types/hooks/useAddressField';
import { isNonEmptyString } from '@sniptt/guards';
import { useAddressFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useAddressFieldDisplay';
import { TextDisplay } from '@/ui/field/display/components/TextDisplay';
export const AddressFieldDisplay = () => {
const { fieldValue } = useAddressField();
const { fieldValue } = useAddressFieldDisplay();
const content = [
fieldValue?.addressStreet1,
@ -10,7 +12,7 @@ export const AddressFieldDisplay = () => {
fieldValue?.addressCity,
fieldValue?.addressCountry,
]
.filter(Boolean)
.filter(isNonEmptyString)
.join(', ');
return <TextDisplay text={content} />;

View File

@ -1,8 +1,8 @@
import { useBooleanField } from '@/object-record/record-field/meta-types/hooks/useBooleanField';
import { useBooleanFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useBooleanFieldDisplay';
import { BooleanDisplay } from '@/ui/field/display/components/BooleanDisplay';
export const BooleanFieldDisplay = () => {
const { fieldValue } = useBooleanField();
const { fieldValue } = useBooleanFieldDisplay();
return <BooleanDisplay value={fieldValue} />;
};

View File

@ -1,9 +1,8 @@
import { useCurrencyFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useCurrencyFieldDisplay';
import { CurrencyDisplay } from '@/ui/field/display/components/CurrencyDisplay';
import { useCurrencyField } from '../../hooks/useCurrencyField';
export const CurrencyFieldDisplay = () => {
const { fieldValue } = useCurrencyField();
const { fieldValue } = useCurrencyFieldDisplay();
return <CurrencyDisplay currencyValue={fieldValue} />;
};

View File

@ -1,9 +1,8 @@
import { useDateFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useDateFieldDisplay';
import { DateDisplay } from '@/ui/field/display/components/DateDisplay';
import { useDateField } from '../../hooks/useDateField';
export const DateFieldDisplay = () => {
const { fieldValue } = useDateField();
const { fieldValue } = useDateFieldDisplay();
return <DateDisplay value={fieldValue} />;
};

View File

@ -1,9 +1,8 @@
import { useDateTimeFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useDateTimeFieldDisplay';
import { DateTimeDisplay } from '@/ui/field/display/components/DateTimeDisplay';
import { useDateTimeField } from '../../hooks/useDateTimeField';
export const DateTimeFieldDisplay = () => {
const { fieldValue } = useDateTimeField();
const { fieldValue } = useDateTimeFieldDisplay();
return <DateTimeDisplay value={fieldValue} />;
};

View File

@ -1,9 +1,8 @@
import { useEmailFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useEmailFieldDisplay';
import { EmailDisplay } from '@/ui/field/display/components/EmailDisplay';
import { useEmailField } from '../../hooks/useEmailField';
export const EmailFieldDisplay = () => {
const { fieldValue } = useEmailField();
const { fieldValue } = useEmailFieldDisplay();
return <EmailDisplay value={fieldValue} />;
};

View File

@ -1,11 +1,13 @@
import { useFullNameField } from '@/object-record/record-field/meta-types/hooks/useFullNameField';
import { isNonEmptyString } from '@sniptt/guards';
import { useFullNameFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useFullNameFieldDisplay';
import { TextDisplay } from '@/ui/field/display/components/TextDisplay';
export const FullNameFieldDisplay = () => {
const { fieldValue } = useFullNameField();
const { fieldValue } = useFullNameFieldDisplay();
const content = [fieldValue.firstName, fieldValue.lastName]
.filter(Boolean)
const content = [fieldValue?.firstName, fieldValue?.lastName]
.filter(isNonEmptyString)
.join(' ');
return <TextDisplay text={content} />;

View File

@ -1,19 +1,15 @@
import { useJsonField } from '@/object-record/record-field/meta-types/hooks/useJsonField';
import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue';
import { useJsonFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useJsonFieldDisplay';
import { JsonDisplay } from '@/ui/field/display/components/JsonDisplay';
import { isDefined } from '~/utils/isDefined';
export const JsonFieldDisplay = () => {
const { fieldValue, maxWidth } = useJsonField();
const { fieldValue, maxWidth } = useJsonFieldDisplay();
return (
<JsonDisplay
text={
isFieldRawJsonValue(fieldValue) && isDefined(fieldValue)
? JSON.stringify(fieldValue)
: ''
}
maxWidth={maxWidth}
/>
);
if (!isDefined(fieldValue)) {
return <></>;
}
const value = JSON.stringify(fieldValue);
return <JsonDisplay text={value} maxWidth={maxWidth} />;
};

View File

@ -1,9 +1,8 @@
import { useLinkFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useLinkFieldDisplay';
import { LinkDisplay } from '@/ui/field/display/components/LinkDisplay';
import { useLinkField } from '../../hooks/useLinkField';
export const LinkFieldDisplay = () => {
const { fieldValue } = useLinkField();
const { fieldValue } = useLinkFieldDisplay();
return <LinkDisplay value={fieldValue} />;
};

View File

@ -1,9 +1,9 @@
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
import { useLinksFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useLinksFieldDisplay';
import { LinksDisplay } from '@/ui/field/display/components/LinksDisplay';
export const LinksFieldDisplay = () => {
const { fieldValue } = useLinksField();
const { fieldValue } = useLinksFieldDisplay();
const { isFocused } = useFieldFocus();

View File

@ -1,23 +1,39 @@
import { Tag } from 'twenty-ui';
import { styled } from '@linaria/react';
import { Tag, THEME_COMMON } from 'twenty-ui';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField';
import { useMultiSelectFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useMultiSelectFieldDisplay';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
const spacing1 = THEME_COMMON.spacing(1);
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${spacing1};
justify-content: flex-start;
max-width: 100%;
overflow: hidden;
width: 100%;
`;
export const MultiSelectFieldDisplay = () => {
const { fieldValues, fieldDefinition } = useMultiSelectField();
const { fieldValue, fieldDefinition } = useMultiSelectFieldDisplay();
const { isFocused } = useFieldFocus();
const selectedOptions = fieldValues
const selectedOptions = fieldValue
? fieldDefinition.metadata.options?.filter((option) =>
fieldValues.includes(option.value),
fieldValue.includes(option.value),
)
: [];
if (!selectedOptions) return null;
return (
return isFocused ? (
<ExpandableList isChipCountDisplayed={isFocused}>
{selectedOptions.map((selectedOption, index) => (
<Tag
@ -27,5 +43,16 @@ export const MultiSelectFieldDisplay = () => {
/>
))}
</ExpandableList>
) : (
<StyledContainer>
{selectedOptions.map((selectedOption, index) => (
<Tag
preventShrink
key={index}
color={selectedOption.color}
text={selectedOption.label}
/>
))}
</StyledContainer>
);
};

View File

@ -1,9 +1,8 @@
import { useNumberFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useNumberFieldDisplay';
import { NumberDisplay } from '@/ui/field/display/components/NumberDisplay';
import { useNumberField } from '../../hooks/useNumberField';
export const NumberFieldDisplay = () => {
const { fieldValue } = useNumberField();
const { fieldValue } = useNumberFieldDisplay();
return <NumberDisplay value={fieldValue} />;
};

View File

@ -1,17 +1,24 @@
import { Tag } from 'twenty-ui';
import { useSelectField } from '../../hooks/useSelectField';
import { useSelectFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useSelectFieldDisplay';
import { isDefined } from '~/utils/isDefined';
export const SelectFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useSelectField();
const { fieldValue, fieldDefinition } = useSelectFieldDisplay();
const selectedOption = fieldDefinition.metadata.options?.find(
(option) => option.value === fieldValue,
);
return selectedOption ? (
<Tag color={selectedOption.color} text={selectedOption.label} />
) : (
<></>
if (!isDefined(selectedOption)) {
return <></>;
}
return (
<Tag
preventShrink
color={selectedOption.color}
text={selectedOption.label}
/>
);
};

View File

@ -1,9 +1,8 @@
import { useTextFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useTextFieldDisplay';
import { TextDisplay } from '@/ui/field/display/components/TextDisplay';
import { useTextField } from '../../hooks/useTextField';
export const TextFieldDisplay = () => {
const { fieldValue, maxWidth } = useTextField();
const { fieldValue, maxWidth } = useTextFieldDisplay();
return <TextDisplay text={fieldValue} maxWidth={maxWidth} />;
};

View File

@ -1,67 +0,0 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { FieldMetadataType } from '~/generated/graphql';
import { FieldContext } from '../../../../contexts/FieldContext';
import { useDateTimeField } from '../../../hooks/useDateTimeField';
import { DateTimeFieldDisplay } from '../DateTimeFieldDisplay';
const formattedDate = new Date('2023-04-01');
const DateFieldValueSetterEffect = ({ value }: { value: string }) => {
const { setFieldValue } = useDateTimeField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/DateFieldDisplay',
decorators: [
(Story, { args }) => (
<FieldContext.Provider
value={{
entityId: '',
isLabelIdentifier: false,
fieldDefinition: {
fieldMetadataId: 'date',
label: 'Date',
type: FieldMetadataType.DateTime,
iconName: 'IconCalendarEvent',
metadata: {
fieldName: 'Date',
objectMetadataNameSingular: 'person',
},
},
hotkeyScope: 'hotkey-scope',
}}
>
<DateFieldValueSetterEffect value={args.value} />
<Story />
</FieldContext.Provider>
),
ComponentDecorator,
],
component: DateTimeFieldDisplay,
argTypes: { value: { control: 'date' } },
args: {
value: formattedDate,
},
};
export default meta;
type Story = StoryObj<typeof DateTimeFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};

View File

@ -1,67 +0,0 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useEmailField } from '@/object-record/record-field/meta-types/hooks/useEmailField';
import { FieldMetadataType } from '~/generated/graphql';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { EmailFieldDisplay } from '../EmailFieldDisplay';
const EmailFieldValueSetterEffect = ({ value }: { value: string }) => {
const { setFieldValue } = useEmailField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/EmailFieldDisplay',
decorators: [
MemoryRouterDecorator,
(Story, { args }) => (
<FieldContext.Provider
value={{
entityId: '',
isLabelIdentifier: false,
fieldDefinition: {
fieldMetadataId: 'email',
label: 'Email',
type: FieldMetadataType.Email,
iconName: 'IconLink',
metadata: {
fieldName: 'Email',
placeHolder: 'Email',
objectMetadataNameSingular: 'person',
},
},
hotkeyScope: 'hotkey-scope',
}}
>
<EmailFieldValueSetterEffect value={args.value} />
<Story />
</FieldContext.Provider>
),
ComponentDecorator,
],
component: EmailFieldDisplay,
args: {
value: 'Test@Test.test',
},
};
export default meta;
type Story = StoryObj<typeof EmailFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};

View File

@ -1,83 +0,0 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { FieldMetadataType } from '~/generated/graphql';
import { FieldContext } from '../../../../contexts/FieldContext';
import { useNumberField } from '../../../hooks/useNumberField';
import { NumberFieldDisplay } from '../NumberFieldDisplay';
const NumberFieldValueSetterEffect = ({ value }: { value: number }) => {
const { setFieldValue } = useNumberField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/NumberFieldDisplay',
decorators: [
(Story, { args }) => (
<FieldContext.Provider
value={{
entityId: '',
isLabelIdentifier: false,
fieldDefinition: {
fieldMetadataId: 'number',
label: 'Number',
type: FieldMetadataType.Number,
iconName: 'Icon123',
metadata: {
fieldName: 'Number',
placeHolder: 'Number',
isPositive: true,
objectMetadataNameSingular: 'person',
},
},
hotkeyScope: 'hotkey-scope',
useUpdateRecord: () => [() => undefined, {}],
}}
>
<NumberFieldValueSetterEffect value={args.value} />
<Story />
</FieldContext.Provider>
),
ComponentDecorator,
],
component: NumberFieldDisplay,
};
export default meta;
type Story = StoryObj<typeof NumberFieldDisplay>;
export const Default: Story = {
args: {
value: 100,
},
};
export const Elipsis: Story = {
args: {
value: 1e100,
},
parameters: {
container: { width: 100 },
},
};
export const Negative: Story = {
args: {
value: -1000,
},
};
export const Float: Story = {
args: {
value: 1.357802,
},
};

View File

@ -0,0 +1,54 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { AddressFieldDisplay } from '@/object-record/record-field/meta-types/display/components/AddressFieldDisplay';
import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/AddressFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('person', 'testAddress'),
ComponentDecorator,
],
component: AddressFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof AddressFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 100 },
},
decorators: [
getFieldDecorator('person', 'testAddress', {
addressCity:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam',
addressCountry: 'United States',
addressStreet1: '1234 Elm Street',
addressStreet2: 'Apt 1234',
addressLat: 0,
addressLng: 0,
addressPostcode: '12345',
addressState: 'CA',
} as FieldAddressValue),
],
};
export const Performance = getProfilingStory({
componentName: 'AddressFieldDisplay',
averageThresholdInMs: 0.15,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,34 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/BooleanFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('person', 'testBoolean'),
ComponentDecorator,
],
component: BooleanFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof BooleanFieldDisplay>;
export const Default: Story = {};
export const Performance = getProfilingStory({
componentName: 'BooleanFieldDisplay',
averageThresholdInMs: 0.15,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,64 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { CurrencyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/CurrencyFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/CurrencyFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('company', 'annualRecurringRevenue'),
ComponentDecorator,
],
component: CurrencyFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof CurrencyFieldDisplay>;
export const Default: Story = {};
export const Millions: Story = {
decorators: [
getFieldDecorator('company', 'annualRecurringRevenue', {
__typename: 'Currency',
amountMicros: 18200000 * 1000000,
currencyCode: 'EUR',
}),
],
};
export const Billions: Story = {
decorators: [
getFieldDecorator('company', 'annualRecurringRevenue', {
__typename: 'Currency',
amountMicros: 3230000000 * 1000000,
currencyCode: 'USD',
}),
],
};
export const Bazillions: Story = {
decorators: [
getFieldDecorator('company', 'annualRecurringRevenue', {
__typename: 'Currency',
amountMicros: 1e100,
currencyCode: 'USD',
}),
],
};
export const Performance = getProfilingStory({
componentName: 'CurrencyFieldDisplay',
averageThresholdInMs: 0.2,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,40 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { DateFieldDisplay } from '@/object-record/record-field/meta-types/display/components/DateFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/DateFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('person', 'createdAt'),
ComponentDecorator,
],
component: DateFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof DateFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};
export const Performance = getProfilingStory({
componentName: 'DateFieldDisplay',
averageThresholdInMs: 0.1,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,40 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { DateTimeFieldDisplay } from '@/object-record/record-field/meta-types/display/components/DateTimeFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/DateTimeFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('person', 'createdAt'),
ComponentDecorator,
],
component: DateTimeFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof DateTimeFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};
export const Performance = getProfilingStory({
componentName: 'DateTimeFieldDisplay',
averageThresholdInMs: 0.1,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,47 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { EmailFieldDisplay } from '@/object-record/record-field/meta-types/display/components/EmailFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/EmailFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('person', 'email'),
ComponentDecorator,
],
component: EmailFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof EmailFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
decorators: [
getFieldDecorator(
'person',
'email',
'asdasdasdaksjdhkajshdkajhasmdkamskdsd@asdkjhaksjdhaksjd.com',
),
],
};
export const Performance = getProfilingStory({
componentName: 'EmailFieldDisplay',
averageThresholdInMs: 0.5,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,40 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { FullNameFieldDisplay } from '@/object-record/record-field/meta-types/display/components/FullNameFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/FullNameFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('person', 'name'),
ComponentDecorator,
],
component: FullNameFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof FullNameFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};
export const Performance = getProfilingStory({
componentName: 'FullNameFieldDisplay',
averageThresholdInMs: 0.5,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,40 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { JsonFieldDisplay } from '@/object-record/record-field/meta-types/display/components/JsonFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/JsonFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('person', 'testJson'),
ComponentDecorator,
],
component: JsonFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof JsonFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};
export const Performance = getProfilingStory({
componentName: 'JsonFieldDisplay',
averageThresholdInMs: 0.1,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,34 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { LinkFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinkFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/LinkFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('person', 'testLink'),
ComponentDecorator,
],
component: LinkFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof LinkFieldDisplay>;
export const Default: Story = {};
export const Performance = getProfilingStory({
componentName: 'LinkFieldDisplay',
averageThresholdInMs: 0.5,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,69 @@
import { useContext, useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { FieldFocusContext } from '@/object-record/record-field/contexts/FieldFocusContext';
import { FieldFocusContextProvider } from '@/object-record/record-field/contexts/FieldFocusContextProvider';
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const FieldFocusEffect = () => {
const { setIsFocused } = useContext(FieldFocusContext);
useEffect(() => {
setIsFocused(true);
}, [setIsFocused]);
return <></>;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/LinksFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('person', 'testLinks'),
ComponentDecorator,
],
component: LinksFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof LinksFieldDisplay>;
export const Default: Story = {};
export const ExpandableList: Story = {
decorators: [
(Story) => {
return (
<FieldFocusContextProvider>
<FieldFocusEffect />
<Story />
</FieldFocusContextProvider>
);
},
],
parameters: {
container: { width: 100 },
},
};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};
export const Performance = getProfilingStory({
componentName: 'LinksFieldDisplay',
averageThresholdInMs: 0.5,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,69 @@
import { useContext, useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { FieldFocusContext } from '@/object-record/record-field/contexts/FieldFocusContext';
import { FieldFocusContextProvider } from '@/object-record/record-field/contexts/FieldFocusContextProvider';
import { MultiSelectFieldDisplay } from '@/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const FieldFocusEffect = () => {
const { setIsFocused } = useContext(FieldFocusContext);
useEffect(() => {
setIsFocused(true);
}, [setIsFocused]);
return <></>;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/MultiSelectFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('person', 'testMultiSelect'),
ComponentDecorator,
],
component: MultiSelectFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof MultiSelectFieldDisplay>;
export const Default: Story = {};
export const ExpandableList: Story = {
decorators: [
(Story) => {
return (
<FieldFocusContextProvider>
<FieldFocusEffect />
<Story />
</FieldFocusContextProvider>
);
},
],
parameters: {
container: { width: 130 },
},
};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};
export const Performance = getProfilingStory({
componentName: 'MultiSelectFieldDisplay',
averageThresholdInMs: 0.2,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,53 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { NumberFieldDisplay } from '@/object-record/record-field/meta-types/display/components/NumberFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/NumberFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('company', 'employees'),
ComponentDecorator,
],
component: NumberFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof NumberFieldDisplay>;
export const Default: Story = {
args: {
value: 100,
},
};
export const Elipsis: Story = {
decorators: [getFieldDecorator('company', 'employees', 1e100)],
parameters: {
container: { width: 100 },
},
};
export const Negative: Story = {
decorators: [getFieldDecorator('company', 'employees', -1000)],
};
export const Float: Story = {
decorators: [getFieldDecorator('company', 'employees', 3.14159)],
};
export const Performance = getProfilingStory({
componentName: 'NumberFieldDisplay',
averageThresholdInMs: 0.5,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -33,9 +33,6 @@ export const Elipsis: Story = {
};
export const WrongNumber: Story = {
parameters: {
container: { width: 50 },
},
decorators: [getFieldDecorator('person', 'phone', 'sdklaskdj')],
};

View File

@ -0,0 +1,40 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { SelectFieldDisplay } from '@/object-record/record-field/meta-types/display/components/SelectFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/SelectFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('person', 'testSelect'),
ComponentDecorator,
],
component: SelectFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof SelectFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};
export const Performance = getProfilingStory({
componentName: 'SelectFieldDisplay',
averageThresholdInMs: 0.2,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -1,54 +1,22 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { FieldMetadataType } from '~/generated/graphql';
import { FieldContext } from '../../../../contexts/FieldContext';
import { useTextField } from '../../../hooks/useTextField';
import { TextFieldDisplay } from '../TextFieldDisplay';
const TextFieldValueSetterEffect = ({ value }: { value: string }) => {
const { setFieldValue } = useTextField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
import { TextFieldDisplay } from '@/object-record/record-field/meta-types/display/components/TextFieldDisplay';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
const meta: Meta = {
title: 'UI/Data/Field/Display/TextFieldDisplay',
decorators: [
(Story, { args }) => (
<FieldContext.Provider
value={{
entityId: '',
isLabelIdentifier: false,
fieldDefinition: {
fieldMetadataId: 'text',
label: 'Text',
type: FieldMetadataType.Text,
iconName: 'IconLink',
metadata: {
fieldName: 'Text',
placeHolder: 'Text',
objectMetadataNameSingular: 'person',
},
},
hotkeyScope: 'hotkey-scope',
}}
>
<TextFieldValueSetterEffect value={args.value} />
<Story />
</FieldContext.Provider>
),
MemoryRouterDecorator,
getFieldDecorator('person', 'city'),
ComponentDecorator,
],
component: TextFieldDisplay,
args: {
value: 'Lorem ipsum',
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
@ -59,11 +27,21 @@ type Story = StoryObj<typeof TextFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
args: {
value:
'Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae rerum fugiat veniam illum accusantium saepe, voluptate inventore libero doloribus doloremque distinctio blanditiis amet quis dolor a nulla? Placeat nam itaque rerum esse quidem animi, temporibus saepe debitis commodi quia eius eos minus inventore. Voluptates fugit optio sit ab consectetur ipsum, neque eius atque blanditiis. Ullam provident at porro minima, nobis vero dicta consequatur maxime laboriosam fugit repudiandae repellat tempore voluptas non voluptatibus neque aliquam ducimus doloribus ipsa? Sapiente suscipit unde modi commodi possimus doloribus eum voluptatibus, architecto laudantium, magnam, eos numquam exercitationem est maxime explicabo odio nemo qui distinctio temporibus.',
},
parameters: {
container: { width: 100 },
},
decorators: [
getFieldDecorator(
'person',
'city',
'Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae rerum fugiat veniam illum accusantium saepe, voluptate inventore libero doloribus doloremque distinctio blanditiis amet quis dolor a nulla? Placeat nam itaque rerum esse quidem animi, temporibus saepe debitis commodi quia eius eos minus inventore. Voluptates fugit optio sit ab consectetur ipsum, neque eius atque blanditiis. Ullam provident at porro minima, nobis vero dicta consequatur maxime laboriosam fugit repudiandae repellat tempore voluptas non voluptatibus neque aliquam ducimus doloribus ipsa? Sapiente suscipit unde modi commodi possimus doloribus eum voluptatibus, architecto laudantium, magnam, eos numquam exercitationem est maxime explicabo odio nemo qui distinctio temporibus.',
),
],
};
export const Performance = getProfilingStory({
componentName: 'TextFieldDisplay',
averageThresholdInMs: 0.5,
numberOfRuns: 50,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,22 @@
import { useContext } from 'react';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
import { FieldAddressValue } from '../../types/FieldMetadata';
export const useAddressFieldDisplay = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<FieldAddressValue | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition,
fieldValue,
};
};

View File

@ -0,0 +1,21 @@
import { useContext } from 'react';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
export const useBooleanFieldDisplay = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<boolean | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition,
fieldValue,
};
};

View File

@ -0,0 +1,22 @@
import { useContext } from 'react';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
import { FieldCurrencyValue } from '../../types/FieldMetadata';
export const useCurrencyFieldDisplay = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<FieldCurrencyValue | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition,
fieldValue,
};
};

View File

@ -0,0 +1,24 @@
import { useContext } from 'react';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
export const useDateFieldDisplay = () => {
const { entityId, fieldDefinition, hotkeyScope, clearable } =
useContext(FieldContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<string | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition,
fieldValue,
hotkeyScope,
clearable,
};
};

View File

@ -0,0 +1,24 @@
import { useContext } from 'react';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
export const useDateTimeFieldDisplay = () => {
const { entityId, fieldDefinition, hotkeyScope, clearable } =
useContext(FieldContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<string | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition,
fieldValue,
hotkeyScope,
clearable,
};
};

View File

@ -0,0 +1,22 @@
import { useContext } from 'react';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
export const useEmailFieldDisplay = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<string | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition,
fieldValue,
hotkeyScope,
};
};

View File

@ -0,0 +1,22 @@
import { useContext } from 'react';
import { FieldFullNameValue } from '@/object-record/record-field/types/FieldMetadata';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
export const useFullNameFieldDisplay = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<FieldFullNameValue | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition,
fieldValue,
};
};

View File

@ -0,0 +1,23 @@
import { useContext } from 'react';
import { FieldJsonValue } from '@/object-record/record-field/types/FieldMetadata';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
export const useJsonFieldDisplay = () => {
const { entityId, fieldDefinition, maxWidth } = useContext(FieldContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<FieldJsonValue | undefined>(
entityId,
fieldName,
);
return {
maxWidth,
fieldDefinition,
fieldValue,
};
};

View File

@ -0,0 +1,21 @@
import { useContext } from 'react';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
import { FieldLinkValue } from '../../types/FieldMetadata';
export const useLinkFieldDisplay = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<FieldLinkValue | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition,
fieldValue,
};
};

View File

@ -0,0 +1,22 @@
import { useContext } from 'react';
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
export const useLinksFieldDisplay = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<FieldLinksValue | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition,
fieldValue,
};
};

View File

@ -0,0 +1,26 @@
import { useContext } from 'react';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import {
FieldMultiSelectMetadata,
FieldMultiSelectValue,
} from '@/object-record/record-field/types/FieldMetadata';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
export const useMultiSelectFieldDisplay = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const { fieldName } = fieldDefinition.metadata;
const fieldValue = useRecordFieldValue<FieldMultiSelectValue | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition:
fieldDefinition as FieldDefinition<FieldMultiSelectMetadata>,
fieldValue,
};
};

View File

@ -0,0 +1,26 @@
import { useContext } from 'react';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldNumber } from '../../types/guards/isFieldNumber';
export const useNumberFieldDisplay = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata(FieldMetadataType.Number, isFieldNumber, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<number | null | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition,
fieldValue,
hotkeyScope,
};
};

View File

@ -9,7 +9,10 @@ export const usePhoneFieldDisplay = () => {
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue(entityId, fieldName);
const fieldValue = useRecordFieldValue<string | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition,

View File

@ -3,6 +3,7 @@ import { isNonEmptyString } from '@sniptt/guards';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FIELD_EDIT_BUTTON_WIDTH } from '@/ui/field/display/constants/FieldEditButtonWidth';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
@ -32,7 +33,10 @@ export const useRelationFieldDisplay = () => {
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue(entityId, fieldName);
const fieldValue = useRecordFieldValue<ObjectRecord | undefined>(
entityId,
fieldName,
);
const maxWidthForField =
isDefined(button) && isDefined(maxWidth)

View File

@ -0,0 +1,26 @@
import { useContext } from 'react';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
import {
FieldSelectMetadata,
FieldSelectValue,
} from '../../types/FieldMetadata';
export const useSelectFieldDisplay = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const { fieldName } = fieldDefinition.metadata;
const fieldValue = useRecordFieldValue<FieldSelectValue | undefined>(
entityId,
fieldName,
);
return {
fieldDefinition: fieldDefinition as FieldDefinition<FieldSelectMetadata>,
fieldValue,
};
};

View File

@ -0,0 +1,22 @@
import { useContext } from 'react';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
export const useTextFieldDisplay = () => {
const { entityId, fieldDefinition, hotkeyScope, maxWidth } =
useContext(FieldContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue =
useRecordFieldValue<string | undefined>(entityId, fieldName) ?? '';
return {
maxWidth,
fieldDefinition,
fieldValue,
hotkeyScope,
};
};

View File

@ -1,20 +1,26 @@
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { mockedCompaniesData } from '~/testing/mock-data/companies';
import { mockObjectMetadataItem } from '~/testing/mock-data/objectMetadataItems';
import { getCompaniesMock } from '~/testing/mock-data/companies';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
import { isRecordMatchingFilter } from './isRecordMatchingFilter';
const companiesMock = getCompaniesMock();
const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
describe('isRecordMatchingFilter', () => {
describe('Empty Filters', () => {
it('matches any record when no filter is provided', () => {
const emptyFilter = {};
mockedCompaniesData.forEach((company) => {
companiesMock.forEach((company) => {
expect(
isRecordMatchingFilter({
record: company,
filter: emptyFilter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
});
@ -26,12 +32,12 @@ describe('isRecordMatchingFilter', () => {
employees: {},
};
mockedCompaniesData.forEach((company) => {
companiesMock.forEach((company) => {
expect(
isRecordMatchingFilter({
record: company,
filter: filterWithEmptyFields,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
});
@ -40,12 +46,12 @@ describe('isRecordMatchingFilter', () => {
it('matches any record with an empty and filter', () => {
const filter = { and: [] };
mockedCompaniesData.forEach((company) => {
companiesMock.forEach((company) => {
expect(
isRecordMatchingFilter({
record: company,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
});
@ -54,12 +60,12 @@ describe('isRecordMatchingFilter', () => {
it('matches any record with an empty or filter', () => {
const filter = { or: [] };
mockedCompaniesData.forEach((company) => {
companiesMock.forEach((company) => {
expect(
isRecordMatchingFilter({
record: company,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
});
@ -68,12 +74,12 @@ describe('isRecordMatchingFilter', () => {
it('matches any record with an empty not filter', () => {
const filter = { not: {} };
mockedCompaniesData.forEach((company) => {
companiesMock.forEach((company) => {
expect(
isRecordMatchingFilter({
record: company,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
});
@ -82,92 +88,161 @@ describe('isRecordMatchingFilter', () => {
describe('Simple Filters', () => {
it('matches a record with a simple equality filter on name', () => {
const filter = { name: { eq: 'Airbnb' } };
const companyMockInFilter = {
...companiesMock[0],
};
const companyMockNotInFilter = {
...companiesMock[0],
name: companyMockInFilter.name + 'Different',
};
const filter = { name: { eq: companyMockInFilter.name } };
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0],
record: companyMockInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[1],
record: companyMockNotInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(false);
});
it('matches a record with a simple equality filter on domainName', () => {
const filter = { domainName: { eq: 'airbnb.com' } };
const companyMockInFilter = {
...companiesMock[0],
};
const companyMockNotInFilter = {
...companiesMock[0],
domainName: companyMockInFilter.domainName + 'Different',
};
const filter = { domainName: { eq: companyMockInFilter.domainName } };
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0],
record: companyMockInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[1],
record: companyMockNotInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(false);
});
it('matches a record with a greater than filter on employees', () => {
const filter = { employees: { gt: 10 } };
const companyMockInFilter = {
...companiesMock[0],
employees: 100,
};
const companyMockNotInFilter = {
...companiesMock[0],
employees: companyMockInFilter.employees - 50,
};
const filter = {
employees: { gt: companyMockInFilter.employees - 1 },
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0],
record: companyMockInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[1],
record: companyMockNotInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(false);
});
it('matches a record with a boolean filter on idealCustomerProfile', () => {
const filter = { idealCustomerProfile: { eq: true } };
const companyIdealCustomerProfileTrue = {
...companiesMock[0],
idealCustomerProfile: true,
};
const companyIdealCustomerProfileFalse = {
...companiesMock[0],
idealCustomerProfile: false,
};
const filter = {
idealCustomerProfile: {
eq: companyIdealCustomerProfileTrue.idealCustomerProfile,
},
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0],
record: companyIdealCustomerProfileTrue,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
).toBe(companyIdealCustomerProfileTrue.idealCustomerProfile);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[4], // Assuming this record has idealCustomerProfile as false
record: companyIdealCustomerProfileFalse,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(false);
).toBe(companyIdealCustomerProfileFalse.idealCustomerProfile);
});
});
describe('Complex And/Or/Not Nesting', () => {
it('matches record with a combination of and + or filters', () => {
const companyMockInFilter = {
...companiesMock[0],
idealCustomerProfile: true,
employees: 100,
};
const companyMockNotInFilter = {
...companiesMock[0],
idealCustomerProfile: false,
employees: 0,
};
const filter: RecordGqlOperationFilter = {
and: [
{ domainName: { eq: 'airbnb.com' } },
{
domainName: {
eq: companyMockInFilter.domainName,
},
},
{
or: [
{ employees: { gt: 10 } },
{ idealCustomerProfile: { eq: true } },
{
employees: {
gt: companyMockInFilter.employees - 1,
},
},
{
idealCustomerProfile: {
eq: companyMockInFilter.idealCustomerProfile,
},
},
],
},
],
@ -175,118 +250,181 @@ describe('isRecordMatchingFilter', () => {
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0], // Airbnb
record: companyMockInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[1], // Aircall
record: companyMockNotInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(false);
});
it('matches record with nested not filter', () => {
const companyMockInFilter = {
...companiesMock[0],
idealCustomerProfile: true,
employees: 100,
};
const companyMockNotInFilter = {
...companiesMock[0],
idealCustomerProfile: false,
name: companyMockInFilter.name + 'Different',
};
const filter: RecordGqlOperationFilter = {
not: {
and: [
{ name: { eq: 'Airbnb' } },
{ idealCustomerProfile: { eq: true } },
{ name: { eq: companyMockInFilter.name } },
{
idealCustomerProfile: {
eq: companyMockInFilter.idealCustomerProfile,
},
},
],
},
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0], // Airbnb
record: companyMockInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(false); // Should not match as it's Airbnb with idealCustomerProfile true
).toBe(false);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[3], // Apple
record: companyMockNotInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true); // Should match as it's not Airbnb
).toBe(true);
});
it('matches record with deep nesting of and, or, and not filters', () => {
const companyMockInFilter = {
...companiesMock[0],
idealCustomerProfile: true,
employees: 100,
};
const companyMockNotInFilter = {
...companiesMock[0],
domainName: companyMockInFilter.domainName + 'Different',
employees: 5,
name: companyMockInFilter.name + 'Different',
};
const filter: RecordGqlOperationFilter = {
and: [
{ domainName: { eq: 'apple.com' } },
{ domainName: { eq: companyMockInFilter.domainName } },
{
or: [{ employees: { eq: 10 } }, { not: { name: { eq: 'Apple' } } }],
or: [
{ employees: { eq: companyMockInFilter.employees } },
{ not: { name: { eq: companyMockInFilter.name } } },
],
},
],
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[3], // Apple
record: companyMockInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[4], // Qonto
record: companyMockNotInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(false);
});
it('matches record with and filter at root level', () => {
const companyMockInFilter = {
...companiesMock[0],
idealCustomerProfile: true,
};
const companyMockNotInFilter = {
...companiesMock[0],
idealCustomerProfile: false,
name: companyMockInFilter.name + 'Different',
};
const filter: RecordGqlOperationFilter = {
and: [
{ name: { eq: 'Facebook' } },
{ idealCustomerProfile: { eq: true } },
{ name: { eq: companyMockInFilter.name } },
{
idealCustomerProfile: {
eq: companyMockInFilter.idealCustomerProfile,
},
},
],
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[5], // Facebook
record: companyMockInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0], // Airbnb
record: companyMockNotInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(false);
});
it('matches record with or filter at root level including a not condition', () => {
const companyMockInFilter = {
...companiesMock[0],
idealCustomerProfile: true,
employees: 100,
};
const companyMockNotInFilter = {
...companiesMock[0],
idealCustomerProfile: false,
name: companyMockInFilter.name + 'Different',
employees: companyMockInFilter.employees - 1,
};
const filter: RecordGqlOperationFilter = {
or: [{ name: { eq: 'Sequoia' } }, { not: { employees: { eq: 1 } } }],
or: [
{ name: { eq: companyMockInFilter.name } },
{ not: { employees: { eq: companyMockInFilter.employees - 1 } } },
],
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[6], // Sequoia
record: companyMockInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[1], // Aircall
record: companyMockNotInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(false);
});
@ -294,49 +432,75 @@ describe('isRecordMatchingFilter', () => {
describe('Implicit And Conditions', () => {
it('matches record with implicit and of multiple operators within the same field', () => {
const companyMockInFilter = {
...companiesMock[0],
idealCustomerProfile: true,
employees: 100,
};
const companyMockNotInFilter = {
...companiesMock[0],
idealCustomerProfile: false,
name: companyMockInFilter.name + 'Different',
employees: companyMockInFilter.employees + 100,
};
const filter = {
employees: { gt: 10, lt: 100000 },
name: { eq: 'Airbnb' },
employees: {
gt: companyMockInFilter.employees - 10,
lt: companyMockInFilter.employees + 10,
},
name: { eq: companyMockInFilter.name },
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0], // Airbnb
record: companyMockInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true); // Matches as Airbnb's employee count is between 10 and 100000
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[1], // Aircall
record: companyMockNotInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(false); // Does not match as Aircall's employee count is not within the range
});
it('matches record with implicit and within an object passed to or', () => {
const companyMockInFilter = {
...companiesMock[0],
};
const companyMockNotInFilter = {
...companiesMock[0],
name: companyMockInFilter.name + 'Different',
domainName: companyMockInFilter.name + 'Different',
};
const filter = {
or: {
name: { eq: 'Airbnb' },
domainName: { eq: 'airbnb.com' },
name: { eq: companyMockInFilter.name },
domainName: { eq: companyMockInFilter.domainName },
},
};
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[0], // Airbnb
record: companyMockInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(true);
expect(
isRecordMatchingFilter({
record: mockedCompaniesData[2], // Algolia
record: companyMockNotInFilter,
filter,
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItem: companyMockObjectMetadataItem,
}),
).toBe(false);
});

View File

@ -6,10 +6,12 @@ import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorato
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedCompaniesData } from '~/testing/mock-data/companies';
import { getCompaniesMock } from '~/testing/mock-data/companies';
import { RecordDetailDuplicatesSection } from '../RecordDetailDuplicatesSection';
const companiesMock = getCompaniesMock();
const meta: Meta<typeof RecordDetailDuplicatesSection> = {
title:
'Modules/ObjectRecord/RecordShow/RecordDetailSection/RecordDetailDuplicatesSection',
@ -21,7 +23,7 @@ const meta: Meta<typeof RecordDetailDuplicatesSection> = {
MemoryRouterDecorator,
],
args: {
objectRecordId: mockedCompaniesData[0].id,
objectRecordId: companiesMock[0].id,
objectNameSingular: CoreObjectNameSingular.Company,
},
parameters: {

View File

@ -8,12 +8,16 @@ import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadat
import { RecordStoreDecorator } from '~/testing/decorators/RecordStoreDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedCompaniesData } from '~/testing/mock-data/companies';
import { getCompaniesMock } from '~/testing/mock-data/companies';
import { mockedCompanyObjectMetadataItem } from '~/testing/mock-data/metadata';
import { mockedPeopleData } from '~/testing/mock-data/people';
import { getPeopleMock } from '~/testing/mock-data/people';
import { RecordDetailRelationSection } from '../RecordDetailRelationSection';
const companiesMock = getCompaniesMock();
const peopleMock = getPeopleMock();
const meta: Meta<typeof RecordDetailRelationSection> = {
title:
'Modules/ObjectRecord/RecordShow/RecordDetailSection/RecordDetailRelationSection',
@ -22,7 +26,7 @@ const meta: Meta<typeof RecordDetailRelationSection> = {
(Story) => (
<FieldContext.Provider
value={{
entityId: mockedCompaniesData[0].id,
entityId: companiesMock[0].id,
basePathToShowPage: '/object-record/',
isLabelIdentifier: false,
fieldDefinition: formatFieldMetadataItemAsFieldDefinition({
@ -44,7 +48,7 @@ const meta: Meta<typeof RecordDetailRelationSection> = {
],
parameters: {
msw: graphqlMocks,
records: mockedCompaniesData,
records: companiesMock,
},
};
@ -58,10 +62,10 @@ export const WithRecords: Story = {
parameters: {
records: [
{
...mockedCompaniesData[0],
people: mockedPeopleData,
...companiesMock[0],
people: peopleMock,
},
...mockedPeopleData,
...peopleMock,
],
},
};

View File

@ -36,13 +36,16 @@ export const useRecordValue = (recordId: string) => {
return tableValue?.[recordId] as ObjectRecord | undefined;
};
export const useRecordFieldValue = (recordId: string, fieldName: string) => {
export const useRecordFieldValue = <T,>(
recordId: string,
fieldName: string,
) => {
const recordFieldValues = useContextSelector(
RecordFieldValueSelectorContext,
(value) => value[0],
);
return recordFieldValues?.[recordId]?.[fieldName];
return recordFieldValues?.[recordId]?.[fieldName] as T;
};
export const useSetRecordFieldValue = () => {

View File

@ -8,16 +8,18 @@ import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadat
import { RelationPickerDecorator } from '~/testing/decorators/RelationPickerDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedPeopleData } from '~/testing/mock-data/people';
import { getPeopleMock } from '~/testing/mock-data/people';
import { sleep } from '~/testing/sleep';
import { EntityForSelect } from '../../types/EntityForSelect';
import { SingleEntitySelect } from '../SingleEntitySelect';
const entities = mockedPeopleData.map<EntityForSelect>((person) => ({
const peopleMock = getPeopleMock();
const entities = peopleMock.map<EntityForSelect>((person) => ({
id: person.id,
name: person.name.firstName + ' ' + person.name.lastName,
avatarUrl: person.avatarUrl,
avatarUrl: 'https://picsum.photos/200',
avatarType: 'rounded',
record: { ...person, __typename: 'Person' },
}));