Fixed sync between record value context selector and record store (#5517)

This PR introduces many improvements over the new profiling story
feature, with new tests and some refactor with main :
- Added use-context-selector for getting value faster in display fields
and created useRecordFieldValue() hook and RecordValueSetterEffect to
synchronize states
- Added performance test command in CI
- Refactored ExpandableList drill-downs with FieldFocusContext
- Refactored field button icon logic into getFieldButtonIcon util
- Added RelationFieldDisplay perf story
- Added RecordTableCell perf story
- First split test of useField.. hook with useRelationFieldDisplay()
- Fixed problem with set cell soft focus
- Isolated logic between display / soft focus and edit mode in the
related components to optimize performances for display mode.
- Added warmupRound config for performance story decorator
- Added variance in test reporting
This commit is contained in:
Lucas Bordeau 2024-05-24 16:52:05 +02:00 committed by GitHub
parent 82ec30c957
commit de9321dcd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 2043 additions and 554 deletions

View File

@ -181,6 +181,7 @@
"tsup": "^8.0.1",
"type-fest": "4.10.1",
"typeorm": "^0.3.17",
"use-context-selector": "^2.0.0",
"use-debounce": "^10.0.0",
"uuid": "^9.0.0",
"vite-tsconfig-paths": "^4.2.1",

View File

@ -53,12 +53,12 @@ export const ActivityTargetsInlineCell = ({
/>
}
label="Relations"
displayModeContent={() => (
displayModeContent={
<ActivityTargetChips
activityTargetObjectRecords={activityTargetObjectRecords}
maxWidth={maxWidth}
/>
)}
}
isDisplayModeContentEmpty={activityTargetObjectRecords.length === 0}
/>
</RecordFieldInputScope>

View File

@ -1,5 +1,8 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon';
import { FieldMetadataItem } from '../types/FieldMetadataItem';
@ -15,7 +18,7 @@ export const formatFieldMetadataItemAsFieldDefinition = ({
objectMetadataItem,
showLabel,
labelWidth,
}: FieldMetadataItemAsFieldDefinitionProps) => {
}: FieldMetadataItemAsFieldDefinitionProps): FieldDefinition<FieldMetadata> => {
const relationObjectMetadataItem =
field.toRelationMetadata?.fromObjectMetadata ||
field.fromRelationMetadata?.toObjectMetadata;
@ -24,25 +27,31 @@ export const formatFieldMetadataItemAsFieldDefinition = ({
field.toRelationMetadata?.fromFieldMetadataId ||
field.fromRelationMetadata?.toFieldMetadataId;
const fieldDefintionMetadata = {
fieldName: field.name,
placeHolder: field.label,
relationType: parseFieldRelationType(field),
relationFieldMetadataId,
relationObjectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular ?? '',
relationObjectMetadataNamePlural:
relationObjectMetadataItem?.namePlural ?? '',
objectMetadataNameSingular: objectMetadataItem.nameSingular ?? '',
options: field.options,
};
return {
fieldMetadataId: field.id,
label: field.label,
showLabel,
labelWidth,
type: field.type,
metadata: {
fieldName: field.name,
placeHolder: field.label,
relationType: parseFieldRelationType(field),
relationFieldMetadataId,
relationObjectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular ?? '',
relationObjectMetadataNamePlural:
relationObjectMetadataItem?.namePlural ?? '',
objectMetadataNameSingular: objectMetadataItem.nameSingular ?? '',
options: field.options,
},
metadata: fieldDefintionMetadata,
iconName: field.icon ?? 'Icon123',
defaultValue: field.defaultValue,
editButtonIcon: getFieldButtonIcon({
metadata: fieldDefintionMetadata,
type: field.type,
}),
};
};

View File

@ -13,8 +13,10 @@ import {
RecordUpdateHook,
RecordUpdateHookParams,
} from '@/object-record/record-field/contexts/FieldContext';
import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox';
@ -209,6 +211,7 @@ export const RecordBoardCard = () => {
return (
<StyledBoardCardWrapper onContextMenu={handleContextMenu}>
<RecordValueSetterEffect recordId={recordId} />
<StyledBoardCard
ref={cardRef}
selected={isCurrentCardSelected}
@ -266,6 +269,10 @@ export const RecordBoardCard = () => {
type: fieldDefinition.type,
metadata: fieldDefinition.metadata,
defaultValue: fieldDefinition.defaultValue,
editButtonIcon: getFieldButtonIcon({
metadata: fieldDefinition.metadata,
type: fieldDefinition.type,
}),
},
useUpdateRecord: useUpdateOneRecordHook,
hotkeyScope: InlineCellHotkeyScope.InlineCell,

View File

@ -37,17 +37,7 @@ import { isFieldSelect } from '../types/guards/isFieldSelect';
import { isFieldText } from '../types/guards/isFieldText';
import { isFieldUuid } from '../types/guards/isFieldUuid';
type FieldDisplayProps = {
isCellSoftFocused?: boolean;
cellElement?: HTMLElement;
fromTableCell?: boolean;
};
export const FieldDisplay = ({
isCellSoftFocused,
cellElement,
fromTableCell,
}: FieldDisplayProps) => {
export const FieldDisplay = () => {
const { fieldDefinition, isLabelIdentifier } = useContext(FieldContext);
const isChipDisplay =
@ -78,10 +68,7 @@ export const FieldDisplay = ({
) : isFieldLink(fieldDefinition) ? (
<LinkFieldDisplay />
) : isFieldLinks(fieldDefinition) ? (
<LinksFieldDisplay
isCellSoftFocused={isCellSoftFocused}
fromTableCell={fromTableCell}
/>
<LinksFieldDisplay />
) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldDisplay />
) : isFieldFullName(fieldDefinition) ? (
@ -89,11 +76,7 @@ export const FieldDisplay = ({
) : isFieldSelect(fieldDefinition) ? (
<SelectFieldDisplay />
) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldDisplay
isCellSoftFocused={isCellSoftFocused}
cellElement={cellElement}
fromTableCell={fromTableCell}
/>
<MultiSelectFieldDisplay />
) : isFieldAddress(fieldDefinition) ? (
<AddressFieldDisplay />
) : isFieldRawJson(fieldDefinition) ? (

View File

@ -0,0 +1,10 @@
import { createContext } from 'react';
export type FieldFocusContextType = {
isFocused: boolean;
setIsFocused: (isFocused: boolean) => void;
};
export const FieldFocusContext = createContext<FieldFocusContextType>(
{} as FieldFocusContextType,
);

View File

@ -0,0 +1,18 @@
import { useState } from 'react';
import { FieldFocusContext } from '@/object-record/record-field/contexts/FieldFocusContext';
export const FieldFocusContextProvider = ({ children }: any) => {
const [isFocused, setIsFocused] = useState(false);
return (
<FieldFocusContext.Provider
value={{
isFocused,
setIsFocused,
}}
>
{children}
</FieldFocusContext.Provider>
);
};

View File

@ -0,0 +1,12 @@
import { useContext } from 'react';
import { FieldFocusContext } from '@/object-record/record-field/contexts/FieldFocusContext';
export const useFieldFocus = () => {
const { isFocused, setIsFocused } = useContext(FieldFocusContext);
return {
isFocused,
setIsFocused,
};
};

View File

@ -1,33 +1,12 @@
import { useContext } from 'react';
import { IconComponent, IconPencil } from 'twenty-ui';
import { IconComponent } from 'twenty-ui';
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon';
import { FieldContext } from '../contexts/FieldContext';
import { isFieldEmail } from '../types/guards/isFieldEmail';
import { isFieldLink } from '../types/guards/isFieldLink';
import { isFieldPhone } from '../types/guards/isFieldPhone';
export const useGetButtonIcon = (): IconComponent | undefined => {
const { fieldDefinition } = useContext(FieldContext);
if (isUndefinedOrNull(fieldDefinition)) return undefined;
if (
isFieldLink(fieldDefinition) ||
isFieldEmail(fieldDefinition) ||
isFieldPhone(fieldDefinition) ||
isFieldDisplayedAsPhone(fieldDefinition) ||
isFieldMultiSelect(fieldDefinition) ||
(isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationObjectMetadataNameSingular !==
'workspaceMember') ||
isFieldLinks(fieldDefinition)
) {
return IconPencil;
}
return getFieldButtonIcon(fieldDefinition);
};

View File

@ -1,18 +1,16 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../contexts/FieldContext';
export const useIsFieldEmpty = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const fieldValue = useRecoilValue(
recordStoreFamilySelector({
fieldName: fieldDefinition.metadata.fieldName,
recordId: entityId,
}),
const fieldValue = useRecordFieldValue(
entityId,
fieldDefinition.metadata.fieldName,
);
return isFieldValueEmpty({

View File

@ -1,22 +1,11 @@
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
import { LinksDisplay } from '@/ui/field/display/components/LinksDisplay';
type LinksFieldDisplayProps = {
isCellSoftFocused?: boolean;
fromTableCell?: boolean;
};
export const LinksFieldDisplay = ({
isCellSoftFocused,
fromTableCell,
}: LinksFieldDisplayProps) => {
export const LinksFieldDisplay = () => {
const { fieldValue } = useLinksField();
return (
<LinksDisplay
value={fieldValue}
isChipCountDisplayed={isCellSoftFocused}
withExpandedListBorder={fromTableCell}
/>
);
const { isFocused } = useFieldFocus();
return <LinksDisplay value={fieldValue} isFocused={isFocused} />;
};

View File

@ -1,20 +1,14 @@
import { Tag } from 'twenty-ui';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
type MultiSelectFieldDisplayProps = {
isCellSoftFocused?: boolean;
cellElement?: HTMLElement;
fromTableCell?: boolean;
};
export const MultiSelectFieldDisplay = ({
isCellSoftFocused,
fromTableCell,
}: MultiSelectFieldDisplayProps) => {
export const MultiSelectFieldDisplay = () => {
const { fieldValues, fieldDefinition } = useMultiSelectField();
const { isFocused } = useFieldFocus();
const selectedOptions = fieldValues
? fieldDefinition.metadata.options?.filter((option) =>
fieldValues.includes(option.value),
@ -24,10 +18,7 @@ export const MultiSelectFieldDisplay = ({
if (!selectedOptions) return null;
return (
<ExpandableList
isChipCountDisplayed={isCellSoftFocused}
withExpandedListBorder={fromTableCell}
>
<ExpandableList isChipCountDisplayed={isFocused}>
{selectedOptions.map((selectedOption, index) => (
<Tag
key={index}

View File

@ -1,16 +1,16 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import { useRelationFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFieldDisplay';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useRelationField } from '../../hooks/useRelationField';
export const RelationFieldDisplay = () => {
const { fieldValue, fieldDefinition, maxWidth } = useRelationField();
const { fieldValue, fieldDefinition, maxWidth } = useRelationFieldDisplay();
if (
!fieldValue ||
!fieldDefinition?.metadata.relationObjectMetadataNameSingular
)
) {
return null;
}
return (
<RecordChip

View File

@ -0,0 +1,86 @@
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 { 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>
),
ComponentDecorator,
],
component: RelationFieldDisplay,
argTypes: { value: { control: 'date' } },
args: {},
};
export default meta;
type Story = StoryObj<typeof RelationFieldDisplay>;
export const Default: Story = {};
export const Performance = getProfilingStory({
componentName: 'RelationFieldDisplay',
averageThresholdInMs: 0.4,
numberOfRuns: 20,
numberOfTestsPerRun: 100,
});

View File

@ -0,0 +1,113 @@
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,37 @@
import { useContext } from 'react';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FIELD_EDIT_BUTTON_WIDTH } from '@/ui/field/display/constants/FieldEditButtonWidth';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
import { isFieldRelation } from '../../types/guards/isFieldRelation';
export const useRelationFieldDisplay = () => {
const { entityId, fieldDefinition, maxWidth } = useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.Relation,
isFieldRelation,
fieldDefinition,
);
const button = fieldDefinition.editButtonIcon;
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue(entityId, fieldName);
const maxWidthForField =
isDefined(button) && isDefined(maxWidth)
? maxWidth - FIELD_EDIT_BUTTON_WIDTH
: maxWidth;
return {
fieldDefinition,
fieldValue,
maxWidth: maxWidthForField,
};
};

View File

@ -1,3 +1,5 @@
import { IconComponent } from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldMetadata } from './FieldMetadata';
@ -24,4 +26,5 @@ export type FieldDefinition<T extends FieldMetadata> = {
metadata: T;
infoTooltipContent?: string;
defaultValue?: any;
editButtonIcon?: IconComponent;
};

View File

@ -0,0 +1,36 @@
import { IconComponent, IconPencil } from 'twenty-ui';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { isFieldEmail } from '../types/guards/isFieldEmail';
import { isFieldLink } from '../types/guards/isFieldLink';
import { isFieldPhone } from '../types/guards/isFieldPhone';
export const getFieldButtonIcon = (
fieldDefinition:
| Pick<FieldDefinition<FieldMetadata>, 'type' | 'metadata'>
| undefined
| null,
): IconComponent | undefined => {
if (isUndefinedOrNull(fieldDefinition)) return undefined;
if (
isFieldLink(fieldDefinition) ||
isFieldEmail(fieldDefinition) ||
isFieldPhone(fieldDefinition) ||
isFieldDisplayedAsPhone(fieldDefinition) ||
isFieldMultiSelect(fieldDefinition) ||
(isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationObjectMetadataNameSingular !==
'workspaceMember') ||
isFieldLinks(fieldDefinition)
) {
return IconPencil;
}
};

View File

@ -16,6 +16,7 @@ import { recordIndexIsCompactModeActiveState } from '@/object-record/record-inde
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
import { ViewBar } from '@/views/components/ViewBar';
@ -105,74 +106,78 @@ export const RecordIndexContainer = ({
return (
<StyledContainer>
<SpreadsheetImportProvider>
<ViewBar
viewBarId={recordIndexId}
optionsDropdownButton={
<RecordIndexOptionsDropdown
recordIndexId={recordIndexId}
objectNameSingular={objectNameSingular}
viewType={recordIndexViewType ?? ViewType.Table}
/>
}
onCurrentViewChange={(view) => {
if (!view) {
return;
<RecordFieldValueSelectorContextProvider>
<SpreadsheetImportProvider>
<ViewBar
viewBarId={recordIndexId}
optionsDropdownButton={
<RecordIndexOptionsDropdown
recordIndexId={recordIndexId}
objectNameSingular={objectNameSingular}
viewType={recordIndexViewType ?? ViewType.Table}
/>
}
onCurrentViewChange={(view) => {
if (!view) {
return;
}
onViewFieldsChange(view.viewFields);
setTableFilters(
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
);
setRecordIndexFilters(
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
);
setTableSorts(mapViewSortsToSorts(view.viewSorts, sortDefinitions));
setRecordIndexSorts(
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
);
setRecordIndexViewType(view.type);
setRecordIndexViewKanbanFieldMetadataIdState(
view.kanbanFieldMetadataId,
);
setRecordIndexIsCompactModeActive(view.isCompact);
}}
/>
<RecordIndexViewBarEffect
objectNamePlural={objectNamePlural}
viewBarId={recordIndexId}
/>
</SpreadsheetImportProvider>
{recordIndexViewType === ViewType.Table && (
<>
<RecordIndexTableContainer
recordTableId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
createRecord={createRecord}
onViewFieldsChange(view.viewFields);
setTableFilters(
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
);
setRecordIndexFilters(
mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
);
setTableSorts(
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
);
setRecordIndexSorts(
mapViewSortsToSorts(view.viewSorts, sortDefinitions),
);
setRecordIndexViewType(view.type);
setRecordIndexViewKanbanFieldMetadataIdState(
view.kanbanFieldMetadataId,
);
setRecordIndexIsCompactModeActive(view.isCompact);
}}
/>
<RecordIndexTableContainerEffect
objectNameSingular={objectNameSingular}
recordTableId={recordIndexId}
<RecordIndexViewBarEffect
objectNamePlural={objectNamePlural}
viewBarId={recordIndexId}
/>
</>
)}
{recordIndexViewType === ViewType.Kanban && (
<>
<RecordIndexBoardContainer
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
createRecord={createRecord}
/>
<RecordIndexBoardContainerEffect
objectNameSingular={objectNameSingular}
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
/>
</>
)}
</SpreadsheetImportProvider>
{recordIndexViewType === ViewType.Table && (
<>
<RecordIndexTableContainer
recordTableId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
createRecord={createRecord}
/>
<RecordIndexTableContainerEffect
objectNameSingular={objectNameSingular}
recordTableId={recordIndexId}
viewBarId={recordIndexId}
/>
</>
)}
{recordIndexViewType === ViewType.Kanban && (
<>
<RecordIndexBoardContainer
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
createRecord={createRecord}
/>
<RecordIndexBoardContainerEffect
objectNameSingular={objectNameSingular}
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
/>
</>
)}
</RecordFieldValueSelectorContextProvider>
</StyledContainer>
);
};

View File

@ -4,6 +4,7 @@ import { useIcons } from 'twenty-ui';
import { FieldDisplay } from '@/object-record/record-field/components/FieldDisplay';
import { FieldInput } from '@/object-record/record-field/components/FieldInput';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { FieldFocusContextProvider } from '@/object-record/record-field/contexts/FieldFocusContextProvider';
import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButtonIcon';
import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty';
import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly';
@ -70,45 +71,44 @@ export const RecordInlineCell = ({
const { getIcon } = useIcons();
return (
<RecordInlineCellContainer
readonly={readonly}
buttonIcon={buttonIcon}
customEditHotkeyScope={
isFieldRelation(fieldDefinition)
? {
scope: RelationPickerHotkeyScope.RelationPicker,
}
: undefined
}
IconLabel={
fieldDefinition.iconName ? getIcon(fieldDefinition.iconName) : undefined
}
label={fieldDefinition.label}
labelWidth={fieldDefinition.labelWidth}
showLabel={fieldDefinition.showLabel}
editModeContent={
<FieldInput
recordFieldInputdId={`${entityId}-${fieldDefinition?.metadata?.fieldName}`}
onEnter={handleEnter}
onCancel={handleCancel}
onEscape={handleEscape}
onSubmit={handleSubmit}
onTab={handleTab}
onShiftTab={handleShiftTab}
onClickOutside={handleClickOutside}
isReadOnly={readonly}
/>
}
displayModeContent={({ cellElement, isCellSoftFocused }) => (
<FieldDisplay
cellElement={cellElement}
isCellSoftFocused={isCellSoftFocused}
/>
)}
isDisplayModeContentEmpty={isFieldEmpty}
isDisplayModeFixHeight
editModeContentOnly={isFieldInputOnly}
loading={loading}
/>
<FieldFocusContextProvider>
<RecordInlineCellContainer
readonly={readonly}
buttonIcon={buttonIcon}
customEditHotkeyScope={
isFieldRelation(fieldDefinition)
? {
scope: RelationPickerHotkeyScope.RelationPicker,
}
: undefined
}
IconLabel={
fieldDefinition.iconName
? getIcon(fieldDefinition.iconName)
: undefined
}
label={fieldDefinition.label}
labelWidth={fieldDefinition.labelWidth}
showLabel={fieldDefinition.showLabel}
editModeContent={
<FieldInput
recordFieldInputdId={`${entityId}-${fieldDefinition?.metadata?.fieldName}`}
onEnter={handleEnter}
onCancel={handleCancel}
onEscape={handleEscape}
onSubmit={handleSubmit}
onTab={handleTab}
onShiftTab={handleShiftTab}
onClickOutside={handleClickOutside}
isReadOnly={readonly}
/>
}
displayModeContent={<FieldDisplay />}
isDisplayModeContentEmpty={isFieldEmpty}
isDisplayModeFixHeight
editModeContentOnly={isFieldInputOnly}
loading={loading}
/>
</FieldFocusContextProvider>
);
};

View File

@ -1,20 +1,15 @@
import React, { useContext, useState } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import React, { ReactElement, useContext } from 'react';
import { Tooltip } from 'react-tooltip';
import { css, useTheme } from '@emotion/react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from 'twenty-ui';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { RecordInlineCellValue } from '@/object-record/record-inline-cell/components/RecordInlineCellValue';
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useInlineCell } from '../hooks/useInlineCell';
import { RecordInlineCellDisplayMode } from './RecordInlineCellDisplayMode';
import { RecordInlineCellButton } from './RecordInlineCellEditButton';
import { RecordInlineCellEditMode } from './RecordInlineCellEditMode';
const StyledIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
@ -48,18 +43,6 @@ const StyledLabelContainer = styled.div<{ width?: number }>`
width: ${({ width }) => width}px;
`;
const StyledClickableContainer = styled.div<{ readonly?: boolean }>`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
width: 100%;
${({ readonly }) =>
!readonly &&
css`
cursor: pointer;
`};
`;
const StyledInlineCellBaseContainer = styled.div`
align-items: center;
box-sizing: border-box;
@ -82,41 +65,20 @@ const StyledTooltip = styled(Tooltip)`
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledSkeletonDiv = styled.div`
export const StyledSkeletonDiv = styled.div`
height: 24px;
`;
const StyledInlineCellSkeletonLoader = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledSkeletonDiv>
<Skeleton width={154} height={16} />
</StyledSkeletonDiv>
</SkeletonTheme>
);
};
type RecordInlineCellContainerProps = {
export type RecordInlineCellContainerProps = {
readonly?: boolean;
IconLabel?: IconComponent;
label?: string;
labelWidth?: number;
showLabel?: boolean;
buttonIcon?: IconComponent;
editModeContent?: React.ReactNode;
editModeContent?: ReactElement;
editModeContentOnly?: boolean;
displayModeContent: ({
isCellSoftFocused,
cellElement,
}: {
isCellSoftFocused: boolean;
cellElement?: HTMLDivElement;
}) => React.ReactNode;
displayModeContent: ReactElement;
customEditHotkeyScope?: HotkeyScope;
isDisplayModeContentEmpty?: boolean;
isDisplayModeFixHeight?: boolean;
@ -141,85 +103,24 @@ export const RecordInlineCellContainer = ({
loading = false,
}: RecordInlineCellContainerProps) => {
const { entityId, fieldDefinition } = useContext(FieldContext);
// Used by some fields in ExpandableList as an anchor for the floating element.
// floating-ui mentions that `useState` must be used instead of `useRef`,
// see https://floating-ui.com/docs/useFloating#elements
const [cellElement, setCellElement] = useState<HTMLDivElement | null>(null);
const [isHovered, setIsHovered] = useState(false);
const [isCellSoftFocused, setIsCellSoftFocused] = useState(false);
const { setIsFocused } = useFieldFocus();
const handleContainerMouseEnter = () => {
if (!readonly) {
setIsHovered(true);
setIsFocused(true);
}
setIsCellSoftFocused(true);
};
const handleContainerMouseLeave = () => {
if (!readonly) {
setIsHovered(false);
}
setIsCellSoftFocused(false);
};
const { isInlineCellInEditMode, openInlineCell } = useInlineCell();
const handleDisplayModeClick = () => {
if (!readonly && !editModeContentOnly) {
openInlineCell(customEditHotkeyScope);
setIsFocused(false);
}
};
const showEditButton =
buttonIcon &&
!isInlineCellInEditMode &&
isHovered &&
!editModeContentOnly &&
!isDisplayModeContentEmpty;
const theme = useTheme();
const labelId = `label-${entityId}-${fieldDefinition?.metadata?.fieldName}`;
const showContent = () => {
if (loading) {
return <StyledInlineCellSkeletonLoader />;
}
return !readonly && isInlineCellInEditMode ? (
<RecordInlineCellEditMode>{editModeContent}</RecordInlineCellEditMode>
) : editModeContentOnly ? (
<StyledClickableContainer readonly={readonly}>
<RecordInlineCellDisplayMode
disableHoverEffect={disableHoverEffect}
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
isDisplayModeFixHeight={isDisplayModeFixHeight}
isHovered={isHovered}
emptyPlaceholder={showLabel ? 'Empty' : label}
>
{editModeContent}
</RecordInlineCellDisplayMode>
</StyledClickableContainer>
) : (
<StyledClickableContainer
readonly={readonly}
onClick={handleDisplayModeClick}
>
<RecordInlineCellDisplayMode
disableHoverEffect={disableHoverEffect}
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
isDisplayModeFixHeight={isDisplayModeFixHeight}
isHovered={isHovered}
emptyPlaceholder={showLabel ? 'Empty' : label}
>
{displayModeContent({
isCellSoftFocused,
cellElement: cellElement ?? undefined,
})}
</RecordInlineCellDisplayMode>
{showEditButton && <RecordInlineCellButton Icon={buttonIcon} />}
</StyledClickableContainer>
);
};
return (
<StyledInlineCellBaseContainer
onMouseEnter={handleContainerMouseEnter}
@ -250,8 +151,23 @@ export const RecordInlineCellContainer = ({
)}
</StyledLabelAndIconContainer>
)}
<StyledValueContainer ref={setCellElement}>
{showContent()}
<StyledValueContainer>
<RecordInlineCellValue
{...{
displayModeContent,
customEditHotkeyScope,
disableHoverEffect,
editModeContent,
editModeContentOnly,
isDisplayModeContentEmpty,
isDisplayModeFixHeight,
buttonIcon,
label,
loading,
readonly,
showLabel,
}}
/>
</StyledValueContainer>
</StyledInlineCellBaseContainer>
);

View File

@ -0,0 +1,20 @@
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useTheme } from '@emotion/react';
import { StyledSkeletonDiv } from './RecordInlineCellContainer';
export const RecordInlineCellSkeletonLoader = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledSkeletonDiv>
<Skeleton width={154} height={16} />
</StyledSkeletonDiv>
</SkeletonTheme>
);
};

View File

@ -0,0 +1,106 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { RecordInlineCellContainerProps } from '@/object-record/record-inline-cell/components/RecordInlineCellContainer';
import { RecordInlineCellDisplayMode } from '@/object-record/record-inline-cell/components/RecordInlineCellDisplayMode';
import { RecordInlineCellButton } from '@/object-record/record-inline-cell/components/RecordInlineCellEditButton';
import { RecordInlineCellEditMode } from '@/object-record/record-inline-cell/components/RecordInlineCellEditMode';
import { RecordInlineCellSkeletonLoader } from '@/object-record/record-inline-cell/components/RecordInlineCellSkeletonLoader';
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
const StyledClickableContainer = styled.div<{ readonly?: boolean }>`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
width: 100%;
${({ readonly }) =>
!readonly &&
css`
cursor: pointer;
`};
`;
type RecordInlineCellValueProps = Pick<
RecordInlineCellContainerProps,
| 'editModeContent'
| 'displayModeContent'
| 'customEditHotkeyScope'
| 'isDisplayModeContentEmpty'
| 'editModeContentOnly'
| 'isDisplayModeFixHeight'
| 'disableHoverEffect'
| 'readonly'
| 'buttonIcon'
| 'loading'
| 'showLabel'
| 'label'
>;
export const RecordInlineCellValue = ({
displayModeContent,
customEditHotkeyScope,
disableHoverEffect,
editModeContent,
editModeContentOnly,
isDisplayModeContentEmpty,
isDisplayModeFixHeight,
readonly,
buttonIcon,
loading,
showLabel,
label,
}: RecordInlineCellValueProps) => {
const { isFocused } = useFieldFocus();
const { isInlineCellInEditMode, openInlineCell } = useInlineCell();
const handleDisplayModeClick = () => {
if (!readonly && !editModeContentOnly) {
openInlineCell(customEditHotkeyScope);
}
};
const showEditButton =
buttonIcon &&
!isInlineCellInEditMode &&
isFocused &&
!editModeContentOnly &&
!isDisplayModeContentEmpty;
if (loading === true) {
return <RecordInlineCellSkeletonLoader />;
}
return !readonly && isInlineCellInEditMode ? (
<RecordInlineCellEditMode>{editModeContent}</RecordInlineCellEditMode>
) : editModeContentOnly ? (
<StyledClickableContainer readonly={readonly}>
<RecordInlineCellDisplayMode
disableHoverEffect={disableHoverEffect}
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
isDisplayModeFixHeight={isDisplayModeFixHeight}
isHovered={isFocused}
emptyPlaceholder={showLabel ? 'Empty' : label}
>
{editModeContent}
</RecordInlineCellDisplayMode>
</StyledClickableContainer>
) : (
<StyledClickableContainer
readonly={readonly}
onClick={handleDisplayModeClick}
>
<RecordInlineCellDisplayMode
disableHoverEffect={disableHoverEffect}
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
isDisplayModeFixHeight={isDisplayModeFixHeight}
isHovered={isFocused}
emptyPlaceholder={showLabel ? 'Empty' : label}
>
{displayModeContent}
</RecordInlineCellDisplayMode>
{showEditButton && <RecordInlineCellButton Icon={buttonIcon} />}
</StyledClickableContainer>
);
};

View File

@ -28,6 +28,7 @@ import { RecordInlineCell } from '@/object-record/record-inline-cell/components/
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { RecordDetailRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsListItem';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported';
@ -187,6 +188,7 @@ export const RecordDetailRelationRecordsListItem = ({
return (
<>
<RecordValueSetterEffect recordId={relationRecord.id} />
<StyledListItem isDropdownOpen={isDropdownOpen}>
<RecordChip
record={relationRecord}

View File

@ -0,0 +1,17 @@
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { useSetRecordValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
export const RecordValueSetterEffect = ({ recordId }: { recordId: string }) => {
const setRecordValue = useSetRecordValue();
const recordValue = useRecoilValue(recordStoreFamilyState(recordId));
useEffect(() => {
setRecordValue(recordId, recordValue);
}, [setRecordValue, recordValue, recordId]);
return null;
};

View File

@ -0,0 +1,73 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
export type RecordFieldValue = {
[recordId: string]: {
[fieldName: string]: any;
};
};
export const RecordFieldValueSelectorContext = createContext<
[RecordFieldValue, Dispatch<SetStateAction<RecordFieldValue>>]
>([{}, () => {}]);
export const useSetRecordValue = () => {
const setTableValue = useContextSelector(
RecordFieldValueSelectorContext,
(value) => value[1],
);
return (recordId: string, newRecord: any) => {
setTableValue((currentTable) => ({
...currentTable,
[recordId]: newRecord,
}));
};
};
export const useRecordValue = (recordId: string) => {
const tableValue = useContextSelector(
RecordFieldValueSelectorContext,
(value) => value[0],
);
return tableValue?.[recordId];
};
export const useRecordFieldValue = (recordId: string, fieldName: string) => {
const tableValue = useContextSelector(
RecordFieldValueSelectorContext,
(value) => value[0],
);
return tableValue?.[recordId]?.[fieldName];
};
export const useSetRecordFieldValue = () => {
const setTableValue = useContextSelector(
RecordFieldValueSelectorContext,
(value) => value[1],
);
return (recordId: string, fieldName: string, newValue: any) => {
setTableValue((currentTable) => ({
...currentTable,
[recordId]: {
...currentTable[recordId],
[fieldName]: newValue,
},
}));
};
};
export const RecordFieldValueSelectorContextProvider = ({
children,
}: {
children: any;
}) => (
<RecordFieldValueSelectorContext.Provider
value={useState<RecordFieldValue>({})}
>
{children}
</RecordFieldValueSelectorContext.Provider>
);

View File

@ -4,6 +4,7 @@ import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
import { RecordTableCellFieldContextWrapper } from '@/object-record/record-table/components/RecordTableCellFieldContextWrapper';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
@ -52,6 +53,7 @@ export const RecordTableRow = ({ recordId, rowIndex }: RecordTableRowProps) => {
isReadOnly: objectMetadataItem.isRemote ?? false,
}}
>
<RecordValueSetterEffect recordId={recordId} />
<tr
ref={elementRef}
data-testid={`row-id-${recordId}`}

View File

@ -0,0 +1,134 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import {
RecordFieldValueSelectorContextProvider,
useSetRecordValue,
} from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { RecordTableCellFieldContextWrapper } from '@/object-record/record-table/components/RecordTableCellFieldContextWrapper';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
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 { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
import { recordTableCellMock } from './mock';
const RelationFieldValueSetterEffect = () => {
const setEntity = useSetRecoilState(
recordStoreFamilyState(recordTableCellMock.entityId),
);
const setRelationEntity = useSetRecoilState(
recordStoreFamilyState(recordTableCellMock.relationEntityId),
);
const setRecordValue = useSetRecordValue();
useEffect(() => {
setEntity(recordTableCellMock.entityValue);
setRelationEntity(recordTableCellMock.relationFieldValue);
setRecordValue(
recordTableCellMock.entityValue.id,
recordTableCellMock.entityValue,
);
setRecordValue(
recordTableCellMock.relationFieldValue.id,
recordTableCellMock.relationFieldValue,
);
}, [setEntity, setRelationEntity, setRecordValue]);
return null;
};
const meta: Meta = {
title: 'RecordIndex/Table/RecordTableCell',
decorators: [
MemoryRouterDecorator,
(Story) => (
<RecordFieldValueSelectorContextProvider>
<RecordTableContext.Provider
value={{
objectMetadataItem: recordTableCellMock.objectMetadataItem as any,
onUpsertRecord: () => {},
onOpenTableCell: () => {},
onMoveFocus: () => {},
onCloseTableCell: () => {},
onMoveSoftFocusToCell: () => {},
onContextMenu: () => {},
onCellMouseEnter: () => {},
}}
>
<RecordTableScope recordTableScopeId="asd" onColumnsChange={() => {}}>
<RecordTableRowContext.Provider
value={{
recordId: recordTableCellMock.entityId,
rowIndex: 0,
pathToShowPage:
getBasePathToShowPage({
objectNameSingular:
recordTableCellMock.entityValue.__typename.toLocaleLowerCase(),
}) + recordTableCellMock.entityId,
isSelected: false,
isReadOnly: false,
}}
>
<RecordTableCellContext.Provider
value={{
columnDefinition: recordTableCellMock.fieldDefinition,
columnIndex: 0,
}}
>
<FieldContext.Provider
value={{
entityId: recordTableCellMock.entityId,
basePathToShowPage: '/object-record/',
isLabelIdentifier: false,
fieldDefinition: {
...recordTableCellMock.fieldDefinition,
},
hotkeyScope: 'hotkey-scope',
}}
>
<RelationFieldValueSetterEffect />
<table>
<tbody>
<tr>
<Story />
</tr>
</tbody>
</table>
</FieldContext.Provider>
</RecordTableCellContext.Provider>
</RecordTableRowContext.Provider>
</RecordTableScope>
</RecordTableContext.Provider>
</RecordFieldValueSelectorContextProvider>
),
ComponentDecorator,
],
component: RecordTableCellFieldContextWrapper,
argTypes: { value: { control: 'date' } },
args: {},
};
export default meta;
type Story = StoryObj<typeof RecordTableCellFieldContextWrapper>;
export const Default: Story = {};
export const Performance = getProfilingStory({
componentName: 'RecordTableCell',
averageThresholdInMs: 0.6,
numberOfRuns: 50,
numberOfTestsPerRun: 200,
warmUpRounds: 20,
});

View File

@ -0,0 +1,883 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const recordTableCellMock = {
objectMetadataItem: {
__typename: 'object',
id: '4916628e-8570-4242-8970-f58c509e5a93',
dataSourceId: '0fd9fd54-0e8d-4f78-911c-76b33436a768',
nameSingular: 'person',
namePlural: 'people',
labelSingular: 'Person',
labelPlural: 'People',
description: 'A person',
icon: 'IconUser',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
fields: [
{
__typename: 'field',
id: '9058056e-36b3-4a3f-9037-f0bca9744296',
type: 'RELATION',
name: 'company',
label: 'Company',
description: 'Contacts company',
icon: 'IconBuildingSkyscraper',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: {
__typename: 'relation',
id: '0cf72416-3d94-4d94-abf3-7dc9d734435b',
relationType: 'ONE_TO_MANY',
fromObjectMetadata: {
__typename: 'object',
id: '79c2d29c-76f6-432f-91c9-df1259b73d95',
dataSourceId: '0fd9fd54-0e8d-4f78-911c-76b33436a768',
nameSingular: 'company',
namePlural: 'companies',
isSystem: false,
isRemote: false,
},
fromFieldMetadataId: '7b281010-5f47-4771-b3f5-f4bcd24ed1b5',
},
defaultValue: null,
options: null,
relationDefinition: {
__typename: 'RelationDefinition',
relationId: '0cf72416-3d94-4d94-abf3-7dc9d734435b',
direction: 'MANY_TO_ONE',
sourceObjectMetadata: {
__typename: 'object',
id: '4916628e-8570-4242-8970-f58c509e5a93',
nameSingular: 'person',
namePlural: 'people',
},
sourceFieldMetadata: {
__typename: 'field',
id: '9058056e-36b3-4a3f-9037-f0bca9744296',
name: 'company',
},
targetObjectMetadata: {
__typename: 'object',
id: '79c2d29c-76f6-432f-91c9-df1259b73d95',
nameSingular: 'company',
namePlural: 'companies',
},
targetFieldMetadata: {
__typename: 'field',
id: '7b281010-5f47-4771-b3f5-f4bcd24ed1b5',
name: 'people',
},
},
},
{
__typename: 'field',
id: 'bd504d22-ecae-4228-8729-5c770a174336',
type: 'TEXT',
name: 'avatarUrl',
label: 'Avatar',
description: 'Contacts avatar',
icon: 'IconFileUpload',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: false,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: "''",
options: null,
relationDefinition: null,
},
{
__typename: 'field',
id: '21238919-5d92-402e-8124-367948ef86e6',
type: 'TEXT',
name: 'city',
label: 'City',
description: 'Contacts city',
icon: 'IconMap',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: "''",
options: null,
relationDefinition: null,
},
{
__typename: 'field',
id: '78edf4bb-c6a6-449e-b9db-20a575b97d5e',
type: 'RELATION',
name: 'activityTargets',
label: 'Activities',
description: 'Activities tied to the contact',
icon: 'IconCheckbox',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: {
__typename: 'relation',
id: 'd76f949d-023d-4b45-a71e-f39e3b1562ba',
relationType: 'ONE_TO_MANY',
toObjectMetadata: {
__typename: 'object',
id: '82222ca2-dd40-44ec-b8c5-eb0eca9ec625',
dataSourceId: '0fd9fd54-0e8d-4f78-911c-76b33436a768',
nameSingular: 'activityTarget',
namePlural: 'activityTargets',
isSystem: true,
isRemote: false,
},
toFieldMetadataId: 'f5f515cc-6d8a-44c3-b2d4-f04b9868a9c5',
},
toRelationMetadata: null,
defaultValue: null,
options: null,
relationDefinition: {
__typename: 'RelationDefinition',
relationId: 'd76f949d-023d-4b45-a71e-f39e3b1562ba',
direction: 'ONE_TO_MANY',
sourceObjectMetadata: {
__typename: 'object',
id: '4916628e-8570-4242-8970-f58c509e5a93',
nameSingular: 'person',
namePlural: 'people',
},
sourceFieldMetadata: {
__typename: 'field',
id: '78edf4bb-c6a6-449e-b9db-20a575b97d5e',
name: 'activityTargets',
},
targetObjectMetadata: {
__typename: 'object',
id: '82222ca2-dd40-44ec-b8c5-eb0eca9ec625',
nameSingular: 'activityTarget',
namePlural: 'activityTargets',
},
targetFieldMetadata: {
__typename: 'field',
id: 'f5f515cc-6d8a-44c3-b2d4-f04b9868a9c5',
name: 'person',
},
},
},
{
__typename: 'field',
id: '4128b168-1439-441e-bb6a-223fa1276642',
type: 'RELATION',
name: 'pointOfContactForOpportunities',
label: 'POC for Opportunities',
description: 'Point of Contact for Opportunities',
icon: 'IconTargetArrow',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: {
__typename: 'relation',
id: 'a5a61d23-8ac9-4014-9441-ec3a1781a661',
relationType: 'ONE_TO_MANY',
toObjectMetadata: {
__typename: 'object',
id: '494b9b7c-a44e-4d52-b274-cdfb0e322165',
dataSourceId: '0fd9fd54-0e8d-4f78-911c-76b33436a768',
nameSingular: 'opportunity',
namePlural: 'opportunities',
isSystem: false,
isRemote: false,
},
toFieldMetadataId: '86559a6f-6afc-4d5c-9bed-fc74d063791b',
},
toRelationMetadata: null,
defaultValue: null,
options: null,
relationDefinition: {
__typename: 'RelationDefinition',
relationId: 'a5a61d23-8ac9-4014-9441-ec3a1781a661',
direction: 'ONE_TO_MANY',
sourceObjectMetadata: {
__typename: 'object',
id: '4916628e-8570-4242-8970-f58c509e5a93',
nameSingular: 'person',
namePlural: 'people',
},
sourceFieldMetadata: {
__typename: 'field',
id: '4128b168-1439-441e-bb6a-223fa1276642',
name: 'pointOfContactForOpportunities',
},
targetObjectMetadata: {
__typename: 'object',
id: '494b9b7c-a44e-4d52-b274-cdfb0e322165',
nameSingular: 'opportunity',
namePlural: 'opportunities',
},
targetFieldMetadata: {
__typename: 'field',
id: '86559a6f-6afc-4d5c-9bed-fc74d063791b',
name: 'pointOfContact',
},
},
},
{
__typename: 'field',
id: '3db3a6ac-a960-42bd-8375-59ab6c4837d6',
type: 'RELATION',
name: 'calendarEventParticipants',
label: 'Calendar Event Participants',
description: 'Calendar Event Participants',
icon: 'IconCalendar',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: {
__typename: 'relation',
id: '456f7875-b48c-4795-a0c7-a69d7339afee',
relationType: 'ONE_TO_MANY',
toObjectMetadata: {
__typename: 'object',
id: 'eba13fca-57b7-470c-8c23-a0e640e04ffb',
dataSourceId: '0fd9fd54-0e8d-4f78-911c-76b33436a768',
nameSingular: 'calendarEventParticipant',
namePlural: 'calendarEventParticipants',
isSystem: true,
isRemote: false,
},
toFieldMetadataId: 'c1cdebda-b514-4487-9b9c-aa59d8fca8eb',
},
toRelationMetadata: null,
defaultValue: null,
options: null,
relationDefinition: {
__typename: 'RelationDefinition',
relationId: '456f7875-b48c-4795-a0c7-a69d7339afee',
direction: 'ONE_TO_MANY',
sourceObjectMetadata: {
__typename: 'object',
id: '4916628e-8570-4242-8970-f58c509e5a93',
nameSingular: 'person',
namePlural: 'people',
},
sourceFieldMetadata: {
__typename: 'field',
id: '3db3a6ac-a960-42bd-8375-59ab6c4837d6',
name: 'calendarEventParticipants',
},
targetObjectMetadata: {
__typename: 'object',
id: 'eba13fca-57b7-470c-8c23-a0e640e04ffb',
nameSingular: 'calendarEventParticipant',
namePlural: 'calendarEventParticipants',
},
targetFieldMetadata: {
__typename: 'field',
id: 'c1cdebda-b514-4487-9b9c-aa59d8fca8eb',
name: 'person',
},
},
},
{
__typename: 'field',
id: 'f0a290ac-fa74-48da-a77f-db221cb0206a',
type: 'DATE_TIME',
name: 'createdAt',
label: 'Creation date',
description: 'Creation date',
icon: 'IconCalendar',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: 'now',
options: null,
relationDefinition: null,
},
{
__typename: 'field',
id: 'b96e0e45-278c-44b6-a601-30ba24592dd6',
type: 'RELATION',
name: 'favorites',
label: 'Favorites',
description: 'Favorites linked to the contact',
icon: 'IconHeart',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: {
__typename: 'relation',
id: '31542774-fb15-4d01-b00b-8fc94887f458',
relationType: 'ONE_TO_MANY',
toObjectMetadata: {
__typename: 'object',
id: 'f08422e2-14cd-4966-9cd3-bce0302cc56f',
dataSourceId: '0fd9fd54-0e8d-4f78-911c-76b33436a768',
nameSingular: 'favorite',
namePlural: 'favorites',
isSystem: true,
isRemote: false,
},
toFieldMetadataId: '67d28b17-ff3c-49b4-a6da-1354be9634b0',
},
toRelationMetadata: null,
defaultValue: null,
options: null,
relationDefinition: {
__typename: 'RelationDefinition',
relationId: '31542774-fb15-4d01-b00b-8fc94887f458',
direction: 'ONE_TO_MANY',
sourceObjectMetadata: {
__typename: 'object',
id: '4916628e-8570-4242-8970-f58c509e5a93',
nameSingular: 'person',
namePlural: 'people',
},
sourceFieldMetadata: {
__typename: 'field',
id: 'b96e0e45-278c-44b6-a601-30ba24592dd6',
name: 'favorites',
},
targetObjectMetadata: {
__typename: 'object',
id: 'f08422e2-14cd-4966-9cd3-bce0302cc56f',
nameSingular: 'favorite',
namePlural: 'favorites',
},
targetFieldMetadata: {
__typename: 'field',
id: '67d28b17-ff3c-49b4-a6da-1354be9634b0',
name: 'person',
},
},
},
{
__typename: 'field',
id: '430af81e-2a8c-4ce2-9969-c0f0e91818bb',
type: 'LINK',
name: 'linkedinLink',
label: 'Linkedin',
description: 'Contacts Linkedin account',
icon: 'IconBrandLinkedin',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: {
url: "''",
label: "''",
},
options: null,
relationDefinition: null,
},
{
__typename: 'field',
id: 'c885c3d9-63e2-4c0d-b7d6-ee9e867eb1f6',
type: 'RELATION',
name: 'attachments',
label: 'Attachments',
description: 'Attachments linked to the contact.',
icon: 'IconFileImport',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: {
__typename: 'relation',
id: 'c0cc3456-afa4-46e0-820d-2db0b63a8273',
relationType: 'ONE_TO_MANY',
toObjectMetadata: {
__typename: 'object',
id: '0e3c9a9d-8a60-4671-a466-7b840a422da2',
dataSourceId: '0fd9fd54-0e8d-4f78-911c-76b33436a768',
nameSingular: 'attachment',
namePlural: 'attachments',
isSystem: true,
isRemote: false,
},
toFieldMetadataId: 'a920a0d6-8e71-4ab8-90b9-ab540e04732a',
},
toRelationMetadata: null,
defaultValue: null,
options: null,
relationDefinition: {
__typename: 'RelationDefinition',
relationId: 'c0cc3456-afa4-46e0-820d-2db0b63a8273',
direction: 'ONE_TO_MANY',
sourceObjectMetadata: {
__typename: 'object',
id: '4916628e-8570-4242-8970-f58c509e5a93',
nameSingular: 'person',
namePlural: 'people',
},
sourceFieldMetadata: {
__typename: 'field',
id: 'c885c3d9-63e2-4c0d-b7d6-ee9e867eb1f6',
name: 'attachments',
},
targetObjectMetadata: {
__typename: 'object',
id: '0e3c9a9d-8a60-4671-a466-7b840a422da2',
nameSingular: 'attachment',
namePlural: 'attachments',
},
targetFieldMetadata: {
__typename: 'field',
id: 'a920a0d6-8e71-4ab8-90b9-ab540e04732a',
name: 'person',
},
},
},
{
__typename: 'field',
id: 'cc63e38f-56d6-495e-a545-edf101e400cf',
type: 'TEXT',
name: 'phone',
label: 'Phone',
description: 'Contacts phone number',
icon: 'IconPhone',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: "''",
options: null,
relationDefinition: null,
},
{
__typename: 'field',
id: '0084a5f7-cb57-4cd5-8b14-93ab51c21f45',
type: 'POSITION',
name: 'position',
label: 'Position',
description: 'Person record Position',
icon: 'IconHierarchy2',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: null,
options: null,
relationDefinition: null,
},
{
__typename: 'field',
id: 'ca54aa1d-1ecb-486c-99ea-b8240871a0da',
type: 'EMAIL',
name: 'email',
label: 'Email',
description: 'Contacts Email',
icon: 'IconMail',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: "''",
options: null,
relationDefinition: null,
},
{
__typename: 'field',
id: '54561a8e-b918-471b-a363-5a77f49cd348',
type: 'TEXT',
name: 'jobTitle',
label: 'Job Title',
description: 'Contacts job title',
icon: 'IconBriefcase',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: false,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: "''",
options: null,
relationDefinition: null,
},
{
__typename: 'field',
id: '4e844d31-f117-443c-8754-8cb63e963ecc',
type: 'DATE_TIME',
name: 'updatedAt',
label: 'Update date',
description: 'Update date',
icon: 'IconCalendar',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: false,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: 'now',
options: null,
relationDefinition: null,
},
{
__typename: 'field',
id: '4ddd38df-d9a3-4889-a39f-1e336cd8113c',
type: 'UUID',
name: 'companyId',
label: 'Company id (foreign key)',
description: 'Contacts company id foreign key',
icon: 'IconBuildingSkyscraper',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: null,
options: null,
relationDefinition: null,
},
{
__typename: 'field',
id: 'e6922ecb-7a3a-4520-b001-bbf95fc33197',
type: 'RELATION',
name: 'timelineActivities',
label: 'Events',
description: 'Events linked to the company',
icon: 'IconTimelineEvent',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: {
__typename: 'relation',
id: '25150feb-fcd7-407e-b5fa-ffe58a0450ac',
relationType: 'ONE_TO_MANY',
toObjectMetadata: {
__typename: 'object',
id: '83b5ff3e-975e-4dc9-ba4d-c645a0d8afb2',
dataSourceId: '0fd9fd54-0e8d-4f78-911c-76b33436a768',
nameSingular: 'timelineActivity',
namePlural: 'timelineActivities',
isSystem: true,
isRemote: false,
},
toFieldMetadataId: '556a12d4-ef0a-4232-963f-0f317f4c5ef5',
},
toRelationMetadata: null,
defaultValue: null,
options: null,
relationDefinition: {
__typename: 'RelationDefinition',
relationId: '25150feb-fcd7-407e-b5fa-ffe58a0450ac',
direction: 'ONE_TO_MANY',
sourceObjectMetadata: {
__typename: 'object',
id: '4916628e-8570-4242-8970-f58c509e5a93',
nameSingular: 'person',
namePlural: 'people',
},
sourceFieldMetadata: {
__typename: 'field',
id: 'e6922ecb-7a3a-4520-b001-bbf95fc33197',
name: 'timelineActivities',
},
targetObjectMetadata: {
__typename: 'object',
id: '83b5ff3e-975e-4dc9-ba4d-c645a0d8afb2',
nameSingular: 'timelineActivity',
namePlural: 'timelineActivities',
},
targetFieldMetadata: {
__typename: 'field',
id: '556a12d4-ef0a-4232-963f-0f317f4c5ef5',
name: 'person',
},
},
},
{
__typename: 'field',
id: '07a8a574-ed28-4015-b456-c01ff3050e2b',
type: 'FULL_NAME',
name: 'name',
label: 'Name',
description: 'Contacts name',
icon: 'IconUser',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: {
lastName: "''",
firstName: "''",
},
options: null,
relationDefinition: null,
},
{
__typename: 'field',
id: 'c470144b-6692-47cb-a28f-04610d9d641c',
type: 'LINK',
name: 'xLink',
label: 'X',
description: 'Contacts X/Twitter account',
icon: 'IconBrandX',
isCustom: false,
isActive: true,
isSystem: false,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: {
url: "''",
label: "''",
},
options: null,
relationDefinition: null,
},
{
__typename: 'field',
id: 'c692aa2c-e88e-4aff-b77e-b9ebf26509e3',
type: 'RELATION',
name: 'messageParticipants',
label: 'Message Participants',
description: 'Message Participants',
icon: 'IconUserCircle',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: true,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: {
__typename: 'relation',
id: 'e2eb7156-6e65-4bf8-922b-670179744f27',
relationType: 'ONE_TO_MANY',
toObjectMetadata: {
__typename: 'object',
id: 'ffd8e640-84b7-4ed6-99e9-14def0f9d82b',
dataSourceId: '0fd9fd54-0e8d-4f78-911c-76b33436a768',
nameSingular: 'messageParticipant',
namePlural: 'messageParticipants',
isSystem: true,
isRemote: false,
},
toFieldMetadataId: '8c4593a1-ad40-4681-92fe-43ad4fe60205',
},
toRelationMetadata: null,
defaultValue: null,
options: null,
relationDefinition: {
__typename: 'RelationDefinition',
relationId: 'e2eb7156-6e65-4bf8-922b-670179744f27',
direction: 'ONE_TO_MANY',
sourceObjectMetadata: {
__typename: 'object',
id: '4916628e-8570-4242-8970-f58c509e5a93',
nameSingular: 'person',
namePlural: 'people',
},
sourceFieldMetadata: {
__typename: 'field',
id: 'c692aa2c-e88e-4aff-b77e-b9ebf26509e3',
name: 'messageParticipants',
},
targetObjectMetadata: {
__typename: 'object',
id: 'ffd8e640-84b7-4ed6-99e9-14def0f9d82b',
nameSingular: 'messageParticipant',
namePlural: 'messageParticipants',
},
targetFieldMetadata: {
__typename: 'field',
id: '8c4593a1-ad40-4681-92fe-43ad4fe60205',
name: 'person',
},
},
},
{
__typename: 'field',
id: '66d33eae-71be-49fa-ad7a-3e10ac53dfba',
type: 'UUID',
name: 'id',
label: 'Id',
description: 'Id',
icon: 'Icon123',
isCustom: false,
isActive: true,
isSystem: true,
isNullable: false,
createdAt: '2024-05-16T10:54:27.788Z',
updatedAt: '2024-05-16T10:54:27.788Z',
fromRelationMetadata: null,
toRelationMetadata: null,
defaultValue: 'uuid',
options: null,
relationDefinition: null,
},
],
},
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

@ -21,6 +21,13 @@ export const useCloseCurrentTableCellInEditMode = (recordTableId?: string) => {
isTableCellInEditModeFamilyState(currentTableCellInEditModePosition),
false,
);
document.dispatchEvent(
new CustomEvent(
`edit-mode-change-${currentTableCellInEditModePosition.row}:${currentTableCellInEditModePosition.column}`,
{ detail: false },
),
);
};
},
[currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState],

View File

@ -24,9 +24,23 @@ export const useMoveEditModeToTableCellPosition = (recordTableId?: string) => {
false,
);
document.dispatchEvent(
new CustomEvent(
`edit-mode-change-${currentTableCellInEditModePosition.row}:${currentTableCellInEditModePosition.column}`,
{ detail: false },
),
);
set(currentTableCellInEditModePositionState, newPosition);
set(isTableCellInEditModeFamilyState(newPosition), true);
document.dispatchEvent(
new CustomEvent(
`edit-mode-change-${newPosition.row}:${newPosition.column}`,
{ detail: true },
),
);
};
},
[currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState],

View File

@ -24,9 +24,23 @@ export const useSetSoftFocusPosition = (recordTableId?: string) => {
set(isSoftFocusOnTableCellFamilyState(currentPosition), false);
document.dispatchEvent(
new CustomEvent(
`soft-focus-move-${currentPosition.row}:${currentPosition.column}`,
{ detail: false },
),
);
set(softFocusPositionState, newPosition);
set(isSoftFocusOnTableCellFamilyState(newPosition), true);
document.dispatchEvent(
new CustomEvent(
`soft-focus-move-${newPosition.row}:${newPosition.column}`,
{ detail: true },
),
);
};
},
[

View File

@ -3,6 +3,7 @@ import { useContext } from 'react';
import { FieldDisplay } from '@/object-record/record-field/components/FieldDisplay';
import { FieldInput } from '@/object-record/record-field/components/FieldInput';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { FieldFocusContextProvider } from '@/object-record/record-field/contexts/FieldFocusContextProvider';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
@ -87,28 +88,24 @@ export const RecordTableCell = ({
};
return (
<RecordTableCellContainer
editHotkeyScope={customHotkeyScope}
editModeContent={
<FieldInput
recordFieldInputdId={`${entityId}-${fieldDefinition?.metadata?.fieldName}`}
onCancel={handleCancel}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onSubmit={handleSubmit}
onTab={handleTab}
isReadOnly={isReadOnly}
/>
}
nonEditModeContent={({ isCellSoftFocused, cellElement }) => (
<FieldDisplay
isCellSoftFocused={isCellSoftFocused}
cellElement={cellElement}
fromTableCell
/>
)}
/>
<FieldFocusContextProvider>
<RecordTableCellContainer
editHotkeyScope={customHotkeyScope}
editModeContent={
<FieldInput
recordFieldInputdId={`${entityId}-${fieldDefinition?.metadata?.fieldName}`}
onCancel={handleCancel}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
onShiftTab={handleShiftTab}
onSubmit={handleSubmit}
onTab={handleTab}
isReadOnly={isReadOnly}
/>
}
nonEditModeContent={<FieldDisplay />}
/>
</FieldFocusContextProvider>
);
};

View File

@ -1,28 +1,15 @@
import React, { ReactElement, useContext, useState } from 'react';
import React, { ReactElement, useContext, useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconArrowUpRight } from 'twenty-ui';
import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButtonIcon';
import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty';
import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { useCurrentTableCellPosition } from '@/object-record/record-table/record-table-cell/hooks/useCurrentCellPosition';
import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell';
import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { isSoftFocusOnTableCellComponentFamilyState } from '@/object-record/record-table/states/isSoftFocusOnTableCellComponentFamilyState';
import { isTableCellInEditModeComponentFamilyState } from '@/object-record/record-table/states/isTableCellInEditModeComponentFamilyState';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId';
import { extractComponentFamilyState } from '@/ui/utilities/state/component-state/utils/extractComponentFamilyState';
import { CellHotkeyScopeContext } from '../../contexts/CellHotkeyScopeContext';
import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { RecordTableCellButton } from './RecordTableCellButton';
import { RecordTableCellDisplayMode } from './RecordTableCellDisplayMode';
import { RecordTableCellEditMode } from './RecordTableCellEditMode';
import { RecordTableCellSoftFocusMode } from './RecordTableCellSoftFocusMode';
@ -51,13 +38,7 @@ const StyledCellBaseContainer = styled.div<{ softFocus: boolean }>`
export type RecordTableCellContainerProps = {
editModeContent: ReactElement;
nonEditModeContent?: ({
isCellSoftFocused,
cellElement,
}: {
isCellSoftFocused: boolean;
cellElement?: HTMLTableCellElement;
}) => ReactElement;
nonEditModeContent: ReactElement;
editHotkeyScope?: HotkeyScope;
transparent?: boolean;
maxContentWidth?: number;
@ -74,91 +55,85 @@ export const RecordTableCellContainer = ({
nonEditModeContent,
editHotkeyScope,
}: RecordTableCellContainerProps) => {
const { columnIndex } = useContext(RecordTableCellContext);
// Used by some fields in ExpandableList as an anchor for the floating element.
// floating-ui mentions that `useState` must be used instead of `useRef`,
// see https://floating-ui.com/docs/useFloating#elements
const [cellElement, setCellElement] = useState<HTMLTableCellElement | null>(
null,
);
const [isCellBaseContainerHovered, setIsCellBaseContainerHovered] =
useState(false);
const { isReadOnly, isSelected, recordId } = useContext(
RecordTableRowContext,
);
const { onMoveSoftFocusToCell, onContextMenu, onCellMouseEnter } =
useContext(RecordTableContext);
const { setIsFocused } = useFieldFocus();
const { isSelected, recordId } = useContext(RecordTableRowContext);
const { onContextMenu, onCellMouseEnter } = useContext(RecordTableContext);
const [isHovered, setIsHovered] = useState(false);
const [hasSoftFocus, setHasSoftFocus] = useState(false);
const [isInEditMode, setIsInEditMode] = useState(false);
const cellPosition = useCurrentTableCellPosition();
const { openTableCell } = useOpenRecordTableCellFromCell();
const tableScopeId = useAvailableScopeIdOrThrow(
RecordTableScopeInternalContext,
getScopeIdOrUndefinedFromComponentId(),
);
const isTableCellInEditModeFamilyState = extractComponentFamilyState(
isTableCellInEditModeComponentFamilyState,
tableScopeId,
);
const isSoftFocusOnTableCellFamilyState = extractComponentFamilyState(
isSoftFocusOnTableCellComponentFamilyState,
tableScopeId,
);
const isCurrentTableCellInEditMode = useRecoilValue(
isTableCellInEditModeFamilyState(cellPosition),
);
const hasSoftFocus = useRecoilValue(
isSoftFocusOnTableCellFamilyState(cellPosition),
);
const isEmpty = useIsFieldEmpty();
const handleButtonClick = () => {
onMoveSoftFocusToCell(cellPosition);
openTableCell();
};
const handleContextMenu = (event: React.MouseEvent) => {
onContextMenu(event, recordId);
};
const handleContainerMouseEnter = () => {
onCellMouseEnter({
cellPosition,
isHovered: isCellBaseContainerHovered,
setIsHovered: setIsCellBaseContainerHovered,
});
if (!hasSoftFocus) {
onCellMouseEnter({
cellPosition,
isHovered,
setIsHovered,
});
}
};
const handleContainerMouseLeave = () => {
setIsCellBaseContainerHovered(false);
setIsHovered(false);
};
const editModeContentOnly = useIsFieldInputOnly();
const handleContainerMouseMove = () => {
handleContainerMouseEnter();
};
const isFirstColumn = columnIndex === 0;
const customButtonIcon = useGetButtonIcon();
const buttonIcon = isFirstColumn ? IconArrowUpRight : customButtonIcon;
useEffect(() => {
const customEventListener = (event: any) => {
const newHasSoftFocus = event.detail;
const showButton =
!!buttonIcon &&
hasSoftFocus &&
!isCurrentTableCellInEditMode &&
!editModeContentOnly &&
(!isFirstColumn || !isEmpty) &&
!isReadOnly;
setHasSoftFocus(newHasSoftFocus);
setIsFocused(newHasSoftFocus);
};
document.addEventListener(
`soft-focus-move-${cellPosition.row}:${cellPosition.column}`,
customEventListener,
);
return () => {
document.removeEventListener(
`soft-focus-move-${cellPosition.row}:${cellPosition.column}`,
customEventListener,
);
};
}, [cellPosition, setIsFocused]);
useEffect(() => {
const customEventListener = (event: any) => {
const newIsInEditMode = event.detail;
setIsInEditMode(newIsInEditMode);
};
document.addEventListener(
`edit-mode-change-${cellPosition.row}:${cellPosition.column}`,
customEventListener,
);
return () => {
document.removeEventListener(
`edit-mode-change-${cellPosition.row}:${cellPosition.column}`,
customEventListener,
);
};
}, [cellPosition]);
return (
<StyledTd
ref={setCellElement}
isSelected={isSelected}
onContextMenu={handleContextMenu}
isInEditMode={isCurrentTableCellInEditMode}
isInEditMode={isInEditMode}
>
<CellHotkeyScopeContext.Provider
value={editHotkeyScope ?? DEFAULT_CELL_SCOPE}
@ -166,46 +141,22 @@ export const RecordTableCellContainer = ({
<StyledCellBaseContainer
onMouseEnter={handleContainerMouseEnter}
onMouseLeave={handleContainerMouseLeave}
onMouseMove={handleContainerMouseMove}
softFocus={hasSoftFocus}
>
{isCurrentTableCellInEditMode ? (
{isInEditMode ? (
<RecordTableCellEditMode>{editModeContent}</RecordTableCellEditMode>
) : hasSoftFocus ? (
<>
<RecordTableCellSoftFocusMode>
{editModeContentOnly
? editModeContent
: nonEditModeContent?.({
isCellSoftFocused: true,
cellElement: cellElement ?? undefined,
})}
</RecordTableCellSoftFocusMode>
{showButton && (
<RecordTableCellButton
onClick={handleButtonClick}
Icon={buttonIcon}
/>
)}
<RecordTableCellSoftFocusMode
editModeContent={editModeContent}
nonEditModeContent={nonEditModeContent}
/>
</>
) : (
<>
{!isEmpty && (
<RecordTableCellDisplayMode>
{editModeContentOnly
? editModeContent
: nonEditModeContent?.({
isCellSoftFocused: false,
cellElement: cellElement ?? undefined,
})}
</RecordTableCellDisplayMode>
)}
{showButton && (
<RecordTableCellButton
onClick={handleButtonClick}
Icon={buttonIcon}
/>
)}
</>
<RecordTableCellDisplayMode>
{nonEditModeContent}
</RecordTableCellDisplayMode>
)}
</StyledCellBaseContainer>
</CellHotkeyScopeContext.Provider>

View File

@ -1,31 +1,18 @@
import { useContext } from 'react';
import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { useCurrentTableCellPosition } from '@/object-record/record-table/record-table-cell/hooks/useCurrentCellPosition';
import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell';
import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty';
import { RecordTableCellDisplayContainer } from './RecordTableCellDisplayContainer';
export const RecordTableCellDisplayMode = ({
children,
}: React.PropsWithChildren<unknown>) => {
const cellPosition = useCurrentTableCellPosition();
const { onMoveSoftFocusToCell } = useContext(RecordTableContext);
const { openTableCell } = useOpenRecordTableCellFromCell();
const isEmpty = useIsFieldEmpty();
const isFieldInputOnly = useIsFieldInputOnly();
const handleClick = () => {
onMoveSoftFocusToCell(cellPosition);
if (!isFieldInputOnly) {
openTableCell();
}
};
if (isEmpty) {
return <></>;
}
return (
<RecordTableCellDisplayContainer onClick={handleClick}>
<RecordTableCellDisplayContainer>
{children}
</RecordTableCellDisplayContainer>
);

View File

@ -1,11 +1,17 @@
import { PropsWithChildren, useEffect, useRef } from 'react';
import { ReactElement, useContext, useEffect, useRef } from 'react';
import isEmpty from 'lodash.isempty';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { IconArrowUpRight } from 'twenty-ui';
import { useClearField } from '@/object-record/record-field/hooks/useClearField';
import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButtonIcon';
import { useIsFieldClearable } from '@/object-record/record-field/hooks/useIsFieldClearable';
import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly';
import { useToggleEditOnlyInput } from '@/object-record/record-field/hooks/useToggleEditOnlyInput';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { RecordTableCellButton } from '@/object-record/record-table/record-table-cell/components/RecordTableCellButton';
import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell';
import { isSoftFocusUsingMouseState } from '@/object-record/record-table/states/isSoftFocusUsingMouseState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
@ -15,13 +21,23 @@ import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { RecordTableCellDisplayContainer } from './RecordTableCellDisplayContainer';
type RecordTableCellSoftFocusModeProps = PropsWithChildren<unknown>;
type RecordTableCellSoftFocusModeProps = {
editModeContent: ReactElement;
nonEditModeContent: ReactElement;
};
export const RecordTableCellSoftFocusMode = ({
children,
editModeContent,
nonEditModeContent,
}: RecordTableCellSoftFocusModeProps) => {
const { columnIndex } = useContext(RecordTableCellContext);
const { isReadOnly } = useContext(RecordTableRowContext);
const { openTableCell } = useOpenRecordTableCellFromCell();
const editModeContentOnly = useIsFieldInputOnly();
const isFieldInputOnly = useIsFieldInputOnly();
const isFieldClearable = useIsFieldClearable();
@ -98,12 +114,27 @@ export const RecordTableCellSoftFocusMode = ({
}
};
const isFirstColumn = columnIndex === 0;
const customButtonIcon = useGetButtonIcon();
const buttonIcon = isFirstColumn ? IconArrowUpRight : customButtonIcon;
const showButton =
!!buttonIcon &&
!editModeContentOnly &&
(!isFirstColumn || !isEmpty) &&
!isReadOnly;
return (
<RecordTableCellDisplayContainer
onClick={handleClick}
scrollRef={scrollRef}
>
{children}
</RecordTableCellDisplayContainer>
<>
<RecordTableCellDisplayContainer
onClick={handleClick}
scrollRef={scrollRef}
>
{editModeContentOnly ? editModeContent : nonEditModeContent}
</RecordTableCellDisplayContainer>
{showButton && (
<RecordTableCellButton onClick={handleClick} Icon={buttonIcon} />
)}
</>
);
};

View File

@ -1,10 +1,7 @@
import { MouseEventHandler, useMemo } from 'react';
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
import {
ExpandableList,
ExpandableListProps,
} from '@/ui/layout/expandable-list/components/ExpandableList';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink';
import {
LinkType,
@ -15,18 +12,12 @@ import { isDefined } from '~/utils/isDefined';
import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl';
import { getUrlHostName } from '~/utils/url/getUrlHostName';
type LinksDisplayProps = Pick<
ExpandableListProps,
'isChipCountDisplayed' | 'withExpandedListBorder'
> & {
type LinksDisplayProps = {
value?: FieldLinksValue;
isFocused?: boolean;
};
export const LinksDisplay = ({
isChipCountDisplayed,
withExpandedListBorder,
value,
}: LinksDisplayProps) => {
export const LinksDisplay = ({ value, isFocused }: LinksDisplayProps) => {
const links = useMemo(
() =>
[
@ -53,10 +44,7 @@ export const LinksDisplay = ({
const handleClick: MouseEventHandler = (event) => event.stopPropagation();
return (
<ExpandableList
isChipCountDisplayed={isChipCountDisplayed}
withExpandedListBorder={withExpandedListBorder}
>
<ExpandableList isChipCountDisplayed={isFocused}>
{links.map(({ url, label, type }, index) =>
type === LinkType.LinkedIn || type === LinkType.Twitter ? (
<SocialLink key={index} href={url} onClick={handleClick} type={type}>

View File

@ -0,0 +1 @@
export const FIELD_EDIT_BUTTON_WIDTH = 28;

View File

@ -9,6 +9,8 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer';
import { findOneRecordForShowPageOperationSignatureFactory } from '@/object-record/record-show/graphql/operations/factories/findOneRecordForShowPageOperationSignatureFactory';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { PageBody } from '@/ui/layout/page/PageBody';
import { PageContainer } from '@/ui/layout/page/PageContainer';
@ -64,7 +66,10 @@ export const RecordShowPage = () => {
});
useEffect(() => {
if (!record) return;
if (!record) {
return;
}
setEntityFields(record);
}, [record, setEntityFields]);
@ -102,40 +107,43 @@ export const RecordShowPage = () => {
: capitalize(objectNameSingular);
return (
<PageContainer>
<PageTitle title={pageTitle} />
<PageHeader
title={pageName ?? ''}
hasBackButton
Icon={headerIcon}
loading={loading}
>
<>
<PageFavoriteButton
isFavorite={isFavorite}
onClick={handleFavoriteButtonClick}
/>
<ShowPageAddButton
key="add"
activityTargetObject={{
id: record?.id ?? '0',
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
/>
<ShowPageMoreButton
key="more"
recordId={record?.id ?? '0'}
objectNameSingular={objectNameSingular}
/>
</>
</PageHeader>
<PageBody>
<RecordShowContainer
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
<RecordFieldValueSelectorContextProvider>
<RecordValueSetterEffect recordId={objectRecordId} />
<PageContainer>
<PageTitle title={pageTitle} />
<PageHeader
title={pageName ?? ''}
hasBackButton
Icon={headerIcon}
loading={loading}
/>
</PageBody>
</PageContainer>
>
<>
<PageFavoriteButton
isFavorite={isFavorite}
onClick={handleFavoriteButtonClick}
/>
<ShowPageAddButton
key="add"
activityTargetObject={{
id: record?.id ?? '0',
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
/>
<ShowPageMoreButton
key="more"
recordId={record?.id ?? '0'}
objectNameSingular={objectNameSingular}
/>
</>
</PageHeader>
<PageBody>
<RecordShowContainer
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
loading={loading}
/>
</PageBody>
</PageContainer>
</RecordFieldValueSelectorContextProvider>
);
};

View File

@ -4,7 +4,7 @@ import { useRecoilState } from 'recoil';
import { ProfilerWrapper } from '~/testing/profiling/components/ProfilerWrapper';
import { ProfilingQueueEffect } from '~/testing/profiling/components/ProfilingQueueEffect';
import { ProfilingReporter } from '~/testing/profiling/components/ProfilingReporter';
import { currentProfilingRunIndexState } from '~/testing/profiling/states/currentProfilingRunState';
import { currentProfilingRunIndexState } from '~/testing/profiling/states/currentProfilingRunIndexState';
import { profilingSessionRunsState } from '~/testing/profiling/states/profilingSessionRunsState';
import { profilingSessionStatusState } from '~/testing/profiling/states/profilingSessionStatusState';
import { getTestArray } from '~/testing/profiling/utils/getTestArray';
@ -12,6 +12,7 @@ import { getTestArray } from '~/testing/profiling/utils/getTestArray';
export const ProfilerDecorator: Decorator = (Story, { id, parameters }) => {
const numberOfTests = parameters.numberOfTests ?? 2;
const numberOfRuns = parameters.numberOfRuns ?? 2;
const warmUpRounds = parameters.warmUpRounds ?? 5;
const [currentProfilingRunIndex] = useRecoilState(
currentProfilingRunIndexState,
@ -31,6 +32,7 @@ export const ProfilerDecorator: Decorator = (Story, { id, parameters }) => {
<ProfilingQueueEffect
numberOfRuns={numberOfRuns}
numberOfTestsPerRun={numberOfTests}
warmUpRounds={warmUpRounds}
profilingId={id}
/>
<div>

View File

@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { TIME_BETWEEN_TEST_RUNS_IN_MS } from '~/testing/profiling/constants/TimeBetweenTestRunsInMs';
import { currentProfilingRunIndexState } from '~/testing/profiling/states/currentProfilingRunState';
import { currentProfilingRunIndexState } from '~/testing/profiling/states/currentProfilingRunIndexState';
import { profilingQueueState } from '~/testing/profiling/states/profilingQueueState';
import { profilingSessionRunsState } from '~/testing/profiling/states/profilingSessionRunsState';
import { profilingSessionStatusState } from '~/testing/profiling/states/profilingSessionStatusState';
@ -12,10 +12,12 @@ export const ProfilingQueueEffect = ({
profilingId,
numberOfTestsPerRun,
numberOfRuns,
warmUpRounds,
}: {
profilingId: string;
numberOfTestsPerRun: number;
numberOfRuns: number;
warmUpRounds: number;
}) => {
const [currentProfilingRunIndex, setCurrentProfilingRunIndex] =
useRecoilState(currentProfilingRunIndexState);
@ -38,9 +40,9 @@ export const ProfilingQueueEffect = ({
setCurrentProfilingRunIndex(0);
const newTestRuns = [
'warm-up-1',
'warm-up-2',
'warm-up-3',
...[
...Array.from({ length: warmUpRounds }, (_, i) => `warm-up-${i}`),
],
...[
...Array.from({ length: numberOfRuns }, (_, i) => `real-run-${i}`),
],
@ -76,9 +78,13 @@ export const ProfilingQueueEffect = ({
return;
}
await new Promise((resolve) =>
setTimeout(resolve, TIME_BETWEEN_TEST_RUNS_IN_MS),
);
const timeInMs = profilingSessionRuns[
currentProfilingRunIndex
].startsWith('warm-up')
? TIME_BETWEEN_TEST_RUNS_IN_MS * 2
: TIME_BETWEEN_TEST_RUNS_IN_MS;
await new Promise((resolve) => setTimeout(resolve, timeInMs));
const nextIndex = currentProfilingRunIndex + 1;
@ -109,6 +115,7 @@ export const ProfilingQueueEffect = ({
profilingSessionRuns,
setProfilingSessionRuns,
numberOfRuns,
warmUpRounds,
]);
return <></>;

View File

@ -3,7 +3,9 @@ import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { PROFILING_REPORTER_DIV_ID } from '~/testing/profiling/constants/ProfilingReporterDivId';
import { currentProfilingRunIndexState } from '~/testing/profiling/states/currentProfilingRunIndexState';
import { profilingSessionDataPointsState } from '~/testing/profiling/states/profilingSessionDataPointsState';
import { profilingSessionStatusState } from '~/testing/profiling/states/profilingSessionStatusState';
import { computeProfilingReport } from '~/testing/profiling/utils/computeProfilingReport';
const StyledTable = styled.table`
@ -24,6 +26,12 @@ export const ProfilingReporter = () => {
profilingSessionDataPointsState,
);
const [currentProfilingRunIndex] = useRecoilState(
currentProfilingRunIndexState,
);
const [profilingSessionStatus] = useRecoilState(profilingSessionStatusState);
const profilingReport = useMemo(
() => computeProfilingReport(profilingSessionDataPoints),
[profilingSessionDataPoints],
@ -34,6 +42,10 @@ export const ProfilingReporter = () => {
data-profiling-report={JSON.stringify(profilingReport)}
id={PROFILING_REPORTER_DIV_ID}
>
<h2>Profiling report</h2>
<div>
Run #{currentProfilingRunIndex} - Status {profilingSessionStatus}
</div>
<StyledTable>
<thead>
<tr>
@ -46,6 +58,7 @@ export const ProfilingReporter = () => {
<th>P95</th>
<th>P99</th>
<th>Max</th>
<th>Variance</th>
</tr>
</thead>
<tbody>
@ -59,6 +72,9 @@ export const ProfilingReporter = () => {
<td>{Math.round(profilingReport.total.p95 * 1000) / 1000}ms</td>
<td>{Math.round(profilingReport.total.p99 * 1000) / 1000}ms</td>
<td>{Math.round(profilingReport.total.max * 1000) / 1000}ms</td>
<td>
{Math.round(profilingReport.total.variance * 1000000) / 1000000}
</td>
</tr>
{Object.entries(profilingReport.runs).map(([runName, report]) => (
<tr key={runName}>
@ -71,6 +87,7 @@ export const ProfilingReporter = () => {
<td>{Math.round(report.p95 * 1000) / 1000}ms</td>
<td>{Math.round(report.p99 * 1000) / 1000}ms</td>
<td>{Math.round(report.max * 1000) / 1000}ms</td>
<td>{Math.round(report.variance * 1000000) / 1000000}</td>
</tr>
))}
</tbody>

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const currentProfilingRunIndexState = atom<number>({
key: 'currentProfilingRunIndexState',
default: 0,
});

View File

@ -10,6 +10,7 @@ export type ProfilingReportItem = {
p99: number;
min: number;
max: number;
variance: number;
};
export type ProfilingReport = {

View File

@ -1,7 +1,10 @@
import { ProfilingDataPoint } from '~/testing/profiling/types/ProfilingDataPoint';
import { ProfilingReport } from '~/testing/profiling/types/ProfilingReportByRun';
export const computeProfilingReport = (dataPoints: ProfilingDataPoint[]) => {
export const computeProfilingReport = (
dataPoints: ProfilingDataPoint[],
varianceThreshold?: number,
) => {
const profilingReport = { total: {}, runs: {} } as ProfilingReport;
for (const dataPoint of dataPoints) {
@ -27,8 +30,9 @@ export const computeProfilingReport = (dataPoints: ProfilingDataPoint[]) => {
const numberOfIds = ids.length;
profilingReport.runs[runName].average =
profilingReport.runs[runName].sum / numberOfIds;
const mean = profilingReport.runs[runName].sum / numberOfIds;
profilingReport.runs[runName].average = mean;
profilingReport.runs[runName].min = Math.min(
...Object.values(profilingReport.runs[runName].sumById),
@ -38,6 +42,14 @@ export const computeProfilingReport = (dataPoints: ProfilingDataPoint[]) => {
...Object.values(profilingReport.runs[runName].sumById),
);
const intermediaryValuesForVariance = valuesUnsorted.map((value) =>
Math.pow(value - mean, 2),
);
profilingReport.runs[runName].variance =
intermediaryValuesForVariance.reduce((acc, curr) => acc + curr) /
numberOfIds;
const p50Index = Math.floor(numberOfIds * 0.5);
const p80Index = Math.floor(numberOfIds * 0.8);
const p90Index = Math.floor(numberOfIds * 0.9);
@ -55,9 +67,9 @@ export const computeProfilingReport = (dataPoints: ProfilingDataPoint[]) => {
runName.startsWith('real-run'),
);
const runsForTotal = runNamesForTotal.map(
(runName) => profilingReport.runs[runName],
);
const runsForTotal = runNamesForTotal
.map((runName) => profilingReport.runs[runName])
.filter((run) => run.variance < (varianceThreshold ?? 0.2));
profilingReport.total = {
sum: Object.values(runsForTotal).reduce((acc, run) => acc + run.sum, 0),
@ -82,6 +94,9 @@ export const computeProfilingReport = (dataPoints: ProfilingDataPoint[]) => {
Object.values(runsForTotal).reduce((acc, run) => acc + run.p99, 0) /
Object.keys(runsForTotal).length,
dataPointCount: dataPoints.length,
variance:
runsForTotal.reduce((acc, run) => acc + run.variance, 0) /
runsForTotal.length,
};
return profilingReport;

View File

@ -11,19 +11,21 @@ export const getProfilingStory = ({
averageThresholdInMs,
numberOfRuns,
numberOfTestsPerRun,
warmUpRounds,
}: {
componentName: string;
p95ThresholdInMs?: number;
averageThresholdInMs: number;
numberOfRuns: number;
numberOfTestsPerRun: number;
warmUpRounds?: number;
}): StoryObj<any> => ({
decorators: [ProfilerDecorator],
parameters: {
numberOfRuns,
numberOfTests: numberOfTestsPerRun,
componentName,
chromatic: { disableSnapshot: true },
warmUpRounds,
},
play: async ({ canvasElement }) => {
await findByTestId(

View File

@ -46630,6 +46630,7 @@ __metadata:
type-fest: "npm:4.10.1"
typeorm: "npm:^0.3.17"
typescript: "npm:5.3.3"
use-context-selector: "npm:^2.0.0"
use-debounce: "npm:^10.0.0"
uuid: "npm:^9.0.0"
vite: "npm:^5.0.0"
@ -47659,6 +47660,16 @@ __metadata:
languageName: node
linkType: hard
"use-context-selector@npm:^2.0.0":
version: 2.0.0
resolution: "use-context-selector@npm:2.0.0"
peerDependencies:
react: ">=18.0.0"
scheduler: ">=0.19.0"
checksum: 4eb6054ab8996ae8b3f87f9d102e576066e5a8b9db5db2c891128ae920bd64bcdcb4e93a13bc99658ef16280929a8331fc8ac45177f4acd716c425b1bc31135a
languageName: node
linkType: hard
"use-debounce@npm:^10.0.0":
version: 10.0.0
resolution: "use-debounce@npm:10.0.0"