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", "tsup": "^8.0.1",
"type-fest": "4.10.1", "type-fest": "4.10.1",
"typeorm": "^0.3.17", "typeorm": "^0.3.17",
"use-context-selector": "^2.0.0",
"use-debounce": "^10.0.0", "use-debounce": "^10.0.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"vite-tsconfig-paths": "^4.2.1", "vite-tsconfig-paths": "^4.2.1",

View File

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

View File

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

View File

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

View File

@ -37,17 +37,7 @@ import { isFieldSelect } from '../types/guards/isFieldSelect';
import { isFieldText } from '../types/guards/isFieldText'; import { isFieldText } from '../types/guards/isFieldText';
import { isFieldUuid } from '../types/guards/isFieldUuid'; import { isFieldUuid } from '../types/guards/isFieldUuid';
type FieldDisplayProps = { export const FieldDisplay = () => {
isCellSoftFocused?: boolean;
cellElement?: HTMLElement;
fromTableCell?: boolean;
};
export const FieldDisplay = ({
isCellSoftFocused,
cellElement,
fromTableCell,
}: FieldDisplayProps) => {
const { fieldDefinition, isLabelIdentifier } = useContext(FieldContext); const { fieldDefinition, isLabelIdentifier } = useContext(FieldContext);
const isChipDisplay = const isChipDisplay =
@ -78,10 +68,7 @@ export const FieldDisplay = ({
) : isFieldLink(fieldDefinition) ? ( ) : isFieldLink(fieldDefinition) ? (
<LinkFieldDisplay /> <LinkFieldDisplay />
) : isFieldLinks(fieldDefinition) ? ( ) : isFieldLinks(fieldDefinition) ? (
<LinksFieldDisplay <LinksFieldDisplay />
isCellSoftFocused={isCellSoftFocused}
fromTableCell={fromTableCell}
/>
) : isFieldCurrency(fieldDefinition) ? ( ) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldDisplay /> <CurrencyFieldDisplay />
) : isFieldFullName(fieldDefinition) ? ( ) : isFieldFullName(fieldDefinition) ? (
@ -89,11 +76,7 @@ export const FieldDisplay = ({
) : isFieldSelect(fieldDefinition) ? ( ) : isFieldSelect(fieldDefinition) ? (
<SelectFieldDisplay /> <SelectFieldDisplay />
) : isFieldMultiSelect(fieldDefinition) ? ( ) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldDisplay <MultiSelectFieldDisplay />
isCellSoftFocused={isCellSoftFocused}
cellElement={cellElement}
fromTableCell={fromTableCell}
/>
) : isFieldAddress(fieldDefinition) ? ( ) : isFieldAddress(fieldDefinition) ? (
<AddressFieldDisplay /> <AddressFieldDisplay />
) : isFieldRawJson(fieldDefinition) ? ( ) : 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 { 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 { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon';
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 { FieldContext } from '../contexts/FieldContext'; 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 => { export const useGetButtonIcon = (): IconComponent | undefined => {
const { fieldDefinition } = useContext(FieldContext); const { fieldDefinition } = useContext(FieldContext);
if (isUndefinedOrNull(fieldDefinition)) return undefined; return getFieldButtonIcon(fieldDefinition);
if (
isFieldLink(fieldDefinition) ||
isFieldEmail(fieldDefinition) ||
isFieldPhone(fieldDefinition) ||
isFieldDisplayedAsPhone(fieldDefinition) ||
isFieldMultiSelect(fieldDefinition) ||
(isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationObjectMetadataNameSingular !==
'workspaceMember') ||
isFieldLinks(fieldDefinition)
) {
return IconPencil;
}
}; };

View File

@ -1,18 +1,16 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty'; 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'; import { FieldContext } from '../contexts/FieldContext';
export const useIsFieldEmpty = () => { export const useIsFieldEmpty = () => {
const { entityId, fieldDefinition } = useContext(FieldContext); const { entityId, fieldDefinition } = useContext(FieldContext);
const fieldValue = useRecoilValue(
recordStoreFamilySelector({ const fieldValue = useRecordFieldValue(
fieldName: fieldDefinition.metadata.fieldName, entityId,
recordId: entityId, fieldDefinition.metadata.fieldName,
}),
); );
return isFieldValueEmpty({ 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 { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
import { LinksDisplay } from '@/ui/field/display/components/LinksDisplay'; import { LinksDisplay } from '@/ui/field/display/components/LinksDisplay';
type LinksFieldDisplayProps = { export const LinksFieldDisplay = () => {
isCellSoftFocused?: boolean;
fromTableCell?: boolean;
};
export const LinksFieldDisplay = ({
isCellSoftFocused,
fromTableCell,
}: LinksFieldDisplayProps) => {
const { fieldValue } = useLinksField(); const { fieldValue } = useLinksField();
return ( const { isFocused } = useFieldFocus();
<LinksDisplay
value={fieldValue} return <LinksDisplay value={fieldValue} isFocused={isFocused} />;
isChipCountDisplayed={isCellSoftFocused}
withExpandedListBorder={fromTableCell}
/>
);
}; };

View File

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

View File

@ -1,16 +1,16 @@
import { RecordChip } from '@/object-record/components/RecordChip'; 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 { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useRelationField } from '../../hooks/useRelationField';
export const RelationFieldDisplay = () => { export const RelationFieldDisplay = () => {
const { fieldValue, fieldDefinition, maxWidth } = useRelationField(); const { fieldValue, fieldDefinition, maxWidth } = useRelationFieldDisplay();
if ( if (
!fieldValue || !fieldValue ||
!fieldDefinition?.metadata.relationObjectMetadataNameSingular !fieldDefinition?.metadata.relationObjectMetadataNameSingular
) ) {
return null; return null;
}
return ( return (
<RecordChip <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 { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldMetadata } from './FieldMetadata'; import { FieldMetadata } from './FieldMetadata';
@ -24,4 +26,5 @@ export type FieldDefinition<T extends FieldMetadata> = {
metadata: T; metadata: T;
infoTooltipContent?: string; infoTooltipContent?: string;
defaultValue?: any; 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 { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; 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 { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider'; import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
import { ViewBar } from '@/views/components/ViewBar'; import { ViewBar } from '@/views/components/ViewBar';
@ -105,74 +106,78 @@ export const RecordIndexContainer = ({
return ( return (
<StyledContainer> <StyledContainer>
<SpreadsheetImportProvider> <RecordFieldValueSelectorContextProvider>
<ViewBar <SpreadsheetImportProvider>
viewBarId={recordIndexId} <ViewBar
optionsDropdownButton={ viewBarId={recordIndexId}
<RecordIndexOptionsDropdown optionsDropdownButton={
recordIndexId={recordIndexId} <RecordIndexOptionsDropdown
objectNameSingular={objectNameSingular} recordIndexId={recordIndexId}
viewType={recordIndexViewType ?? ViewType.Table} objectNameSingular={objectNameSingular}
/> viewType={recordIndexViewType ?? ViewType.Table}
} />
onCurrentViewChange={(view) => {
if (!view) {
return;
} }
onCurrentViewChange={(view) => {
if (!view) {
return;
}
onViewFieldsChange(view.viewFields); onViewFieldsChange(view.viewFields);
setTableFilters( setTableFilters(
mapViewFiltersToFilters(view.viewFilters, filterDefinitions), mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
); );
setRecordIndexFilters( setRecordIndexFilters(
mapViewFiltersToFilters(view.viewFilters, filterDefinitions), mapViewFiltersToFilters(view.viewFilters, filterDefinitions),
); );
setTableSorts(mapViewSortsToSorts(view.viewSorts, sortDefinitions)); setTableSorts(
setRecordIndexSorts( mapViewSortsToSorts(view.viewSorts, sortDefinitions),
mapViewSortsToSorts(view.viewSorts, sortDefinitions), );
); setRecordIndexSorts(
setRecordIndexViewType(view.type); mapViewSortsToSorts(view.viewSorts, sortDefinitions),
setRecordIndexViewKanbanFieldMetadataIdState( );
view.kanbanFieldMetadataId, setRecordIndexViewType(view.type);
); setRecordIndexViewKanbanFieldMetadataIdState(
setRecordIndexIsCompactModeActive(view.isCompact); view.kanbanFieldMetadataId,
}} );
/> setRecordIndexIsCompactModeActive(view.isCompact);
<RecordIndexViewBarEffect }}
objectNamePlural={objectNamePlural}
viewBarId={recordIndexId}
/>
</SpreadsheetImportProvider>
{recordIndexViewType === ViewType.Table && (
<>
<RecordIndexTableContainer
recordTableId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
createRecord={createRecord}
/> />
<RecordIndexTableContainerEffect <RecordIndexViewBarEffect
objectNameSingular={objectNameSingular} objectNamePlural={objectNamePlural}
recordTableId={recordIndexId}
viewBarId={recordIndexId} viewBarId={recordIndexId}
/> />
</> </SpreadsheetImportProvider>
)} {recordIndexViewType === ViewType.Table && (
{recordIndexViewType === ViewType.Kanban && ( <>
<> <RecordIndexTableContainer
<RecordIndexBoardContainer recordTableId={recordIndexId}
recordBoardId={recordIndexId} viewBarId={recordIndexId}
viewBarId={recordIndexId} objectNameSingular={objectNameSingular}
objectNameSingular={objectNameSingular} createRecord={createRecord}
createRecord={createRecord} />
/> <RecordIndexTableContainerEffect
<RecordIndexBoardContainerEffect objectNameSingular={objectNameSingular}
objectNameSingular={objectNameSingular} recordTableId={recordIndexId}
recordBoardId={recordIndexId} viewBarId={recordIndexId}
viewBarId={recordIndexId} />
/> </>
</> )}
)} {recordIndexViewType === ViewType.Kanban && (
<>
<RecordIndexBoardContainer
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
createRecord={createRecord}
/>
<RecordIndexBoardContainerEffect
objectNameSingular={objectNameSingular}
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
/>
</>
)}
</RecordFieldValueSelectorContextProvider>
</StyledContainer> </StyledContainer>
); );
}; };

View File

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

View File

@ -1,20 +1,15 @@
import React, { useContext, useState } from 'react'; import React, { ReactElement, useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { Tooltip } from 'react-tooltip'; import { Tooltip } from 'react-tooltip';
import { css, useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { IconComponent } from 'twenty-ui'; import { IconComponent } from 'twenty-ui';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; 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 { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; 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` const StyledIconContainer = styled.div`
align-items: center; align-items: center;
color: ${({ theme }) => theme.font.color.tertiary}; color: ${({ theme }) => theme.font.color.tertiary};
@ -48,18 +43,6 @@ const StyledLabelContainer = styled.div<{ width?: number }>`
width: ${({ width }) => width}px; 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` const StyledInlineCellBaseContainer = styled.div`
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
@ -82,41 +65,20 @@ const StyledTooltip = styled(Tooltip)`
padding: ${({ theme }) => theme.spacing(2)}; padding: ${({ theme }) => theme.spacing(2)};
`; `;
const StyledSkeletonDiv = styled.div` export const StyledSkeletonDiv = styled.div`
height: 24px; height: 24px;
`; `;
const StyledInlineCellSkeletonLoader = () => { export type RecordInlineCellContainerProps = {
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 = {
readonly?: boolean; readonly?: boolean;
IconLabel?: IconComponent; IconLabel?: IconComponent;
label?: string; label?: string;
labelWidth?: number; labelWidth?: number;
showLabel?: boolean; showLabel?: boolean;
buttonIcon?: IconComponent; buttonIcon?: IconComponent;
editModeContent?: React.ReactNode; editModeContent?: ReactElement;
editModeContentOnly?: boolean; editModeContentOnly?: boolean;
displayModeContent: ({ displayModeContent: ReactElement;
isCellSoftFocused,
cellElement,
}: {
isCellSoftFocused: boolean;
cellElement?: HTMLDivElement;
}) => React.ReactNode;
customEditHotkeyScope?: HotkeyScope; customEditHotkeyScope?: HotkeyScope;
isDisplayModeContentEmpty?: boolean; isDisplayModeContentEmpty?: boolean;
isDisplayModeFixHeight?: boolean; isDisplayModeFixHeight?: boolean;
@ -141,85 +103,24 @@ export const RecordInlineCellContainer = ({
loading = false, loading = false,
}: RecordInlineCellContainerProps) => { }: RecordInlineCellContainerProps) => {
const { entityId, fieldDefinition } = useContext(FieldContext); 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`, const { setIsFocused } = useFieldFocus();
// 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 handleContainerMouseEnter = () => { const handleContainerMouseEnter = () => {
if (!readonly) { if (!readonly) {
setIsHovered(true); setIsFocused(true);
} }
setIsCellSoftFocused(true);
}; };
const handleContainerMouseLeave = () => { const handleContainerMouseLeave = () => {
if (!readonly) { if (!readonly) {
setIsHovered(false); setIsFocused(false);
}
setIsCellSoftFocused(false);
};
const { isInlineCellInEditMode, openInlineCell } = useInlineCell();
const handleDisplayModeClick = () => {
if (!readonly && !editModeContentOnly) {
openInlineCell(customEditHotkeyScope);
} }
}; };
const showEditButton =
buttonIcon &&
!isInlineCellInEditMode &&
isHovered &&
!editModeContentOnly &&
!isDisplayModeContentEmpty;
const theme = useTheme(); const theme = useTheme();
const labelId = `label-${entityId}-${fieldDefinition?.metadata?.fieldName}`; 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 ( return (
<StyledInlineCellBaseContainer <StyledInlineCellBaseContainer
onMouseEnter={handleContainerMouseEnter} onMouseEnter={handleContainerMouseEnter}
@ -250,8 +151,23 @@ export const RecordInlineCellContainer = ({
)} )}
</StyledLabelAndIconContainer> </StyledLabelAndIconContainer>
)} )}
<StyledValueContainer ref={setCellElement}> <StyledValueContainer>
{showContent()} <RecordInlineCellValue
{...{
displayModeContent,
customEditHotkeyScope,
disableHoverEffect,
editModeContent,
editModeContentOnly,
isDisplayModeContentEmpty,
isDisplayModeFixHeight,
buttonIcon,
label,
loading,
readonly,
showLabel,
}}
/>
</StyledValueContainer> </StyledValueContainer>
</StyledInlineCellBaseContainer> </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 { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { RecordDetailRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsListItem'; 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 { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported'; import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported';
@ -187,6 +188,7 @@ export const RecordDetailRelationRecordsListItem = ({
return ( return (
<> <>
<RecordValueSetterEffect recordId={relationRecord.id} />
<StyledListItem isDropdownOpen={isDropdownOpen}> <StyledListItem isDropdownOpen={isDropdownOpen}>
<RecordChip <RecordChip
record={relationRecord} 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 { useRecoilValue } from 'recoil';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; 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 { RecordTableCellFieldContextWrapper } from '@/object-record/record-table/components/RecordTableCellFieldContextWrapper';
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext'; import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
@ -52,6 +53,7 @@ export const RecordTableRow = ({ recordId, rowIndex }: RecordTableRowProps) => {
isReadOnly: objectMetadataItem.isRemote ?? false, isReadOnly: objectMetadataItem.isRemote ?? false,
}} }}
> >
<RecordValueSetterEffect recordId={recordId} />
<tr <tr
ref={elementRef} ref={elementRef}
data-testid={`row-id-${recordId}`} 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), isTableCellInEditModeFamilyState(currentTableCellInEditModePosition),
false, false,
); );
document.dispatchEvent(
new CustomEvent(
`edit-mode-change-${currentTableCellInEditModePosition.row}:${currentTableCellInEditModePosition.column}`,
{ detail: false },
),
);
}; };
}, },
[currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState], [currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState],

View File

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

View File

@ -24,9 +24,23 @@ export const useSetSoftFocusPosition = (recordTableId?: string) => {
set(isSoftFocusOnTableCellFamilyState(currentPosition), false); set(isSoftFocusOnTableCellFamilyState(currentPosition), false);
document.dispatchEvent(
new CustomEvent(
`soft-focus-move-${currentPosition.row}:${currentPosition.column}`,
{ detail: false },
),
);
set(softFocusPositionState, newPosition); set(softFocusPositionState, newPosition);
set(isSoftFocusOnTableCellFamilyState(newPosition), true); 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 { FieldDisplay } from '@/object-record/record-field/components/FieldDisplay';
import { FieldInput } from '@/object-record/record-field/components/FieldInput'; import { FieldInput } from '@/object-record/record-field/components/FieldInput';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; 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 { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
@ -87,28 +88,24 @@ export const RecordTableCell = ({
}; };
return ( return (
<RecordTableCellContainer <FieldFocusContextProvider>
editHotkeyScope={customHotkeyScope} <RecordTableCellContainer
editModeContent={ editHotkeyScope={customHotkeyScope}
<FieldInput editModeContent={
recordFieldInputdId={`${entityId}-${fieldDefinition?.metadata?.fieldName}`} <FieldInput
onCancel={handleCancel} recordFieldInputdId={`${entityId}-${fieldDefinition?.metadata?.fieldName}`}
onClickOutside={handleClickOutside} onCancel={handleCancel}
onEnter={handleEnter} onClickOutside={handleClickOutside}
onEscape={handleEscape} onEnter={handleEnter}
onShiftTab={handleShiftTab} onEscape={handleEscape}
onSubmit={handleSubmit} onShiftTab={handleShiftTab}
onTab={handleTab} onSubmit={handleSubmit}
isReadOnly={isReadOnly} onTab={handleTab}
/> isReadOnly={isReadOnly}
} />
nonEditModeContent={({ isCellSoftFocused, cellElement }) => ( }
<FieldDisplay nonEditModeContent={<FieldDisplay />}
isCellSoftFocused={isCellSoftFocused} />
cellElement={cellElement} </FieldFocusContextProvider>
fromTableCell
/>
)}
/>
); );
}; };

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

View File

@ -1,31 +1,18 @@
import { useContext } from 'react'; import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty';
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 { RecordTableCellDisplayContainer } from './RecordTableCellDisplayContainer'; import { RecordTableCellDisplayContainer } from './RecordTableCellDisplayContainer';
export const RecordTableCellDisplayMode = ({ export const RecordTableCellDisplayMode = ({
children, children,
}: React.PropsWithChildren<unknown>) => { }: React.PropsWithChildren<unknown>) => {
const cellPosition = useCurrentTableCellPosition(); const isEmpty = useIsFieldEmpty();
const { onMoveSoftFocusToCell } = useContext(RecordTableContext);
const { openTableCell } = useOpenRecordTableCellFromCell();
const isFieldInputOnly = useIsFieldInputOnly(); if (isEmpty) {
return <></>;
const handleClick = () => { }
onMoveSoftFocusToCell(cellPosition);
if (!isFieldInputOnly) {
openTableCell();
}
};
return ( return (
<RecordTableCellDisplayContainer onClick={handleClick}> <RecordTableCellDisplayContainer>
{children} {children}
</RecordTableCellDisplayContainer> </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 { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { IconArrowUpRight } from 'twenty-ui';
import { useClearField } from '@/object-record/record-field/hooks/useClearField'; 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 { useIsFieldClearable } from '@/object-record/record-field/hooks/useIsFieldClearable';
import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly'; import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly';
import { useToggleEditOnlyInput } from '@/object-record/record-field/hooks/useToggleEditOnlyInput'; 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 { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell';
import { isSoftFocusUsingMouseState } from '@/object-record/record-table/states/isSoftFocusUsingMouseState'; import { isSoftFocusUsingMouseState } from '@/object-record/record-table/states/isSoftFocusUsingMouseState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
@ -15,13 +21,23 @@ import { TableHotkeyScope } from '../../types/TableHotkeyScope';
import { RecordTableCellDisplayContainer } from './RecordTableCellDisplayContainer'; import { RecordTableCellDisplayContainer } from './RecordTableCellDisplayContainer';
type RecordTableCellSoftFocusModeProps = PropsWithChildren<unknown>; type RecordTableCellSoftFocusModeProps = {
editModeContent: ReactElement;
nonEditModeContent: ReactElement;
};
export const RecordTableCellSoftFocusMode = ({ export const RecordTableCellSoftFocusMode = ({
children, editModeContent,
nonEditModeContent,
}: RecordTableCellSoftFocusModeProps) => { }: RecordTableCellSoftFocusModeProps) => {
const { columnIndex } = useContext(RecordTableCellContext);
const { isReadOnly } = useContext(RecordTableRowContext);
const { openTableCell } = useOpenRecordTableCellFromCell(); const { openTableCell } = useOpenRecordTableCellFromCell();
const editModeContentOnly = useIsFieldInputOnly();
const isFieldInputOnly = useIsFieldInputOnly(); const isFieldInputOnly = useIsFieldInputOnly();
const isFieldClearable = useIsFieldClearable(); 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 ( return (
<RecordTableCellDisplayContainer <>
onClick={handleClick} <RecordTableCellDisplayContainer
scrollRef={scrollRef} onClick={handleClick}
> scrollRef={scrollRef}
{children} >
</RecordTableCellDisplayContainer> {editModeContentOnly ? editModeContent : nonEditModeContent}
</RecordTableCellDisplayContainer>
{showButton && (
<RecordTableCellButton onClick={handleClick} Icon={buttonIcon} />
)}
</>
); );
}; };

View File

@ -1,10 +1,7 @@
import { MouseEventHandler, useMemo } from 'react'; import { MouseEventHandler, useMemo } from 'react';
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata'; import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
import { import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
ExpandableList,
ExpandableListProps,
} from '@/ui/layout/expandable-list/components/ExpandableList';
import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink'; import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink';
import { import {
LinkType, LinkType,
@ -15,18 +12,12 @@ import { isDefined } from '~/utils/isDefined';
import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl'; import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl';
import { getUrlHostName } from '~/utils/url/getUrlHostName'; import { getUrlHostName } from '~/utils/url/getUrlHostName';
type LinksDisplayProps = Pick< type LinksDisplayProps = {
ExpandableListProps,
'isChipCountDisplayed' | 'withExpandedListBorder'
> & {
value?: FieldLinksValue; value?: FieldLinksValue;
isFocused?: boolean;
}; };
export const LinksDisplay = ({ export const LinksDisplay = ({ value, isFocused }: LinksDisplayProps) => {
isChipCountDisplayed,
withExpandedListBorder,
value,
}: LinksDisplayProps) => {
const links = useMemo( const links = useMemo(
() => () =>
[ [
@ -53,10 +44,7 @@ export const LinksDisplay = ({
const handleClick: MouseEventHandler = (event) => event.stopPropagation(); const handleClick: MouseEventHandler = (event) => event.stopPropagation();
return ( return (
<ExpandableList <ExpandableList isChipCountDisplayed={isFocused}>
isChipCountDisplayed={isChipCountDisplayed}
withExpandedListBorder={withExpandedListBorder}
>
{links.map(({ url, label, type }, index) => {links.map(({ url, label, type }, index) =>
type === LinkType.LinkedIn || type === LinkType.Twitter ? ( type === LinkType.LinkedIn || type === LinkType.Twitter ? (
<SocialLink key={index} href={url} onClick={handleClick} type={type}> <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 { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer'; import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer';
import { findOneRecordForShowPageOperationSignatureFactory } from '@/object-record/record-show/graphql/operations/factories/findOneRecordForShowPageOperationSignatureFactory'; 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 { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { PageBody } from '@/ui/layout/page/PageBody'; import { PageBody } from '@/ui/layout/page/PageBody';
import { PageContainer } from '@/ui/layout/page/PageContainer'; import { PageContainer } from '@/ui/layout/page/PageContainer';
@ -64,7 +66,10 @@ export const RecordShowPage = () => {
}); });
useEffect(() => { useEffect(() => {
if (!record) return; if (!record) {
return;
}
setEntityFields(record); setEntityFields(record);
}, [record, setEntityFields]); }, [record, setEntityFields]);
@ -102,40 +107,43 @@ export const RecordShowPage = () => {
: capitalize(objectNameSingular); : capitalize(objectNameSingular);
return ( return (
<PageContainer> <RecordFieldValueSelectorContextProvider>
<PageTitle title={pageTitle} /> <RecordValueSetterEffect recordId={objectRecordId} />
<PageHeader <PageContainer>
title={pageName ?? ''} <PageTitle title={pageTitle} />
hasBackButton <PageHeader
Icon={headerIcon} title={pageName ?? ''}
loading={loading} hasBackButton
> Icon={headerIcon}
<>
<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} 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 { ProfilerWrapper } from '~/testing/profiling/components/ProfilerWrapper';
import { ProfilingQueueEffect } from '~/testing/profiling/components/ProfilingQueueEffect'; import { ProfilingQueueEffect } from '~/testing/profiling/components/ProfilingQueueEffect';
import { ProfilingReporter } from '~/testing/profiling/components/ProfilingReporter'; 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 { profilingSessionRunsState } from '~/testing/profiling/states/profilingSessionRunsState';
import { profilingSessionStatusState } from '~/testing/profiling/states/profilingSessionStatusState'; import { profilingSessionStatusState } from '~/testing/profiling/states/profilingSessionStatusState';
import { getTestArray } from '~/testing/profiling/utils/getTestArray'; 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 }) => { export const ProfilerDecorator: Decorator = (Story, { id, parameters }) => {
const numberOfTests = parameters.numberOfTests ?? 2; const numberOfTests = parameters.numberOfTests ?? 2;
const numberOfRuns = parameters.numberOfRuns ?? 2; const numberOfRuns = parameters.numberOfRuns ?? 2;
const warmUpRounds = parameters.warmUpRounds ?? 5;
const [currentProfilingRunIndex] = useRecoilState( const [currentProfilingRunIndex] = useRecoilState(
currentProfilingRunIndexState, currentProfilingRunIndexState,
@ -31,6 +32,7 @@ export const ProfilerDecorator: Decorator = (Story, { id, parameters }) => {
<ProfilingQueueEffect <ProfilingQueueEffect
numberOfRuns={numberOfRuns} numberOfRuns={numberOfRuns}
numberOfTestsPerRun={numberOfTests} numberOfTestsPerRun={numberOfTests}
warmUpRounds={warmUpRounds}
profilingId={id} profilingId={id}
/> />
<div> <div>

View File

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

View File

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

View File

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

View File

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

View File

@ -46630,6 +46630,7 @@ __metadata:
type-fest: "npm:4.10.1" type-fest: "npm:4.10.1"
typeorm: "npm:^0.3.17" typeorm: "npm:^0.3.17"
typescript: "npm:5.3.3" typescript: "npm:5.3.3"
use-context-selector: "npm:^2.0.0"
use-debounce: "npm:^10.0.0" use-debounce: "npm:^10.0.0"
uuid: "npm:^9.0.0" uuid: "npm:^9.0.0"
vite: "npm:^5.0.0" vite: "npm:^5.0.0"
@ -47659,6 +47660,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "use-debounce@npm:^10.0.0":
version: 10.0.0 version: 10.0.0
resolution: "use-debounce@npm:10.0.0" resolution: "use-debounce@npm:10.0.0"