Added Linaria for performance optimization (#5693)

- Added Linaria to have compiled CSS on our optimized field displays
- Refactored mocks for performance stories on fields
- Refactored generateRecordChipData into a global context, computed only
when we fetch object metadata items.
- Refactored ChipFieldDisplay 
- Refactored PhoneFieldDisplay
This commit is contained in:
Lucas Bordeau
2024-06-12 16:31:07 +02:00
committed by GitHub
parent 30d3ebc68a
commit 732e8912da
43 changed files with 2166 additions and 519 deletions

View File

@ -2,15 +2,20 @@ export type Company = {
__typename: 'Company';
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
updatedAt?: string;
deletedAt?: string | null;
name: string;
domainName: string;
address: string;
accountOwnerId: string | null;
linkedinLink: { url: string; label: string };
xLink: { url: string; label: string };
annualRecurringRevenue: { amountMicros: number | null; currencyCode: string };
accountOwnerId?: string | null;
position?: number;
linkedinLink: { __typename?: 'Link'; url: string; label: string };
xLink?: { __typename?: 'Link'; url: string; label: string };
annualRecurringRevenue: {
__typename?: 'Currency';
amountMicros: number | null;
currencyCode: string;
};
employees: number | null;
idealCustomerProfile: boolean;
idealCustomerProfile?: boolean;
};

View File

@ -1,9 +1,11 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope';
import { getRecordChipGeneratorPerObjectPerField } from '@/object-record/utils/getRecordChipGeneratorPerObjectPerField';
import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader';
export const ObjectMetadataItemsProvider = ({
@ -13,13 +15,23 @@ export const ObjectMetadataItemsProvider = ({
const shouldDisplayChildren = objectMetadataItems.length > 0;
const chipGeneratorPerObjectPerField = useMemo(() => {
return getRecordChipGeneratorPerObjectPerField(objectMetadataItems);
}, [objectMetadataItems]);
return (
<>
<ObjectMetadataItemsLoadEffect />
{shouldDisplayChildren ? (
<RelationPickerScope relationPickerScopeId="relation-picker">
{children}
</RelationPickerScope>
<PreComputedChipGeneratorsContext.Provider
value={{
chipGeneratorPerObjectPerField,
}}
>
<RelationPickerScope relationPickerScopeId="relation-picker">
{children}
</RelationPickerScope>
</PreComputedChipGeneratorsContext.Provider>
) : (
<UserOrMetadataLoader />
)}

View File

@ -0,0 +1,18 @@
import { createContext } from 'react';
import { RecordChipData } from '@/object-record/record-field/types/RecordChipData';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export type ChipGeneratorPerObjectPerField = Record<
string,
Record<string, (record: ObjectRecord) => RecordChipData>
>;
export type PreComputedChipGeneratorsContextProps = {
chipGeneratorPerObjectPerField: ChipGeneratorPerObjectPerField;
};
export const PreComputedChipGeneratorsContext =
createContext<PreComputedChipGeneratorsContextProps>(
{} as PreComputedChipGeneratorsContextProps,
);

View File

@ -5,6 +5,7 @@ import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/displ
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldChipDisplay } from '@/object-record/utils/getRecordChipGeneratorPerObjectPerField';
import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
@ -42,11 +43,7 @@ import { isFieldUuid } from '../types/guards/isFieldUuid';
export const FieldDisplay = () => {
const { fieldDefinition, isLabelIdentifier } = useContext(FieldContext);
const isChipDisplay =
isLabelIdentifier &&
(isFieldText(fieldDefinition) ||
isFieldFullName(fieldDefinition) ||
isFieldNumber(fieldDefinition));
const isChipDisplay = isFieldChipDisplay(fieldDefinition, isLabelIdentifier);
return isChipDisplay ? (
<ChipFieldDisplay />

View File

@ -1,12 +1,24 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import { useChipField } from '@/object-record/record-field/meta-types/hooks/useChipField';
import { EntityChip } from 'twenty-ui';
import { useChipFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useChipFieldDisplay';
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
export const ChipFieldDisplay = () => {
const { objectNameSingular, record } = useChipField();
const { recordValue, generateRecordChipData } = useChipFieldDisplay();
if (!record) return null;
if (!recordValue) {
return null;
}
const recordChipData = generateRecordChipData(recordValue);
return (
<RecordChip objectNameSingular={objectNameSingular || ''} record={record} />
<EntityChip
entityId={recordValue.id}
name={recordChipData.name as any}
avatarType={recordChipData.avatarType}
avatarUrl={getImageAbsoluteURIOrBase64(recordChipData.avatarUrl) ?? ''}
linkToEntity={recordChipData.linkToShowPage}
/>
);
};

View File

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

View File

@ -23,8 +23,8 @@ export const RelationFromManyFieldDisplay = ({
{recordChipsData.map((record) => {
return (
<EntityChip
key={record.id}
entityId={record.id}
key={record.recordId}
entityId={record.recordId}
name={record.name as any}
avatarType={record.avatarType}
avatarUrl={getImageAbsoluteURIOrBase64(record.avatarUrl) || ''}

View File

@ -1,67 +0,0 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { ChipFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ChipFieldDisplay';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { FieldMetadataType } from '~/generated/graphql';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
const ChipFieldValueSetterEffect = () => {
const setEntityFields = useSetRecoilState(recordStoreFamilyState('123'));
useEffect(() => {
setEntityFields({
id: 'henry',
name: {
firstName: 'Henry',
lastName: 'Cavill',
},
__typename: 'Person',
});
}, [setEntityFields]);
return null;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/ChipFieldDisplay',
decorators: [
MemoryRouterDecorator,
(Story) => (
<FieldContext.Provider
value={{
entityId: '123',
basePathToShowPage: '/object-record/',
isLabelIdentifier: false,
fieldDefinition: {
fieldMetadataId: 'full name',
label: 'Henry Cavill',
type: FieldMetadataType.FullName,
iconName: 'IconCalendarEvent',
metadata: {
fieldName: 'full name',
objectMetadataNameSingular: 'person',
},
},
hotkeyScope: 'hotkey-scope',
}}
>
<ChipFieldValueSetterEffect />
<Story />
</FieldContext.Provider>
),
ComponentDecorator,
],
component: ChipFieldDisplay,
argTypes: { value: { control: 'date' } },
args: {},
};
export default meta;
type Story = StoryObj<typeof ChipFieldDisplay>;
export const Default: Story = {};

View File

@ -1,68 +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 { usePhoneField } from '@/object-record/record-field/meta-types/hooks/usePhoneField';
import { FieldMetadataType } from '~/generated/graphql';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { PhoneFieldDisplay } from '../PhoneFieldDisplay';
const PhoneFieldValueSetterEffect = ({ value }: { value: string }) => {
const { setFieldValue } = usePhoneField();
useEffect(() => {
setFieldValue(value);
}, [setFieldValue, value]);
return null;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/PhoneFieldDisplay',
decorators: [
MemoryRouterDecorator,
(Story, { args }) => (
<FieldContext.Provider
value={{
entityId: '',
isLabelIdentifier: false,
fieldDefinition: {
fieldMetadataId: 'phone',
label: 'Phone',
type: FieldMetadataType.Phone,
iconName: 'IconPhone',
metadata: {
fieldName: 'phone',
placeHolder: 'Phone',
objectMetadataNameSingular: 'person',
},
},
hotkeyScope: 'hotkey-scope',
useUpdateRecord: () => [() => undefined, {}],
}}
>
<PhoneFieldValueSetterEffect value={args.value} />
<Story />
</FieldContext.Provider>
),
ComponentDecorator,
],
component: PhoneFieldDisplay,
args: {
value: '362763872687362',
},
};
export default meta;
type Story = StoryObj<typeof PhoneFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};

View File

@ -0,0 +1,36 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { ChipFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ChipFieldDisplay';
import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator';
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/ChipFieldDisplay',
decorators: [
MemoryRouterDecorator,
ChipGeneratorsDecorator,
getFieldDecorator('person', 'name'),
ComponentDecorator,
],
component: ChipFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof ChipFieldDisplay>;
export const Default: Story = {};
export const Performance = getProfilingStory({
componentName: 'ChipFieldDisplay',
averageThresholdInMs: 0.2,
numberOfRuns: 20,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,47 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { PhoneFieldDisplay } from '@/object-record/record-field/meta-types/display/components/PhoneFieldDisplay';
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/PhoneFieldDisplay',
decorators: [
MemoryRouterDecorator,
getFieldDecorator('person', 'phone'),
ComponentDecorator,
],
component: PhoneFieldDisplay,
args: {},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof PhoneFieldDisplay>;
export const Default: Story = {};
export const Elipsis: Story = {
parameters: {
container: { width: 50 },
},
};
export const WrongNumber: Story = {
parameters: {
container: { width: 50 },
},
decorators: [getFieldDecorator('person', 'phone', 'sdklaskdj')],
};
export const Performance = getProfilingStory({
componentName: 'PhoneFieldDisplay',
averageThresholdInMs: 0.5,
numberOfRuns: 20,
numberOfTestsPerRun: 100,
});

View File

@ -1,74 +1,21 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { RelationFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFieldDisplay';
import {
RecordFieldValueSelectorContextProvider,
useSetRecordValue,
} from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
import { relationFieldDisplayMock } from './mock';
const RelationFieldValueSetterEffect = () => {
const setEntity = useSetRecoilState(
recordStoreFamilyState(relationFieldDisplayMock.entityId),
);
const setRelationEntity = useSetRecoilState(
recordStoreFamilyState(relationFieldDisplayMock.relationEntityId),
);
const setRecordValue = useSetRecordValue();
useEffect(() => {
setEntity(relationFieldDisplayMock.entityValue);
setRelationEntity(relationFieldDisplayMock.relationFieldValue);
setRecordValue(
relationFieldDisplayMock.entityValue.id,
relationFieldDisplayMock.entityValue,
);
setRecordValue(
relationFieldDisplayMock.relationFieldValue.id,
relationFieldDisplayMock.relationFieldValue,
);
}, [setEntity, setRelationEntity, setRecordValue]);
return null;
};
const meta: Meta = {
title: 'UI/Data/Field/Display/RelationFieldDisplay',
decorators: [
MemoryRouterDecorator,
(Story) => (
<RecordFieldValueSelectorContextProvider>
<FieldContext.Provider
value={{
entityId: relationFieldDisplayMock.entityId,
basePathToShowPage: '/object-record/',
isLabelIdentifier: false,
fieldDefinition: {
...relationFieldDisplayMock.fieldDefinition,
},
hotkeyScope: 'hotkey-scope',
}}
>
<RelationFieldValueSetterEffect />
<Story />
</FieldContext.Provider>
</RecordFieldValueSelectorContextProvider>
),
ChipGeneratorsDecorator,
getFieldDecorator('person', 'company'),
ComponentDecorator,
],
component: RelationFieldDisplay,
argTypes: { value: { control: 'date' } },
args: {},
parameters: {
chromatic: { disableSnapshot: true },
@ -83,7 +30,7 @@ export const Default: Story = {};
export const Performance = getProfilingStory({
componentName: 'RelationFieldDisplay',
averageThresholdInMs: 0.4,
averageThresholdInMs: 0.2,
numberOfRuns: 20,
numberOfTestsPerRun: 100,
});

View File

@ -12,6 +12,7 @@ import {
useSetRecordValue,
} from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
@ -52,6 +53,7 @@ const meta: Meta = {
title: 'UI/Data/Field/Display/RelationFromManyFieldDisplay',
decorators: [
MemoryRouterDecorator,
ChipGeneratorsDecorator,
(Story) => (
<RecordFieldValueSelectorContextProvider>
<FieldContext.Provider

View File

@ -1,113 +0,0 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const relationFieldDisplayMock = {
entityId: '20202020-2d40-4e49-8df4-9c6a049191df',
relationEntityId: '20202020-c21e-4ec2-873b-de4264d89025',
entityValue: {
__typename: 'Person',
asd: '',
city: 'Seattle',
jobTitle: '',
name: {
__typename: 'FullName',
firstName: 'Lorie',
lastName: 'Vladim',
},
createdAt: '2024-05-01T13:16:29.046Z',
company: {
__typename: 'Company',
domainName: 'google.com',
xLink: {
__typename: 'Link',
label: '',
url: '',
},
name: 'Google',
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
currencyCode: '',
},
employees: null,
accountOwnerId: null,
address: '',
idealCustomerProfile: false,
createdAt: '2024-05-01T13:16:29.046Z',
id: '20202020-c21e-4ec2-873b-de4264d89025',
position: 6,
updatedAt: '2024-05-01T13:16:29.046Z',
linkedinLink: {
__typename: 'Link',
label: '',
url: '',
},
},
id: '20202020-2d40-4e49-8df4-9c6a049191df',
email: 'lorie.vladim@google.com',
phone: '+33788901235',
linkedinLink: {
__typename: 'Link',
label: '',
url: '',
},
xLink: {
__typename: 'Link',
label: '',
url: '',
},
tEst: '',
position: 15,
},
relationFieldValue: {
__typename: 'Company',
domainName: 'microsoft.com',
xLink: {
__typename: 'Link',
label: '',
url: '',
},
name: 'Microsoft',
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
currencyCode: '',
},
employees: null,
accountOwnerId: null,
address: '',
idealCustomerProfile: false,
createdAt: '2024-05-01T13:16:29.046Z',
id: '20202020-ed89-413a-b31a-962986e67bb4',
position: 4,
updatedAt: '2024-05-01T13:16:29.046Z',
linkedinLink: {
__typename: 'Link',
label: '',
url: '',
},
},
fieldDefinition: {
fieldMetadataId: '4e79f0b7-d100-4e89-a07b-315a710b8059',
label: 'Company',
metadata: {
fieldName: 'company',
placeHolder: 'Company',
relationType: 'TO_ONE_OBJECT',
relationFieldMetadataId: '01fa2247-7937-4493-b7e2-3d72f05d6d25',
relationObjectMetadataNameSingular: 'company',
relationObjectMetadataNamePlural: 'companies',
objectMetadataNameSingular: 'person',
options: null,
},
iconName: 'IconBuildingSkyscraper',
type: FieldMetadataType.Relation,
position: 2,
size: 150,
isLabelIdentifier: false,
isVisible: true,
viewFieldId: '924f4c94-cbcd-4de5-b7a2-ebae2f0b2c3b',
isSortable: false,
isFilterable: true,
defaultValue: null,
},
};

View File

@ -0,0 +1,47 @@
import { useContext } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { useRecordValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { isDefined } from '~/utils/isDefined';
import { FieldContext } from '../../contexts/FieldContext';
export const useChipFieldDisplay = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const { chipGeneratorPerObjectPerField } = useContext(
PreComputedChipGeneratorsContext,
);
if (!isDefined(chipGeneratorPerObjectPerField)) {
throw new Error('Chip generator per object per field is not defined');
}
const objectNameSingular =
isFieldText(fieldDefinition) ||
isFieldFullName(fieldDefinition) ||
isFieldNumber(fieldDefinition)
? fieldDefinition.metadata.objectMetadataNameSingular
: undefined;
const recordValue = useRecordValue(entityId);
if (!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)) {
throw new Error('Object metadata name singular is not a non-empty string');
}
const generateRecordChipData =
chipGeneratorPerObjectPerField[
fieldDefinition.metadata.objectMetadataNameSingular
][fieldDefinition.metadata.fieldName];
return {
objectNameSingular,
recordValue,
generateRecordChipData,
};
};

View File

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

View File

@ -1,9 +1,8 @@
import { useContext } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
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';
@ -15,6 +14,14 @@ import { isFieldRelation } from '../../types/guards/isFieldRelation';
export const useRelationFieldDisplay = () => {
const { entityId, fieldDefinition, maxWidth } = useContext(FieldContext);
const { chipGeneratorPerObjectPerField } = useContext(
PreComputedChipGeneratorsContext,
);
if (!isDefined(chipGeneratorPerObjectPerField)) {
throw new Error('Chip generator per object per field is not defined');
}
assertFieldMetadata(
FieldMetadataType.Relation,
isFieldRelation,
@ -32,18 +39,14 @@ export const useRelationFieldDisplay = () => {
? maxWidth - FIELD_EDIT_BUTTON_WIDTH
: maxWidth;
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
});
if (!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)) {
throw new Error('Object metadata name singular is not a non-empty string');
}
const generateRecordChipData = (record: ObjectRecord) => {
return getObjectRecordIdentifier({
objectMetadataItem: relationObjectMetadataItem,
record,
});
};
const generateRecordChipData =
chipGeneratorPerObjectPerField[
fieldDefinition.metadata.objectMetadataNameSingular
][fieldDefinition.metadata.fieldName];
return {
fieldDefinition,

View File

@ -1,6 +1,7 @@
import { AvatarType } from 'twenty-ui';
export type RecordChipData = {
recordId: string;
name: string | number;
avatarType: AvatarType;
avatarUrl: string;

View File

@ -1,6 +1,8 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export type RecordFieldValue = {
[recordId: string]: {
[fieldName: string]: any;
@ -31,7 +33,7 @@ export const useRecordValue = (recordId: string) => {
(value) => value[0],
);
return tableValue?.[recordId];
return tableValue?.[recordId] as ObjectRecord | undefined;
};
export const useRecordFieldValue = (recordId: string, fieldName: string) => {

View File

@ -17,6 +17,7 @@ import { RecordTableCellContext } from '@/object-record/record-table/contexts/Re
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { RecordTableScope } from '@/object-record/record-table/scopes/RecordTableScope';
import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
@ -57,6 +58,7 @@ const meta: Meta = {
title: 'RecordIndex/Table/RecordTableCell',
decorators: [
MemoryRouterDecorator,
ChipGeneratorsDecorator,
(Story) => {
return (
<RecordFieldValueSelectorContextProvider>
@ -143,7 +145,7 @@ export const Default: Story = {};
export const Performance = getProfilingStory({
componentName: 'RecordTableCell',
averageThresholdInMs: 0.6,
averageThresholdInMs: 0.3,
numberOfRuns: 50,
numberOfTestsPerRun: 200,
warmUpRounds: 20,

View File

@ -1,86 +0,0 @@
import { useMemo } from 'react';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { getAvatarUrl } from '@/object-metadata/utils/getAvatarUrl';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldValue } from '@/object-metadata/utils/getLabelIdentifierFieldValue';
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { RecordChipData } from '@/object-record/record-field/types/RecordChipData';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
export const useRecordChipDataGenerator = ({
objectNameSingular,
visibleTableColumns,
}: {
objectNameSingular: string;
visibleTableColumns: ColumnDefinition<FieldMetadata>[];
}) => {
const { objectMetadataItems } = useObjectMetadataItems();
return useMemo(() => {
return Object.fromEntries<(record: ObjectRecord) => RecordChipData>(
visibleTableColumns
.filter(
(tableColumn) =>
tableColumn.isLabelIdentifier ||
tableColumn.type === FieldMetadataType.Relation,
)
.map((tableColumn) => {
const objectNameSingularToFind = tableColumn.isLabelIdentifier
? objectNameSingular
: isFieldRelation(tableColumn)
? tableColumn.metadata.relationObjectMetadataNameSingular
: undefined;
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingularToFind,
);
if (
!isDefined(objectMetadataItem) ||
!isDefined(objectNameSingularToFind)
) {
return ['', () => ({}) as any];
}
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
const imageIdentifierFieldMetadata = objectMetadataItem.fields.find(
(field) =>
field.id === objectMetadataItem.imageIdentifierFieldMetadataId,
);
const avatarType = getAvatarType(objectNameSingularToFind);
return [
tableColumn.metadata.fieldName,
(record: ObjectRecord) => ({
name: getLabelIdentifierFieldValue(
record,
labelIdentifierFieldMetadataItem,
objectMetadataItem.nameSingular,
),
avatarUrl: getAvatarUrl(
objectMetadataItem.nameSingular,
record,
imageIdentifierFieldMetadata,
),
avatarType,
linkToShowPage: getLinkToShowPage(
objectMetadataItem.nameSingular,
record,
),
}),
];
}),
);
}, [objectNameSingular, visibleTableColumns, objectMetadataItems]);
};

View File

@ -0,0 +1,116 @@
import { ChipGeneratorPerObjectPerField } from '@/object-metadata/context/PreComputedChipGeneratorsContext';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { getAvatarUrl } from '@/object-metadata/utils/getAvatarUrl';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldValue } from '@/object-metadata/utils/getLabelIdentifierFieldValue';
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { RecordChipData } from '@/object-record/record-field/types/RecordChipData';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
export const isFieldChipDisplay = (
field: Pick<FieldMetadataItem, 'type'>,
isLabelIdentifier: boolean,
) =>
isLabelIdentifier &&
(isFieldText(field) || isFieldFullName(field) || isFieldNumber(field));
export const getRecordChipGeneratorPerObjectPerField = (
objectMetadataItems: ObjectMetadataItem[],
) => {
const recordChipGeneratorPerObjectPerField: ChipGeneratorPerObjectPerField =
{};
for (const objectMetadataItem of objectMetadataItems) {
const generatorPerField = Object.fromEntries<
(record: ObjectRecord) => RecordChipData
>(
objectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
isLabelIdentifierField({
fieldMetadataItem: fieldMetadataItem,
objectMetadataItem,
}) ||
fieldMetadataItem.type === FieldMetadataType.Relation ||
isFieldChipDisplay(
fieldMetadataItem,
isLabelIdentifierField({
fieldMetadataItem: fieldMetadataItem,
objectMetadataItem,
}),
),
)
.map((fieldMetadataItem) => {
const objectNameSingularToFind = isLabelIdentifierField({
fieldMetadataItem: fieldMetadataItem,
objectMetadataItem: objectMetadataItem,
})
? objectMetadataItem.nameSingular
: isFieldRelation(fieldMetadataItem)
? fieldMetadataItem.relationDefinition?.targetObjectMetadata
.nameSingular ?? undefined
: undefined;
const objectMetadataItemToUse = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingularToFind,
);
if (
!isDefined(objectMetadataItemToUse) ||
!isDefined(objectNameSingularToFind)
) {
return ['', () => ({}) as any];
}
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItemToUse);
const imageIdentifierFieldMetadata =
objectMetadataItemToUse.fields.find(
(field) =>
field.id ===
objectMetadataItemToUse.imageIdentifierFieldMetadataId,
);
const avatarType = getAvatarType(objectNameSingularToFind);
return [
fieldMetadataItem.name,
(record: ObjectRecord) => ({
recordId: record.id,
name: getLabelIdentifierFieldValue(
record,
labelIdentifierFieldMetadataItem,
objectMetadataItemToUse.nameSingular,
),
avatarUrl: getAvatarUrl(
objectMetadataItemToUse.nameSingular,
record,
imageIdentifierFieldMetadata,
),
avatarType,
linkToShowPage: getLinkToShowPage(
objectMetadataItemToUse.nameSingular,
record,
),
}),
];
}),
);
recordChipGeneratorPerObjectPerField[objectMetadataItem.nameSingular] =
generatorPerField;
}
return recordChipGeneratorPerObjectPerField;
};

View File

@ -2,24 +2,39 @@ export type Person = {
__typename: 'Person';
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
updatedAt?: string;
deletedAt?: string | null;
name: {
__typename?: 'FullName';
firstName: string;
lastName: string;
};
avatarUrl: string;
avatarUrl?: string;
jobTitle: string;
linkedinLink: {
__typename?: 'Link';
url: string;
label: string;
};
xLink: {
__typename?: 'Link';
url: string;
label: string;
};
city: string;
email: string;
phone: string;
companyId: string;
companyId?: string;
position?: number;
links?: {
__typename: 'Links';
primaryLinkUrl: string;
primaryLinkLabel: '';
secondaryLinks?:
| {
url: string;
label: string;
}[]
| null;
};
};

View File

@ -1,4 +1,4 @@
import styled from '@emotion/styled';
import { styled } from '@linaria/react';
const StyledEllipsisDisplay = styled.div<{ maxWidth?: number }>`
max-width: ${({ maxWidth }) => maxWidth ?? '100%'};
@ -19,7 +19,7 @@ export const EllipsisDisplay = ({
maxWidth,
className,
}: EllipsisDisplayProps) => (
<StyledEllipsisDisplay style={{ maxWidth }} className={className}>
<StyledEllipsisDisplay maxWidth={maxWidth} className={className}>
{children}
</StyledEllipsisDisplay>
);

View File

@ -1,27 +1,39 @@
import { MouseEvent } from 'react';
import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js';
import { parsePhoneNumber, PhoneNumber } from 'libphonenumber-js';
import { ContactLink } from '@/ui/navigation/link/components/ContactLink';
import { EllipsisDisplay } from './EllipsisDisplay';
import { isDefined } from '~/utils/isDefined';
type PhoneDisplayProps = {
value: string | null;
};
export const PhoneDisplay = ({ value }: PhoneDisplayProps) => (
<EllipsisDisplay>
{value && isValidPhoneNumber(value) ? (
<ContactLink
href={parsePhoneNumber(value, 'FR')?.getURI()}
onClick={(event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
}}
>
{parsePhoneNumber(value, 'FR')?.formatNational() || value}
</ContactLink>
) : (
<ContactLink href="#">{value}</ContactLink>
)}
</EllipsisDisplay>
);
// TODO: see if we can find a faster way to format the phone number
export const PhoneDisplay = ({ value }: PhoneDisplayProps) => {
if (!isDefined(value)) {
return <ContactLink href="#">{value}</ContactLink>;
}
let parsedPhoneNumber: PhoneNumber | null = null;
try {
// TODO: parse according to locale not hard coded FR
parsedPhoneNumber = parsePhoneNumber(value, 'FR');
} catch (error) {
return <ContactLink href="#">{value}</ContactLink>;
}
const URI = parsedPhoneNumber.getURI();
const formattedNational = parsedPhoneNumber?.formatNational();
return (
<ContactLink
href={URI}
onClick={(event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
}}
>
{formattedNational || value}
</ContactLink>
);
};

View File

@ -1,20 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
const meta: Meta = {
title: 'UI/Input/EllipsisDisplay/EllipsisDisplay',
component: EllipsisDisplay,
decorators: [ComponentDecorator],
args: {
maxWidth: 100,
children: 'This is a long text that should be truncated',
},
};
export default meta;
type Story = StoryObj<typeof EllipsisDisplay>;
export const Default: Story = {};

View File

@ -1,4 +1,4 @@
import { Meta } from '@storybook/react';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
@ -19,6 +19,10 @@ const meta: Meta = {
export default meta;
type Story = StoryObj<typeof EllipsisDisplay>;
export const Default: Story = {};
export const Performance = getProfilingStory({
componentName: 'EllipsisDisplay',
averageThresholdInMs: 0.1,

View File

@ -1,43 +1,47 @@
import * as React from 'react';
import { Link as ReactLink } from 'react-router-dom';
import styled from '@emotion/styled';
import { Theme, withTheme } from '@emotion/react';
import { styled } from '@linaria/react';
const StyledClickableLink = withTheme(styled.a<{
theme: Theme;
maxWidth?: number;
}>`
color: inherit;
overflow: hidden;
text-decoration: underline;
text-decoration-color: ${({ theme }) => theme.border.color.strong};
text-overflow: ellipsis;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
max-width: ${({ maxWidth }) => maxWidth ?? '100%'};
&:hover {
text-decoration-color: ${({ theme }) => theme.font.color.primary};
}
`);
type ContactLinkProps = {
className?: string;
href: string;
children?: React.ReactNode;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
maxWidth?: number;
};
const StyledClickable = styled.div`
display: flex;
overflow: hidden;
white-space: nowrap;
a {
color: inherit;
overflow: hidden;
text-decoration: underline;
text-decoration-color: ${({ theme }) => theme.border.color.strong};
text-overflow: ellipsis;
&:hover {
text-decoration-color: ${({ theme }) => theme.font.color.primary};
}
}
`;
export const ContactLink = ({
className,
href,
children,
onClick,
maxWidth,
}: ContactLinkProps) => (
<div>
<StyledClickable className={className}>
<ReactLink target="_blank" onClick={onClick} to={href}>
{children}
</ReactLink>
</StyledClickable>
</div>
<StyledClickableLink
maxWidth={maxWidth}
target="_blank"
onClick={onClick}
href={href}
>
{children}
</StyledClickableLink>
);

View File

@ -10,7 +10,6 @@ const meta: Meta<typeof ContactLink> = {
component: ContactLink,
decorators: [ComponentWithRouterDecorator],
args: {
className: 'ContactLink',
href: '/test',
children: 'Contact Link',
},

View File

@ -4,6 +4,7 @@ export type WorkspaceMember = {
__typename: 'WorkspaceMember';
id: string;
name: {
__typename?: 'FullName';
firstName: string;
lastName: string;
};