diff --git a/package.json b/package.json index 1d5ce6c3e7..d6c4d06258 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "tsup": "^8.0.1", "type-fest": "4.10.1", "typeorm": "^0.3.17", + "use-context-selector": "^2.0.0", "use-debounce": "^10.0.0", "uuid": "^9.0.0", "vite-tsconfig-paths": "^4.2.1", diff --git a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx index 4626d872f8..21310d09aa 100644 --- a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx +++ b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx @@ -53,12 +53,12 @@ export const ActivityTargetsInlineCell = ({ /> } label="Relations" - displayModeContent={() => ( + displayModeContent={ - )} + } isDisplayModeContentEmpty={activityTargetObjectRecords.length === 0} /> diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts index 0b8ec528ba..3d06b28fa1 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts @@ -1,5 +1,8 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType'; +import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; +import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon'; import { FieldMetadataItem } from '../types/FieldMetadataItem'; @@ -15,7 +18,7 @@ export const formatFieldMetadataItemAsFieldDefinition = ({ objectMetadataItem, showLabel, labelWidth, -}: FieldMetadataItemAsFieldDefinitionProps) => { +}: FieldMetadataItemAsFieldDefinitionProps): FieldDefinition => { const relationObjectMetadataItem = field.toRelationMetadata?.fromObjectMetadata || field.fromRelationMetadata?.toObjectMetadata; @@ -24,25 +27,31 @@ export const formatFieldMetadataItemAsFieldDefinition = ({ field.toRelationMetadata?.fromFieldMetadataId || field.fromRelationMetadata?.toFieldMetadataId; + const fieldDefintionMetadata = { + fieldName: field.name, + placeHolder: field.label, + relationType: parseFieldRelationType(field), + relationFieldMetadataId, + relationObjectMetadataNameSingular: + relationObjectMetadataItem?.nameSingular ?? '', + relationObjectMetadataNamePlural: + relationObjectMetadataItem?.namePlural ?? '', + objectMetadataNameSingular: objectMetadataItem.nameSingular ?? '', + options: field.options, + }; + return { fieldMetadataId: field.id, label: field.label, showLabel, labelWidth, type: field.type, - metadata: { - fieldName: field.name, - placeHolder: field.label, - relationType: parseFieldRelationType(field), - relationFieldMetadataId, - relationObjectMetadataNameSingular: - relationObjectMetadataItem?.nameSingular ?? '', - relationObjectMetadataNamePlural: - relationObjectMetadataItem?.namePlural ?? '', - objectMetadataNameSingular: objectMetadataItem.nameSingular ?? '', - options: field.options, - }, + metadata: fieldDefintionMetadata, iconName: field.icon ?? 'Icon123', defaultValue: field.defaultValue, + editButtonIcon: getFieldButtonIcon({ + metadata: fieldDefintionMetadata, + type: field.type, + }), }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx index 81838d8ff0..9b695a3f3c 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx @@ -13,8 +13,10 @@ import { RecordUpdateHook, RecordUpdateHookParams, } from '@/object-record/record-field/contexts/FieldContext'; +import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon'; import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; +import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox'; @@ -209,6 +211,7 @@ export const RecordBoardCard = () => { return ( + { type: fieldDefinition.type, metadata: fieldDefinition.metadata, defaultValue: fieldDefinition.defaultValue, + editButtonIcon: getFieldButtonIcon({ + metadata: fieldDefinition.metadata, + type: fieldDefinition.type, + }), }, useUpdateRecord: useUpdateOneRecordHook, hotkeyScope: InlineCellHotkeyScope.InlineCell, diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx index 4e2b6d71ab..738c35ac24 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx @@ -37,17 +37,7 @@ import { isFieldSelect } from '../types/guards/isFieldSelect'; import { isFieldText } from '../types/guards/isFieldText'; import { isFieldUuid } from '../types/guards/isFieldUuid'; -type FieldDisplayProps = { - isCellSoftFocused?: boolean; - cellElement?: HTMLElement; - fromTableCell?: boolean; -}; - -export const FieldDisplay = ({ - isCellSoftFocused, - cellElement, - fromTableCell, -}: FieldDisplayProps) => { +export const FieldDisplay = () => { const { fieldDefinition, isLabelIdentifier } = useContext(FieldContext); const isChipDisplay = @@ -78,10 +68,7 @@ export const FieldDisplay = ({ ) : isFieldLink(fieldDefinition) ? ( ) : isFieldLinks(fieldDefinition) ? ( - + ) : isFieldCurrency(fieldDefinition) ? ( ) : isFieldFullName(fieldDefinition) ? ( @@ -89,11 +76,7 @@ export const FieldDisplay = ({ ) : isFieldSelect(fieldDefinition) ? ( ) : isFieldMultiSelect(fieldDefinition) ? ( - + ) : isFieldAddress(fieldDefinition) ? ( ) : isFieldRawJson(fieldDefinition) ? ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/contexts/FieldFocusContext.ts b/packages/twenty-front/src/modules/object-record/record-field/contexts/FieldFocusContext.ts new file mode 100644 index 0000000000..3d3b008266 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/contexts/FieldFocusContext.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +export type FieldFocusContextType = { + isFocused: boolean; + setIsFocused: (isFocused: boolean) => void; +}; + +export const FieldFocusContext = createContext( + {} as FieldFocusContextType, +); diff --git a/packages/twenty-front/src/modules/object-record/record-field/contexts/FieldFocusContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-field/contexts/FieldFocusContextProvider.tsx new file mode 100644 index 0000000000..9c780c7a99 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/contexts/FieldFocusContextProvider.tsx @@ -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 ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useFieldFocus.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useFieldFocus.ts new file mode 100644 index 0000000000..7837d6ade5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useFieldFocus.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useGetButtonIcon.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useGetButtonIcon.ts index 59af661090..ebd413adc2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/useGetButtonIcon.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useGetButtonIcon.ts @@ -1,33 +1,12 @@ import { useContext } from 'react'; -import { IconComponent, IconPencil } from 'twenty-ui'; +import { IconComponent } from 'twenty-ui'; -import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; -import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; -import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect'; -import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; -import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon'; import { FieldContext } from '../contexts/FieldContext'; -import { isFieldEmail } from '../types/guards/isFieldEmail'; -import { isFieldLink } from '../types/guards/isFieldLink'; -import { isFieldPhone } from '../types/guards/isFieldPhone'; export const useGetButtonIcon = (): IconComponent | undefined => { const { fieldDefinition } = useContext(FieldContext); - if (isUndefinedOrNull(fieldDefinition)) return undefined; - - if ( - isFieldLink(fieldDefinition) || - isFieldEmail(fieldDefinition) || - isFieldPhone(fieldDefinition) || - isFieldDisplayedAsPhone(fieldDefinition) || - isFieldMultiSelect(fieldDefinition) || - (isFieldRelation(fieldDefinition) && - fieldDefinition.metadata.relationObjectMetadataNameSingular !== - 'workspaceMember') || - isFieldLinks(fieldDefinition) - ) { - return IconPencil; - } + return getFieldButtonIcon(fieldDefinition); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldEmpty.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldEmpty.ts index 793cd53f49..01c4573c00 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldEmpty.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldEmpty.ts @@ -1,18 +1,16 @@ import { useContext } from 'react'; -import { useRecoilValue } from 'recoil'; import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty'; -import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { FieldContext } from '../contexts/FieldContext'; export const useIsFieldEmpty = () => { const { entityId, fieldDefinition } = useContext(FieldContext); - const fieldValue = useRecoilValue( - recordStoreFamilySelector({ - fieldName: fieldDefinition.metadata.fieldName, - recordId: entityId, - }), + + const fieldValue = useRecordFieldValue( + entityId, + fieldDefinition.metadata.fieldName, ); return isFieldValueEmpty({ diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/LinksFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/LinksFieldDisplay.tsx index 2d93d91a88..4b170b5a2e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/LinksFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/LinksFieldDisplay.tsx @@ -1,22 +1,11 @@ +import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField'; import { LinksDisplay } from '@/ui/field/display/components/LinksDisplay'; -type LinksFieldDisplayProps = { - isCellSoftFocused?: boolean; - fromTableCell?: boolean; -}; - -export const LinksFieldDisplay = ({ - isCellSoftFocused, - fromTableCell, -}: LinksFieldDisplayProps) => { +export const LinksFieldDisplay = () => { const { fieldValue } = useLinksField(); - return ( - - ); + const { isFocused } = useFieldFocus(); + + return ; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx index b61557e9e3..8962d1639a 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx @@ -1,20 +1,14 @@ import { Tag } from 'twenty-ui'; +import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField'; import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; -type MultiSelectFieldDisplayProps = { - isCellSoftFocused?: boolean; - cellElement?: HTMLElement; - fromTableCell?: boolean; -}; - -export const MultiSelectFieldDisplay = ({ - isCellSoftFocused, - fromTableCell, -}: MultiSelectFieldDisplayProps) => { +export const MultiSelectFieldDisplay = () => { const { fieldValues, fieldDefinition } = useMultiSelectField(); + const { isFocused } = useFieldFocus(); + const selectedOptions = fieldValues ? fieldDefinition.metadata.options?.filter((option) => fieldValues.includes(option.value), @@ -24,10 +18,7 @@ export const MultiSelectFieldDisplay = ({ if (!selectedOptions) return null; return ( - + {selectedOptions.map((selectedOption, index) => ( { - const { fieldValue, fieldDefinition, maxWidth } = useRelationField(); + const { fieldValue, fieldDefinition, maxWidth } = useRelationFieldDisplay(); if ( !fieldValue || !fieldDefinition?.metadata.relationObjectMetadataNameSingular - ) + ) { return null; + } return ( { + 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) => ( + + + + + + + ), + ComponentDecorator, + ], + component: RelationFieldDisplay, + argTypes: { value: { control: 'date' } }, + args: {}, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Performance = getProfilingStory({ + componentName: 'RelationFieldDisplay', + averageThresholdInMs: 0.4, + numberOfRuns: 20, + numberOfTestsPerRun: 100, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/mock.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/mock.ts new file mode 100644 index 0000000000..4fdbfc03b8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/mock.ts @@ -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, + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFieldDisplay.ts new file mode 100644 index 0000000000..ef56895874 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFieldDisplay.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts index 0b54996588..f1ab2a9929 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts @@ -1,3 +1,5 @@ +import { IconComponent } from 'twenty-ui'; + import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadata } from './FieldMetadata'; @@ -24,4 +26,5 @@ export type FieldDefinition = { metadata: T; infoTooltipContent?: string; defaultValue?: any; + editButtonIcon?: IconComponent; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx b/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx new file mode 100644 index 0000000000..d8ea93496f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx @@ -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, '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; + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 3fceb3f2cc..3deaaa9aff 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -16,6 +16,7 @@ import { recordIndexIsCompactModeActiveState } from '@/object-record/record-inde import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; +import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider'; import { ViewBar } from '@/views/components/ViewBar'; @@ -105,74 +106,78 @@ export const RecordIndexContainer = ({ return ( - - - } - onCurrentViewChange={(view) => { - if (!view) { - return; + + + } + onCurrentViewChange={(view) => { + if (!view) { + return; + } - onViewFieldsChange(view.viewFields); - setTableFilters( - mapViewFiltersToFilters(view.viewFilters, filterDefinitions), - ); - setRecordIndexFilters( - mapViewFiltersToFilters(view.viewFilters, filterDefinitions), - ); - setTableSorts(mapViewSortsToSorts(view.viewSorts, sortDefinitions)); - setRecordIndexSorts( - mapViewSortsToSorts(view.viewSorts, sortDefinitions), - ); - setRecordIndexViewType(view.type); - setRecordIndexViewKanbanFieldMetadataIdState( - view.kanbanFieldMetadataId, - ); - setRecordIndexIsCompactModeActive(view.isCompact); - }} - /> - - - {recordIndexViewType === ViewType.Table && ( - <> - - - - )} - {recordIndexViewType === ViewType.Kanban && ( - <> - - - - )} + + {recordIndexViewType === ViewType.Table && ( + <> + + + + )} + {recordIndexViewType === ViewType.Kanban && ( + <> + + + + )} + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx index 624e749d30..8eef4e126c 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx @@ -4,6 +4,7 @@ import { useIcons } from 'twenty-ui'; import { FieldDisplay } from '@/object-record/record-field/components/FieldDisplay'; import { FieldInput } from '@/object-record/record-field/components/FieldInput'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { FieldFocusContextProvider } from '@/object-record/record-field/contexts/FieldFocusContextProvider'; import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButtonIcon'; import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty'; import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly'; @@ -70,45 +71,44 @@ export const RecordInlineCell = ({ const { getIcon } = useIcons(); return ( - - } - displayModeContent={({ cellElement, isCellSoftFocused }) => ( - - )} - isDisplayModeContentEmpty={isFieldEmpty} - isDisplayModeFixHeight - editModeContentOnly={isFieldInputOnly} - loading={loading} - /> + + + } + displayModeContent={} + isDisplayModeContentEmpty={isFieldEmpty} + isDisplayModeFixHeight + editModeContentOnly={isFieldInputOnly} + loading={loading} + /> + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx index 48c92d8e6f..5d9f288bd0 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx @@ -1,20 +1,15 @@ -import React, { useContext, useState } from 'react'; -import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; +import React, { ReactElement, useContext } from 'react'; import { Tooltip } from 'react-tooltip'; -import { css, useTheme } from '@emotion/react'; +import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { IconComponent } from 'twenty-ui'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; +import { RecordInlineCellValue } from '@/object-record/record-inline-cell/components/RecordInlineCellValue'; import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; -import { useInlineCell } from '../hooks/useInlineCell'; - -import { RecordInlineCellDisplayMode } from './RecordInlineCellDisplayMode'; -import { RecordInlineCellButton } from './RecordInlineCellEditButton'; -import { RecordInlineCellEditMode } from './RecordInlineCellEditMode'; - const StyledIconContainer = styled.div` align-items: center; color: ${({ theme }) => theme.font.color.tertiary}; @@ -48,18 +43,6 @@ const StyledLabelContainer = styled.div<{ width?: number }>` width: ${({ width }) => width}px; `; -const StyledClickableContainer = styled.div<{ readonly?: boolean }>` - display: flex; - gap: ${({ theme }) => theme.spacing(1)}; - width: 100%; - - ${({ readonly }) => - !readonly && - css` - cursor: pointer; - `}; -`; - const StyledInlineCellBaseContainer = styled.div` align-items: center; box-sizing: border-box; @@ -82,41 +65,20 @@ const StyledTooltip = styled(Tooltip)` padding: ${({ theme }) => theme.spacing(2)}; `; -const StyledSkeletonDiv = styled.div` +export const StyledSkeletonDiv = styled.div` height: 24px; `; -const StyledInlineCellSkeletonLoader = () => { - const theme = useTheme(); - return ( - - - - - - ); -}; - -type RecordInlineCellContainerProps = { +export type RecordInlineCellContainerProps = { readonly?: boolean; IconLabel?: IconComponent; label?: string; labelWidth?: number; showLabel?: boolean; buttonIcon?: IconComponent; - editModeContent?: React.ReactNode; + editModeContent?: ReactElement; editModeContentOnly?: boolean; - displayModeContent: ({ - isCellSoftFocused, - cellElement, - }: { - isCellSoftFocused: boolean; - cellElement?: HTMLDivElement; - }) => React.ReactNode; + displayModeContent: ReactElement; customEditHotkeyScope?: HotkeyScope; isDisplayModeContentEmpty?: boolean; isDisplayModeFixHeight?: boolean; @@ -141,85 +103,24 @@ export const RecordInlineCellContainer = ({ loading = false, }: RecordInlineCellContainerProps) => { const { entityId, fieldDefinition } = useContext(FieldContext); - // Used by some fields in ExpandableList as an anchor for the floating element. - // floating-ui mentions that `useState` must be used instead of `useRef`, - // see https://floating-ui.com/docs/useFloating#elements - const [cellElement, setCellElement] = useState(null); - const [isHovered, setIsHovered] = useState(false); - const [isCellSoftFocused, setIsCellSoftFocused] = useState(false); + + const { setIsFocused } = useFieldFocus(); const handleContainerMouseEnter = () => { if (!readonly) { - setIsHovered(true); + setIsFocused(true); } - setIsCellSoftFocused(true); }; const handleContainerMouseLeave = () => { if (!readonly) { - setIsHovered(false); - } - setIsCellSoftFocused(false); - }; - - const { isInlineCellInEditMode, openInlineCell } = useInlineCell(); - - const handleDisplayModeClick = () => { - if (!readonly && !editModeContentOnly) { - openInlineCell(customEditHotkeyScope); + setIsFocused(false); } }; - const showEditButton = - buttonIcon && - !isInlineCellInEditMode && - isHovered && - !editModeContentOnly && - !isDisplayModeContentEmpty; - const theme = useTheme(); const labelId = `label-${entityId}-${fieldDefinition?.metadata?.fieldName}`; - const showContent = () => { - if (loading) { - return ; - } - return !readonly && isInlineCellInEditMode ? ( - {editModeContent} - ) : editModeContentOnly ? ( - - - {editModeContent} - - - ) : ( - - - {displayModeContent({ - isCellSoftFocused, - cellElement: cellElement ?? undefined, - })} - - {showEditButton && } - - ); - }; - return ( )} - - {showContent()} + + ); diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellSkeletonLoader.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellSkeletonLoader.tsx new file mode 100644 index 0000000000..2e673478db --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellSkeletonLoader.tsx @@ -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 ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx new file mode 100644 index 0000000000..1a4de00268 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellValue.tsx @@ -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 ; + } + + return !readonly && isInlineCellInEditMode ? ( + {editModeContent} + ) : editModeContentOnly ? ( + + + {editModeContent} + + + ) : ( + + + {displayModeContent} + + {showEditButton && } + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx index d7d4c7fb7d..faea1d1742 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx @@ -28,6 +28,7 @@ import { RecordInlineCell } from '@/object-record/record-inline-cell/components/ import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox'; import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; import { RecordDetailRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsListItem'; +import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported'; @@ -187,6 +188,7 @@ export const RecordDetailRelationRecordsListItem = ({ return ( <> + { + const setRecordValue = useSetRecordValue(); + + const recordValue = useRecoilValue(recordStoreFamilyState(recordId)); + + useEffect(() => { + setRecordValue(recordId, recordValue); + }, [setRecordValue, recordValue, recordId]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-store/contexts/RecordFieldValueSelectorContext.tsx b/packages/twenty-front/src/modules/object-record/record-store/contexts/RecordFieldValueSelectorContext.tsx new file mode 100644 index 0000000000..f4ee2c1775 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-store/contexts/RecordFieldValueSelectorContext.tsx @@ -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>] +>([{}, () => {}]); + +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; +}) => ( + ({})} + > + {children} + +); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx index cafb21ab5f..1d2c5b5ce0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; +import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; import { RecordTableCellFieldContextWrapper } from '@/object-record/record-table/components/RecordTableCellFieldContextWrapper'; import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext'; import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext'; @@ -52,6 +53,7 @@ export const RecordTableRow = ({ recordId, rowIndex }: RecordTableRowProps) => { isReadOnly: objectMetadataItem.isRemote ?? false, }} > + { + 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) => ( + + {}, + onOpenTableCell: () => {}, + onMoveFocus: () => {}, + onCloseTableCell: () => {}, + onMoveSoftFocusToCell: () => {}, + onContextMenu: () => {}, + onCellMouseEnter: () => {}, + }} + > + {}}> + + + + + + + + + + +
+
+
+
+
+
+
+ ), + ComponentDecorator, + ], + component: RecordTableCellFieldContextWrapper, + argTypes: { value: { control: 'date' } }, + args: {}, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Performance = getProfilingStory({ + componentName: 'RecordTableCell', + averageThresholdInMs: 0.6, + numberOfRuns: 50, + numberOfTestsPerRun: 200, + warmUpRounds: 20, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/mock.ts b/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/mock.ts new file mode 100644 index 0000000000..9b3191bae1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/mock.ts @@ -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: 'Contact’s 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: 'Contact’s 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: 'Contact’s 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: 'Contact’s 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: 'Contact’s 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: 'Contact’s 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: 'Contact’s 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: 'Contact’s 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: 'Contact’s 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: 'Contact’s 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, + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useCloseCurrentTableCellInEditMode.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useCloseCurrentTableCellInEditMode.ts index a9976aa05f..b894046ce0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useCloseCurrentTableCellInEditMode.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useCloseCurrentTableCellInEditMode.ts @@ -21,6 +21,13 @@ export const useCloseCurrentTableCellInEditMode = (recordTableId?: string) => { isTableCellInEditModeFamilyState(currentTableCellInEditModePosition), false, ); + + document.dispatchEvent( + new CustomEvent( + `edit-mode-change-${currentTableCellInEditModePosition.row}:${currentTableCellInEditModePosition.column}`, + { detail: false }, + ), + ); }; }, [currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState], diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useMoveEditModeToCellPosition.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useMoveEditModeToCellPosition.ts index 02e04a259d..c51be2fa6b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useMoveEditModeToCellPosition.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useMoveEditModeToCellPosition.ts @@ -24,9 +24,23 @@ export const useMoveEditModeToTableCellPosition = (recordTableId?: string) => { false, ); + document.dispatchEvent( + new CustomEvent( + `edit-mode-change-${currentTableCellInEditModePosition.row}:${currentTableCellInEditModePosition.column}`, + { detail: false }, + ), + ); + set(currentTableCellInEditModePositionState, newPosition); set(isTableCellInEditModeFamilyState(newPosition), true); + + document.dispatchEvent( + new CustomEvent( + `edit-mode-change-${newPosition.row}:${newPosition.column}`, + { detail: true }, + ), + ); }; }, [currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState], diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetSoftFocusPosition.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetSoftFocusPosition.ts index edf8f7a904..d645c7bb90 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetSoftFocusPosition.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetSoftFocusPosition.ts @@ -24,9 +24,23 @@ export const useSetSoftFocusPosition = (recordTableId?: string) => { set(isSoftFocusOnTableCellFamilyState(currentPosition), false); + document.dispatchEvent( + new CustomEvent( + `soft-focus-move-${currentPosition.row}:${currentPosition.column}`, + { detail: false }, + ), + ); + set(softFocusPositionState, newPosition); set(isSoftFocusOnTableCellFamilyState(newPosition), true); + + document.dispatchEvent( + new CustomEvent( + `soft-focus-move-${newPosition.row}:${newPosition.column}`, + { detail: true }, + ), + ); }; }, [ diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCell.tsx index a53f8c7226..a38d61a39d 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCell.tsx @@ -3,6 +3,7 @@ import { useContext } from 'react'; import { FieldDisplay } from '@/object-record/record-field/components/FieldDisplay'; import { FieldInput } from '@/object-record/record-field/components/FieldInput'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { FieldFocusContextProvider } from '@/object-record/record-field/contexts/FieldFocusContextProvider'; import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext'; @@ -87,28 +88,24 @@ export const RecordTableCell = ({ }; return ( - - } - nonEditModeContent={({ isCellSoftFocused, cellElement }) => ( - - )} - /> + + + } + nonEditModeContent={} + /> + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.tsx index 28f30bf9bc..90200dc230 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.tsx @@ -1,28 +1,15 @@ -import React, { ReactElement, useContext, useState } from 'react'; +import React, { ReactElement, useContext, useEffect, useState } from 'react'; import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; -import { IconArrowUpRight } from 'twenty-ui'; -import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButtonIcon'; -import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty'; -import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly'; -import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext'; +import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { useCurrentTableCellPosition } from '@/object-record/record-table/record-table-cell/hooks/useCurrentCellPosition'; -import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell'; -import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext'; -import { isSoftFocusOnTableCellComponentFamilyState } from '@/object-record/record-table/states/isSoftFocusOnTableCellComponentFamilyState'; -import { isTableCellInEditModeComponentFamilyState } from '@/object-record/record-table/states/isTableCellInEditModeComponentFamilyState'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; -import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; -import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId'; -import { extractComponentFamilyState } from '@/ui/utilities/state/component-state/utils/extractComponentFamilyState'; import { CellHotkeyScopeContext } from '../../contexts/CellHotkeyScopeContext'; import { TableHotkeyScope } from '../../types/TableHotkeyScope'; -import { RecordTableCellButton } from './RecordTableCellButton'; import { RecordTableCellDisplayMode } from './RecordTableCellDisplayMode'; import { RecordTableCellEditMode } from './RecordTableCellEditMode'; import { RecordTableCellSoftFocusMode } from './RecordTableCellSoftFocusMode'; @@ -51,13 +38,7 @@ const StyledCellBaseContainer = styled.div<{ softFocus: boolean }>` export type RecordTableCellContainerProps = { editModeContent: ReactElement; - nonEditModeContent?: ({ - isCellSoftFocused, - cellElement, - }: { - isCellSoftFocused: boolean; - cellElement?: HTMLTableCellElement; - }) => ReactElement; + nonEditModeContent: ReactElement; editHotkeyScope?: HotkeyScope; transparent?: boolean; maxContentWidth?: number; @@ -74,91 +55,85 @@ export const RecordTableCellContainer = ({ nonEditModeContent, editHotkeyScope, }: RecordTableCellContainerProps) => { - const { columnIndex } = useContext(RecordTableCellContext); - // Used by some fields in ExpandableList as an anchor for the floating element. - // floating-ui mentions that `useState` must be used instead of `useRef`, - // see https://floating-ui.com/docs/useFloating#elements - const [cellElement, setCellElement] = useState( - null, - ); - const [isCellBaseContainerHovered, setIsCellBaseContainerHovered] = - useState(false); - const { isReadOnly, isSelected, recordId } = useContext( - RecordTableRowContext, - ); - const { onMoveSoftFocusToCell, onContextMenu, onCellMouseEnter } = - useContext(RecordTableContext); + const { setIsFocused } = useFieldFocus(); + + const { isSelected, recordId } = useContext(RecordTableRowContext); + const { onContextMenu, onCellMouseEnter } = useContext(RecordTableContext); + + const [isHovered, setIsHovered] = useState(false); + const [hasSoftFocus, setHasSoftFocus] = useState(false); + const [isInEditMode, setIsInEditMode] = useState(false); const cellPosition = useCurrentTableCellPosition(); - const { openTableCell } = useOpenRecordTableCellFromCell(); - - const tableScopeId = useAvailableScopeIdOrThrow( - RecordTableScopeInternalContext, - getScopeIdOrUndefinedFromComponentId(), - ); - - const isTableCellInEditModeFamilyState = extractComponentFamilyState( - isTableCellInEditModeComponentFamilyState, - tableScopeId, - ); - - const isSoftFocusOnTableCellFamilyState = extractComponentFamilyState( - isSoftFocusOnTableCellComponentFamilyState, - tableScopeId, - ); - - const isCurrentTableCellInEditMode = useRecoilValue( - isTableCellInEditModeFamilyState(cellPosition), - ); - - const hasSoftFocus = useRecoilValue( - isSoftFocusOnTableCellFamilyState(cellPosition), - ); - - const isEmpty = useIsFieldEmpty(); - - const handleButtonClick = () => { - onMoveSoftFocusToCell(cellPosition); - openTableCell(); - }; - const handleContextMenu = (event: React.MouseEvent) => { onContextMenu(event, recordId); }; const handleContainerMouseEnter = () => { - onCellMouseEnter({ - cellPosition, - isHovered: isCellBaseContainerHovered, - setIsHovered: setIsCellBaseContainerHovered, - }); + if (!hasSoftFocus) { + onCellMouseEnter({ + cellPosition, + isHovered, + setIsHovered, + }); + } }; const handleContainerMouseLeave = () => { - setIsCellBaseContainerHovered(false); + setIsHovered(false); }; - const editModeContentOnly = useIsFieldInputOnly(); + const handleContainerMouseMove = () => { + handleContainerMouseEnter(); + }; - const isFirstColumn = columnIndex === 0; - const customButtonIcon = useGetButtonIcon(); - const buttonIcon = isFirstColumn ? IconArrowUpRight : customButtonIcon; + useEffect(() => { + const customEventListener = (event: any) => { + const newHasSoftFocus = event.detail; - const showButton = - !!buttonIcon && - hasSoftFocus && - !isCurrentTableCellInEditMode && - !editModeContentOnly && - (!isFirstColumn || !isEmpty) && - !isReadOnly; + setHasSoftFocus(newHasSoftFocus); + setIsFocused(newHasSoftFocus); + }; + + document.addEventListener( + `soft-focus-move-${cellPosition.row}:${cellPosition.column}`, + customEventListener, + ); + + return () => { + document.removeEventListener( + `soft-focus-move-${cellPosition.row}:${cellPosition.column}`, + customEventListener, + ); + }; + }, [cellPosition, setIsFocused]); + + useEffect(() => { + const customEventListener = (event: any) => { + const newIsInEditMode = event.detail; + + setIsInEditMode(newIsInEditMode); + }; + + document.addEventListener( + `edit-mode-change-${cellPosition.row}:${cellPosition.column}`, + customEventListener, + ); + + return () => { + document.removeEventListener( + `edit-mode-change-${cellPosition.row}:${cellPosition.column}`, + customEventListener, + ); + }; + }, [cellPosition]); return ( - {isCurrentTableCellInEditMode ? ( + {isInEditMode ? ( {editModeContent} ) : hasSoftFocus ? ( <> - - {editModeContentOnly - ? editModeContent - : nonEditModeContent?.({ - isCellSoftFocused: true, - cellElement: cellElement ?? undefined, - })} - - {showButton && ( - - )} + ) : ( - <> - {!isEmpty && ( - - {editModeContentOnly - ? editModeContent - : nonEditModeContent?.({ - isCellSoftFocused: false, - cellElement: cellElement ?? undefined, - })} - - )} - {showButton && ( - - )} - + + {nonEditModeContent} + )} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDisplayMode.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDisplayMode.tsx index 9a052ee96c..566be79baf 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDisplayMode.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDisplayMode.tsx @@ -1,31 +1,18 @@ -import { useContext } from 'react'; - -import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly'; -import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext'; -import { useCurrentTableCellPosition } from '@/object-record/record-table/record-table-cell/hooks/useCurrentCellPosition'; -import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell'; +import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty'; import { RecordTableCellDisplayContainer } from './RecordTableCellDisplayContainer'; export const RecordTableCellDisplayMode = ({ children, }: React.PropsWithChildren) => { - const cellPosition = useCurrentTableCellPosition(); - const { onMoveSoftFocusToCell } = useContext(RecordTableContext); - const { openTableCell } = useOpenRecordTableCellFromCell(); + const isEmpty = useIsFieldEmpty(); - const isFieldInputOnly = useIsFieldInputOnly(); - - const handleClick = () => { - onMoveSoftFocusToCell(cellPosition); - - if (!isFieldInputOnly) { - openTableCell(); - } - }; + if (isEmpty) { + return <>; + } return ( - + {children} ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellSoftFocusMode.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellSoftFocusMode.tsx index 3c016ddb20..3aedd7870f 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellSoftFocusMode.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellSoftFocusMode.tsx @@ -1,11 +1,17 @@ -import { PropsWithChildren, useEffect, useRef } from 'react'; +import { ReactElement, useContext, useEffect, useRef } from 'react'; +import isEmpty from 'lodash.isempty'; import { useRecoilValue } from 'recoil'; import { Key } from 'ts-key-enum'; +import { IconArrowUpRight } from 'twenty-ui'; import { useClearField } from '@/object-record/record-field/hooks/useClearField'; +import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButtonIcon'; import { useIsFieldClearable } from '@/object-record/record-field/hooks/useIsFieldClearable'; import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly'; import { useToggleEditOnlyInput } from '@/object-record/record-field/hooks/useToggleEditOnlyInput'; +import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext'; +import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext'; +import { RecordTableCellButton } from '@/object-record/record-table/record-table-cell/components/RecordTableCellButton'; import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellFromCell'; import { isSoftFocusUsingMouseState } from '@/object-record/record-table/states/isSoftFocusUsingMouseState'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; @@ -15,13 +21,23 @@ import { TableHotkeyScope } from '../../types/TableHotkeyScope'; import { RecordTableCellDisplayContainer } from './RecordTableCellDisplayContainer'; -type RecordTableCellSoftFocusModeProps = PropsWithChildren; +type RecordTableCellSoftFocusModeProps = { + editModeContent: ReactElement; + nonEditModeContent: ReactElement; +}; export const RecordTableCellSoftFocusMode = ({ - children, + editModeContent, + nonEditModeContent, }: RecordTableCellSoftFocusModeProps) => { + const { columnIndex } = useContext(RecordTableCellContext); + + const { isReadOnly } = useContext(RecordTableRowContext); + const { openTableCell } = useOpenRecordTableCellFromCell(); + const editModeContentOnly = useIsFieldInputOnly(); + const isFieldInputOnly = useIsFieldInputOnly(); const isFieldClearable = useIsFieldClearable(); @@ -98,12 +114,27 @@ export const RecordTableCellSoftFocusMode = ({ } }; + const isFirstColumn = columnIndex === 0; + const customButtonIcon = useGetButtonIcon(); + const buttonIcon = isFirstColumn ? IconArrowUpRight : customButtonIcon; + + const showButton = + !!buttonIcon && + !editModeContentOnly && + (!isFirstColumn || !isEmpty) && + !isReadOnly; + return ( - - {children} - + <> + + {editModeContentOnly ? editModeContent : nonEditModeContent} + + {showButton && ( + + )} + ); }; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx index 01fb5353a5..0b59325aa4 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx @@ -1,10 +1,7 @@ import { MouseEventHandler, useMemo } from 'react'; import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata'; -import { - ExpandableList, - ExpandableListProps, -} from '@/ui/layout/expandable-list/components/ExpandableList'; +import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink'; import { LinkType, @@ -15,18 +12,12 @@ import { isDefined } from '~/utils/isDefined'; import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl'; import { getUrlHostName } from '~/utils/url/getUrlHostName'; -type LinksDisplayProps = Pick< - ExpandableListProps, - 'isChipCountDisplayed' | 'withExpandedListBorder' -> & { +type LinksDisplayProps = { value?: FieldLinksValue; + isFocused?: boolean; }; -export const LinksDisplay = ({ - isChipCountDisplayed, - withExpandedListBorder, - value, -}: LinksDisplayProps) => { +export const LinksDisplay = ({ value, isFocused }: LinksDisplayProps) => { const links = useMemo( () => [ @@ -53,10 +44,7 @@ export const LinksDisplay = ({ const handleClick: MouseEventHandler = (event) => event.stopPropagation(); return ( - + {links.map(({ url, label, type }, index) => type === LinkType.LinkedIn || type === LinkType.Twitter ? ( diff --git a/packages/twenty-front/src/modules/ui/field/display/constants/FieldEditButtonWidth.ts b/packages/twenty-front/src/modules/ui/field/display/constants/FieldEditButtonWidth.ts new file mode 100644 index 0000000000..bf5e82cccb --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/display/constants/FieldEditButtonWidth.ts @@ -0,0 +1 @@ +export const FIELD_EDIT_BUTTON_WIDTH = 28; diff --git a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx index 64887c0cbc..736f162106 100644 --- a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx +++ b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx @@ -9,6 +9,8 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer'; import { findOneRecordForShowPageOperationSignatureFactory } from '@/object-record/record-show/graphql/operations/factories/findOneRecordForShowPageOperationSignatureFactory'; +import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; +import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { PageBody } from '@/ui/layout/page/PageBody'; import { PageContainer } from '@/ui/layout/page/PageContainer'; @@ -64,7 +66,10 @@ export const RecordShowPage = () => { }); useEffect(() => { - if (!record) return; + if (!record) { + return; + } + setEntityFields(record); }, [record, setEntityFields]); @@ -102,40 +107,43 @@ export const RecordShowPage = () => { : capitalize(objectNameSingular); return ( - - - - <> - - - - - - - + + + + - - + > + <> + + + + + + + + + + ); }; diff --git a/packages/twenty-front/src/testing/decorators/ProfilerDecorator.tsx b/packages/twenty-front/src/testing/decorators/ProfilerDecorator.tsx index 6d14603873..4c2edc9531 100644 --- a/packages/twenty-front/src/testing/decorators/ProfilerDecorator.tsx +++ b/packages/twenty-front/src/testing/decorators/ProfilerDecorator.tsx @@ -4,7 +4,7 @@ import { useRecoilState } from 'recoil'; import { ProfilerWrapper } from '~/testing/profiling/components/ProfilerWrapper'; import { ProfilingQueueEffect } from '~/testing/profiling/components/ProfilingQueueEffect'; import { ProfilingReporter } from '~/testing/profiling/components/ProfilingReporter'; -import { currentProfilingRunIndexState } from '~/testing/profiling/states/currentProfilingRunState'; +import { currentProfilingRunIndexState } from '~/testing/profiling/states/currentProfilingRunIndexState'; import { profilingSessionRunsState } from '~/testing/profiling/states/profilingSessionRunsState'; import { profilingSessionStatusState } from '~/testing/profiling/states/profilingSessionStatusState'; import { getTestArray } from '~/testing/profiling/utils/getTestArray'; @@ -12,6 +12,7 @@ import { getTestArray } from '~/testing/profiling/utils/getTestArray'; export const ProfilerDecorator: Decorator = (Story, { id, parameters }) => { const numberOfTests = parameters.numberOfTests ?? 2; const numberOfRuns = parameters.numberOfRuns ?? 2; + const warmUpRounds = parameters.warmUpRounds ?? 5; const [currentProfilingRunIndex] = useRecoilState( currentProfilingRunIndexState, @@ -31,6 +32,7 @@ export const ProfilerDecorator: Decorator = (Story, { id, parameters }) => {
diff --git a/packages/twenty-front/src/testing/profiling/components/ProfilingQueueEffect.tsx b/packages/twenty-front/src/testing/profiling/components/ProfilingQueueEffect.tsx index a00f22aaf4..22307b316d 100644 --- a/packages/twenty-front/src/testing/profiling/components/ProfilingQueueEffect.tsx +++ b/packages/twenty-front/src/testing/profiling/components/ProfilingQueueEffect.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { useRecoilState } from 'recoil'; import { TIME_BETWEEN_TEST_RUNS_IN_MS } from '~/testing/profiling/constants/TimeBetweenTestRunsInMs'; -import { currentProfilingRunIndexState } from '~/testing/profiling/states/currentProfilingRunState'; +import { currentProfilingRunIndexState } from '~/testing/profiling/states/currentProfilingRunIndexState'; import { profilingQueueState } from '~/testing/profiling/states/profilingQueueState'; import { profilingSessionRunsState } from '~/testing/profiling/states/profilingSessionRunsState'; import { profilingSessionStatusState } from '~/testing/profiling/states/profilingSessionStatusState'; @@ -12,10 +12,12 @@ export const ProfilingQueueEffect = ({ profilingId, numberOfTestsPerRun, numberOfRuns, + warmUpRounds, }: { profilingId: string; numberOfTestsPerRun: number; numberOfRuns: number; + warmUpRounds: number; }) => { const [currentProfilingRunIndex, setCurrentProfilingRunIndex] = useRecoilState(currentProfilingRunIndexState); @@ -38,9 +40,9 @@ export const ProfilingQueueEffect = ({ setCurrentProfilingRunIndex(0); const newTestRuns = [ - 'warm-up-1', - 'warm-up-2', - 'warm-up-3', + ...[ + ...Array.from({ length: warmUpRounds }, (_, i) => `warm-up-${i}`), + ], ...[ ...Array.from({ length: numberOfRuns }, (_, i) => `real-run-${i}`), ], @@ -76,9 +78,13 @@ export const ProfilingQueueEffect = ({ return; } - await new Promise((resolve) => - setTimeout(resolve, TIME_BETWEEN_TEST_RUNS_IN_MS), - ); + const timeInMs = profilingSessionRuns[ + currentProfilingRunIndex + ].startsWith('warm-up') + ? TIME_BETWEEN_TEST_RUNS_IN_MS * 2 + : TIME_BETWEEN_TEST_RUNS_IN_MS; + + await new Promise((resolve) => setTimeout(resolve, timeInMs)); const nextIndex = currentProfilingRunIndex + 1; @@ -109,6 +115,7 @@ export const ProfilingQueueEffect = ({ profilingSessionRuns, setProfilingSessionRuns, numberOfRuns, + warmUpRounds, ]); return <>; diff --git a/packages/twenty-front/src/testing/profiling/components/ProfilingReporter.tsx b/packages/twenty-front/src/testing/profiling/components/ProfilingReporter.tsx index 7c1ea5bdb4..91c6e26e3d 100644 --- a/packages/twenty-front/src/testing/profiling/components/ProfilingReporter.tsx +++ b/packages/twenty-front/src/testing/profiling/components/ProfilingReporter.tsx @@ -3,7 +3,9 @@ import styled from '@emotion/styled'; import { useRecoilState } from 'recoil'; import { PROFILING_REPORTER_DIV_ID } from '~/testing/profiling/constants/ProfilingReporterDivId'; +import { currentProfilingRunIndexState } from '~/testing/profiling/states/currentProfilingRunIndexState'; import { profilingSessionDataPointsState } from '~/testing/profiling/states/profilingSessionDataPointsState'; +import { profilingSessionStatusState } from '~/testing/profiling/states/profilingSessionStatusState'; import { computeProfilingReport } from '~/testing/profiling/utils/computeProfilingReport'; const StyledTable = styled.table` @@ -24,6 +26,12 @@ export const ProfilingReporter = () => { profilingSessionDataPointsState, ); + const [currentProfilingRunIndex] = useRecoilState( + currentProfilingRunIndexState, + ); + + const [profilingSessionStatus] = useRecoilState(profilingSessionStatusState); + const profilingReport = useMemo( () => computeProfilingReport(profilingSessionDataPoints), [profilingSessionDataPoints], @@ -34,6 +42,10 @@ export const ProfilingReporter = () => { data-profiling-report={JSON.stringify(profilingReport)} id={PROFILING_REPORTER_DIV_ID} > +

Profiling report

+
+ Run #{currentProfilingRunIndex} - Status {profilingSessionStatus} +
@@ -46,6 +58,7 @@ export const ProfilingReporter = () => { P95 P99 Max + Variance @@ -59,6 +72,9 @@ export const ProfilingReporter = () => { {Math.round(profilingReport.total.p95 * 1000) / 1000}ms {Math.round(profilingReport.total.p99 * 1000) / 1000}ms {Math.round(profilingReport.total.max * 1000) / 1000}ms + + {Math.round(profilingReport.total.variance * 1000000) / 1000000} + {Object.entries(profilingReport.runs).map(([runName, report]) => ( @@ -71,6 +87,7 @@ export const ProfilingReporter = () => { {Math.round(report.p95 * 1000) / 1000}ms {Math.round(report.p99 * 1000) / 1000}ms {Math.round(report.max * 1000) / 1000}ms + {Math.round(report.variance * 1000000) / 1000000} ))} diff --git a/packages/twenty-front/src/testing/profiling/states/currentProfilingRunIndexState.ts b/packages/twenty-front/src/testing/profiling/states/currentProfilingRunIndexState.ts new file mode 100644 index 0000000000..da05d73035 --- /dev/null +++ b/packages/twenty-front/src/testing/profiling/states/currentProfilingRunIndexState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const currentProfilingRunIndexState = atom({ + key: 'currentProfilingRunIndexState', + default: 0, +}); diff --git a/packages/twenty-front/src/testing/profiling/types/ProfilingReportByRun.ts b/packages/twenty-front/src/testing/profiling/types/ProfilingReportByRun.ts index f2714c702e..302a3361e7 100644 --- a/packages/twenty-front/src/testing/profiling/types/ProfilingReportByRun.ts +++ b/packages/twenty-front/src/testing/profiling/types/ProfilingReportByRun.ts @@ -10,6 +10,7 @@ export type ProfilingReportItem = { p99: number; min: number; max: number; + variance: number; }; export type ProfilingReport = { diff --git a/packages/twenty-front/src/testing/profiling/utils/computeProfilingReport.ts b/packages/twenty-front/src/testing/profiling/utils/computeProfilingReport.ts index d9afc33bee..3c8ea9c69c 100644 --- a/packages/twenty-front/src/testing/profiling/utils/computeProfilingReport.ts +++ b/packages/twenty-front/src/testing/profiling/utils/computeProfilingReport.ts @@ -1,7 +1,10 @@ import { ProfilingDataPoint } from '~/testing/profiling/types/ProfilingDataPoint'; import { ProfilingReport } from '~/testing/profiling/types/ProfilingReportByRun'; -export const computeProfilingReport = (dataPoints: ProfilingDataPoint[]) => { +export const computeProfilingReport = ( + dataPoints: ProfilingDataPoint[], + varianceThreshold?: number, +) => { const profilingReport = { total: {}, runs: {} } as ProfilingReport; for (const dataPoint of dataPoints) { @@ -27,8 +30,9 @@ export const computeProfilingReport = (dataPoints: ProfilingDataPoint[]) => { const numberOfIds = ids.length; - profilingReport.runs[runName].average = - profilingReport.runs[runName].sum / numberOfIds; + const mean = profilingReport.runs[runName].sum / numberOfIds; + + profilingReport.runs[runName].average = mean; profilingReport.runs[runName].min = Math.min( ...Object.values(profilingReport.runs[runName].sumById), @@ -38,6 +42,14 @@ export const computeProfilingReport = (dataPoints: ProfilingDataPoint[]) => { ...Object.values(profilingReport.runs[runName].sumById), ); + const intermediaryValuesForVariance = valuesUnsorted.map((value) => + Math.pow(value - mean, 2), + ); + + profilingReport.runs[runName].variance = + intermediaryValuesForVariance.reduce((acc, curr) => acc + curr) / + numberOfIds; + const p50Index = Math.floor(numberOfIds * 0.5); const p80Index = Math.floor(numberOfIds * 0.8); const p90Index = Math.floor(numberOfIds * 0.9); @@ -55,9 +67,9 @@ export const computeProfilingReport = (dataPoints: ProfilingDataPoint[]) => { runName.startsWith('real-run'), ); - const runsForTotal = runNamesForTotal.map( - (runName) => profilingReport.runs[runName], - ); + const runsForTotal = runNamesForTotal + .map((runName) => profilingReport.runs[runName]) + .filter((run) => run.variance < (varianceThreshold ?? 0.2)); profilingReport.total = { sum: Object.values(runsForTotal).reduce((acc, run) => acc + run.sum, 0), @@ -82,6 +94,9 @@ export const computeProfilingReport = (dataPoints: ProfilingDataPoint[]) => { Object.values(runsForTotal).reduce((acc, run) => acc + run.p99, 0) / Object.keys(runsForTotal).length, dataPointCount: dataPoints.length, + variance: + runsForTotal.reduce((acc, run) => acc + run.variance, 0) / + runsForTotal.length, }; return profilingReport; diff --git a/packages/twenty-front/src/testing/profiling/utils/getProfilingStory.ts b/packages/twenty-front/src/testing/profiling/utils/getProfilingStory.ts index 4c5cd0a825..959c7f9c21 100644 --- a/packages/twenty-front/src/testing/profiling/utils/getProfilingStory.ts +++ b/packages/twenty-front/src/testing/profiling/utils/getProfilingStory.ts @@ -11,19 +11,21 @@ export const getProfilingStory = ({ averageThresholdInMs, numberOfRuns, numberOfTestsPerRun, + warmUpRounds, }: { componentName: string; p95ThresholdInMs?: number; averageThresholdInMs: number; numberOfRuns: number; numberOfTestsPerRun: number; + warmUpRounds?: number; }): StoryObj => ({ decorators: [ProfilerDecorator], parameters: { numberOfRuns, numberOfTests: numberOfTestsPerRun, componentName, - chromatic: { disableSnapshot: true }, + warmUpRounds, }, play: async ({ canvasElement }) => { await findByTestId( diff --git a/yarn.lock b/yarn.lock index 79bc74bda0..d28dfe0389 100644 --- a/yarn.lock +++ b/yarn.lock @@ -46630,6 +46630,7 @@ __metadata: type-fest: "npm:4.10.1" typeorm: "npm:^0.3.17" typescript: "npm:5.3.3" + use-context-selector: "npm:^2.0.0" use-debounce: "npm:^10.0.0" uuid: "npm:^9.0.0" vite: "npm:^5.0.0" @@ -47659,6 +47660,16 @@ __metadata: languageName: node linkType: hard +"use-context-selector@npm:^2.0.0": + version: 2.0.0 + resolution: "use-context-selector@npm:2.0.0" + peerDependencies: + react: ">=18.0.0" + scheduler: ">=0.19.0" + checksum: 4eb6054ab8996ae8b3f87f9d102e576066e5a8b9db5db2c891128ae920bd64bcdcb4e93a13bc99658ef16280929a8331fc8ac45177f4acd716c425b1bc31135a + languageName: node + linkType: hard + "use-debounce@npm:^10.0.0": version: 10.0.0 resolution: "use-debounce@npm:10.0.0"