Feat/performance-refactor-styled-component (#5516)

In this PR I'm optimizing a whole RecordTableCell in real conditions
with a complex RelationFieldDisplay component :
- Broke down getObjectRecordIdentifier into multiple utils
- Precompute memoized function for getting chip data per field with
useRecordChipDataGenerator()
- Refactored RelationFieldDisplay
- Use CSS modules where performance is needed instead of styled
components
- Create a CSS theme with global CSS variables to be used by CSS modules
This commit is contained in:
Lucas Bordeau
2024-05-24 18:53:37 +02:00
committed by GitHub
parent 3680647c9a
commit a0178478d4
39 changed files with 1045 additions and 462 deletions

View File

@ -7,7 +7,6 @@ import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOr
export type RecordChipProps = {
objectNameSingular: string;
record: ObjectRecord;
maxWidth?: number;
className?: string;
variant?: EntityChipVariant;
};
@ -15,7 +14,6 @@ export type RecordChipProps = {
export const RecordChip = ({
objectNameSingular,
record,
maxWidth,
className,
variant,
}: RecordChipProps) => {
@ -34,7 +32,6 @@ export const RecordChip = ({
getImageAbsoluteURIOrBase64(objectRecordIdentifier.avatarUrl) || ''
}
linkToEntity={objectRecordIdentifier.linkToShowPage}
maxWidth={maxWidth}
className={className}
variant={variant}
/>

View File

@ -1,9 +1,12 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import { EntityChip } from 'twenty-ui';
import { useRelationFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFieldDisplay';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
import { isDefined } from '~/utils/isDefined';
export const RelationFieldDisplay = () => {
const { fieldValue, fieldDefinition, maxWidth } = useRelationFieldDisplay();
const { fieldValue, fieldDefinition, generateRecordChipData } =
useRelationFieldDisplay();
if (
!fieldValue ||
@ -12,13 +15,21 @@ export const RelationFieldDisplay = () => {
return null;
}
if (!isDefined(generateRecordChipData)) {
throw new Error(
`generateRecordChipData is not defined for field ${fieldDefinition.metadata.fieldName}, this should not happen. Check your RecordTableContext to see if it's correctly initialized.`,
);
}
const recordChipData = generateRecordChipData(fieldValue);
return (
<RecordChip
objectNameSingular={
fieldDefinition.metadata.relationObjectMetadataNameSingular
}
record={fieldValue as unknown as ObjectRecord} // Todo: Fix this type
maxWidth={maxWidth}
<EntityChip
entityId={fieldValue.id}
name={recordChipData.name as any}
avatarType={recordChipData.avatarType}
avatarUrl={getImageAbsoluteURIOrBase64(recordChipData.avatarUrl) || ''}
linkToEntity={recordChipData.linkToShowPage}
/>
);
};

View File

@ -10,8 +10,11 @@ import {
useSetRecordValue,
} from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
import { getLogoUrlFromDomainName } from '~/utils';
import { relationFieldDisplayMock } from './mock';
@ -49,20 +52,35 @@ const meta: Meta = {
MemoryRouterDecorator,
(Story) => (
<RecordFieldValueSelectorContextProvider>
<FieldContext.Provider
value={{
entityId: relationFieldDisplayMock.entityId,
basePathToShowPage: '/object-record/',
isLabelIdentifier: false,
fieldDefinition: {
...relationFieldDisplayMock.fieldDefinition,
},
hotkeyScope: 'hotkey-scope',
}}
<RecordTableContext.Provider
value={
{
recordChipDataGeneratorPerFieldName: {
company: (objectRecord: ObjectRecord) => ({
name: objectRecord.name,
avatarType: 'rounded',
avatarUrl: getLogoUrlFromDomainName(objectRecord.domainName),
linkToShowPage: '/object-record/company',
}),
},
} as any
}
>
<RelationFieldValueSetterEffect />
<Story />
</FieldContext.Provider>
<FieldContext.Provider
value={{
entityId: relationFieldDisplayMock.entityId,
basePathToShowPage: '/object-record/',
isLabelIdentifier: false,
fieldDefinition: {
...relationFieldDisplayMock.fieldDefinition,
},
hotkeyScope: 'hotkey-scope',
}}
>
<RelationFieldValueSetterEffect />
<Story />
</FieldContext.Provider>
</RecordTableContext.Provider>
</RecordFieldValueSelectorContextProvider>
),
ComponentDecorator,

View File

@ -1,6 +1,7 @@
import { useContext } from 'react';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { FIELD_EDIT_BUTTON_WIDTH } from '@/ui/field/display/constants/FieldEditButtonWidth';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
@ -29,9 +30,17 @@ export const useRelationFieldDisplay = () => {
? maxWidth - FIELD_EDIT_BUTTON_WIDTH
: maxWidth;
const { recordChipDataGeneratorPerFieldName } =
useContext(RecordTableContext);
const generateRecordChipData =
recordChipDataGeneratorPerFieldName[fieldDefinition.metadata.fieldName];
return {
fieldDefinition,
fieldValue,
maxWidth: maxWidthForField,
entityId,
generateRecordChipData,
};
};

View File

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

View File

@ -1,5 +1,6 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordTableBody } from '@/object-record/record-table/components/RecordTableBody';
@ -8,6 +9,7 @@ import { RecordTableHeader } from '@/object-record/record-table/components/Recor
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { useHandleContainerMouseEnter } from '@/object-record/record-table/hooks/internal/useHandleContainerMouseEnter';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { useRecordChipDataGenerator } from '@/object-record/record-table/hooks/useRecordChipDataGenerator';
import { useRecordTableMoveFocus } from '@/object-record/record-table/hooks/useRecordTableMoveFocus';
import { useCloseRecordTableCellV2 } from '@/object-record/record-table/record-table-cell/hooks/useCloseRecordTableCellV2';
import { useMoveSoftFocusToCellOnHoverV2 } from '@/object-record/record-table/record-table-cell/hooks/useMoveSoftFocusToCellOnHoverV2';
@ -145,7 +147,8 @@ export const RecordTable = ({
onColumnsChange,
createRecord,
}: RecordTableProps) => {
const { scopeId } = useRecordTableStates(recordTableId);
const { scopeId, visibleTableColumnsSelector } =
useRecordTableStates(recordTableId);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@ -204,6 +207,13 @@ export const RecordTable = ({
recordTableId,
});
const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector());
const recordChipDataGeneratorPerFieldName = useRecordChipDataGenerator({
objectNameSingular,
visibleTableColumns,
});
return (
<RecordTableScope
recordTableScopeId={scopeId}
@ -220,6 +230,8 @@ export const RecordTable = ({
onMoveSoftFocusToCell: handleMoveSoftFocusToCell,
onContextMenu: handleContextMenu,
onCellMouseEnter: handleContainerMouseEnter,
recordChipDataGeneratorPerFieldName,
visibleTableColumns,
}}
>
<StyledTable className="entity-table-cell">

View File

@ -1,9 +1,11 @@
import { useEffect } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { useSetRecoilState } from 'recoil';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { ComponentDecorator } from 'twenty-ui';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import {
RecordFieldValueSelectorContextProvider,
@ -14,36 +16,40 @@ import { RecordTableCellFieldContextWrapper } from '@/object-record/record-table
import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { useRecordChipDataGenerator } from '@/object-record/record-table/hooks/useRecordChipDataGenerator';
import { RecordTableScope } from '@/object-record/record-table/scopes/RecordTableScope';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
import { recordTableCellMock } from './mock';
import { mockPerformance } from './mock';
const objectMetadataItems = getObjectMetadataItemsMock();
const RelationFieldValueSetterEffect = () => {
const setEntity = useSetRecoilState(
recordStoreFamilyState(recordTableCellMock.entityId),
recordStoreFamilyState(mockPerformance.entityId),
);
const setRelationEntity = useSetRecoilState(
recordStoreFamilyState(recordTableCellMock.relationEntityId),
recordStoreFamilyState(mockPerformance.relationEntityId),
);
const setRecordValue = useSetRecordValue();
useEffect(() => {
setEntity(recordTableCellMock.entityValue);
setRelationEntity(recordTableCellMock.relationFieldValue);
const [, setObjectMetadataItems] = useRecoilState(objectMetadataItemsState);
useEffect(() => {
setEntity(mockPerformance.entityValue);
setRelationEntity(mockPerformance.relationFieldValue);
setRecordValue(mockPerformance.entityValue.id, mockPerformance.entityValue);
setRecordValue(
recordTableCellMock.entityValue.id,
recordTableCellMock.entityValue,
mockPerformance.relationFieldValue.id,
mockPerformance.relationFieldValue,
);
setRecordValue(
recordTableCellMock.relationFieldValue.id,
recordTableCellMock.relationFieldValue,
);
}, [setEntity, setRelationEntity, setRecordValue]);
setObjectMetadataItems(objectMetadataItems);
}, [setEntity, setRelationEntity, setRecordValue, setObjectMetadataItems]);
return null;
};
@ -52,66 +58,78 @@ const meta: Meta = {
title: 'RecordIndex/Table/RecordTableCell',
decorators: [
MemoryRouterDecorator,
(Story) => (
<RecordFieldValueSelectorContextProvider>
<RecordTableContext.Provider
value={{
objectMetadataItem: recordTableCellMock.objectMetadataItem as any,
onUpsertRecord: () => {},
onOpenTableCell: () => {},
onMoveFocus: () => {},
onCloseTableCell: () => {},
onMoveSoftFocusToCell: () => {},
onContextMenu: () => {},
onCellMouseEnter: () => {},
}}
>
<RecordTableScope recordTableScopeId="asd" onColumnsChange={() => {}}>
<RecordTableRowContext.Provider
value={{
recordId: recordTableCellMock.entityId,
rowIndex: 0,
pathToShowPage:
getBasePathToShowPage({
objectNameSingular:
recordTableCellMock.entityValue.__typename.toLocaleLowerCase(),
}) + recordTableCellMock.entityId,
isSelected: false,
isReadOnly: false,
}}
(Story) => {
const recordChipDataGeneratorPerFieldName = useRecordChipDataGenerator({
objectNameSingular: mockPerformance.objectMetadataItem.nameSingular,
visibleTableColumns: mockPerformance.visibleTableColumns as any,
});
return (
<RecordFieldValueSelectorContextProvider>
<RecordTableContext.Provider
value={{
objectMetadataItem: mockPerformance.objectMetadataItem as any,
onUpsertRecord: () => {},
onOpenTableCell: () => {},
onMoveFocus: () => {},
onCloseTableCell: () => {},
onMoveSoftFocusToCell: () => {},
onContextMenu: () => {},
onCellMouseEnter: () => {},
recordChipDataGeneratorPerFieldName,
visibleTableColumns: mockPerformance.visibleTableColumns as any,
}}
>
<RecordTableScope
recordTableScopeId="asd"
onColumnsChange={() => {}}
>
<RecordTableCellContext.Provider
<RecordTableRowContext.Provider
value={{
columnDefinition: recordTableCellMock.fieldDefinition,
columnIndex: 0,
recordId: mockPerformance.entityId,
rowIndex: 0,
pathToShowPage:
getBasePathToShowPage({
objectNameSingular:
mockPerformance.entityValue.__typename.toLocaleLowerCase(),
}) + mockPerformance.entityId,
isSelected: false,
isReadOnly: false,
}}
>
<FieldContext.Provider
<RecordTableCellContext.Provider
value={{
entityId: recordTableCellMock.entityId,
basePathToShowPage: '/object-record/',
isLabelIdentifier: false,
fieldDefinition: {
...recordTableCellMock.fieldDefinition,
},
hotkeyScope: 'hotkey-scope',
columnDefinition: mockPerformance.fieldDefinition,
columnIndex: 0,
}}
>
<RelationFieldValueSetterEffect />
<table>
<tbody>
<tr>
<Story />
</tr>
</tbody>
</table>
</FieldContext.Provider>
</RecordTableCellContext.Provider>
</RecordTableRowContext.Provider>
</RecordTableScope>
</RecordTableContext.Provider>
</RecordFieldValueSelectorContextProvider>
),
<FieldContext.Provider
value={{
entityId: mockPerformance.entityId,
basePathToShowPage: '/object-record/',
isLabelIdentifier: false,
fieldDefinition: {
...mockPerformance.fieldDefinition,
},
hotkeyScope: 'hotkey-scope',
}}
>
<RelationFieldValueSetterEffect />
<table>
<tbody>
<tr>
<Story />
</tr>
</tbody>
</table>
</FieldContext.Provider>
</RecordTableCellContext.Provider>
</RecordTableRowContext.Provider>
</RecordTableScope>
</RecordTableContext.Provider>
</RecordFieldValueSelectorContextProvider>
);
},
ComponentDecorator,
],
component: RecordTableCellFieldContextWrapper,

View File

@ -1,6 +1,6 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const recordTableCellMock = {
export const mockPerformance = {
objectMetadataItem: {
__typename: 'object',
id: '4916628e-8570-4242-8970-f58c509e5a93',
@ -880,4 +880,215 @@ export const recordTableCellMock = {
isFilterable: true,
defaultValue: null,
},
visibleTableColumns: [
{
fieldMetadataId: '07a8a574-ed28-4015-b456-c01ff3050e2b',
label: 'Name',
metadata: {
fieldName: 'name',
placeHolder: 'Name',
relationObjectMetadataNameSingular: '',
relationObjectMetadataNamePlural: '',
objectMetadataNameSingular: 'person',
options: null,
},
iconName: 'IconUser',
type: 'FULL_NAME',
position: 0,
size: 210,
isLabelIdentifier: true,
isVisible: true,
viewFieldId: '2953bf2a-4da3-4aab-871b-489acc5cf433',
isSortable: false,
isFilterable: true,
defaultValue: {
lastName: "''",
firstName: "''",
},
},
{
fieldMetadataId: 'ca54aa1d-1ecb-486c-99ea-b8240871a0da',
label: 'Email',
metadata: {
fieldName: 'email',
placeHolder: 'Email',
relationObjectMetadataNameSingular: '',
relationObjectMetadataNamePlural: '',
objectMetadataNameSingular: 'person',
options: null,
},
iconName: 'IconMail',
type: 'EMAIL',
position: 1,
size: 150,
isLabelIdentifier: false,
isVisible: true,
viewFieldId: '356e102f-e561-43fe-9a99-f79a8dff591e',
isSortable: false,
isFilterable: true,
defaultValue: "''",
},
{
fieldMetadataId: '9058056e-36b3-4a3f-9037-f0bca9744296',
label: 'Company',
metadata: {
fieldName: 'company',
placeHolder: 'Company',
relationType: 'TO_ONE_OBJECT',
relationFieldMetadataId: '7b281010-5f47-4771-b3f5-f4bcd24ed1b5',
relationObjectMetadataNameSingular: 'company',
relationObjectMetadataNamePlural: 'companies',
objectMetadataNameSingular: 'person',
options: null,
},
iconName: 'IconBuildingSkyscraper',
type: 'RELATION',
position: 2,
size: 150,
isLabelIdentifier: false,
isVisible: true,
viewFieldId: '9a479a97-deaa-4ddb-9d59-96f05875ac09',
isSortable: false,
isFilterable: true,
defaultValue: null,
},
{
fieldMetadataId: 'cc63e38f-56d6-495e-a545-edf101e400cf',
label: 'Phone',
metadata: {
fieldName: 'phone',
placeHolder: 'Phone',
relationObjectMetadataNameSingular: '',
relationObjectMetadataNamePlural: '',
objectMetadataNameSingular: 'person',
options: null,
},
iconName: 'IconPhone',
type: 'TEXT',
position: 3,
size: 150,
isLabelIdentifier: false,
isVisible: true,
viewFieldId: '45fdb554-aaca-4f0a-8c8c-af0a7b3dc69b',
isSortable: true,
isFilterable: true,
defaultValue: "''",
},
{
fieldMetadataId: 'f0a290ac-fa74-48da-a77f-db221cb0206a',
label: 'Creation date',
metadata: {
fieldName: 'createdAt',
placeHolder: 'Creation date',
relationObjectMetadataNameSingular: '',
relationObjectMetadataNamePlural: '',
objectMetadataNameSingular: 'person',
options: null,
},
iconName: 'IconCalendar',
type: 'DATE_TIME',
position: 4,
size: 150,
isLabelIdentifier: false,
isVisible: true,
viewFieldId: 'bba977df-5a14-4023-a966-3a6ca4c04985',
isSortable: true,
isFilterable: true,
defaultValue: 'now',
},
{
fieldMetadataId: '21238919-5d92-402e-8124-367948ef86e6',
label: 'City',
metadata: {
fieldName: 'city',
placeHolder: 'City',
relationObjectMetadataNameSingular: '',
relationObjectMetadataNamePlural: '',
objectMetadataNameSingular: 'person',
options: null,
},
iconName: 'IconMap',
type: 'TEXT',
position: 5,
size: 150,
isLabelIdentifier: false,
isVisible: true,
viewFieldId: '3c8c9615-b645-46a0-9dc9-5a9f5cb8016f',
isSortable: true,
isFilterable: true,
defaultValue: "''",
},
{
fieldMetadataId: '54561a8e-b918-471b-a363-5a77f49cd348',
label: 'Job Title',
metadata: {
fieldName: 'jobTitle',
placeHolder: 'Job Title',
relationObjectMetadataNameSingular: '',
relationObjectMetadataNamePlural: '',
objectMetadataNameSingular: 'person',
options: null,
},
iconName: 'IconBriefcase',
type: 'TEXT',
position: 6,
size: 150,
isLabelIdentifier: false,
isVisible: true,
viewFieldId: '26ef3a6d-1a26-4a56-baf7-ba863d29d9fb',
isSortable: true,
isFilterable: true,
defaultValue: "''",
},
{
fieldMetadataId: '430af81e-2a8c-4ce2-9969-c0f0e91818bb',
label: 'Linkedin',
metadata: {
fieldName: 'linkedinLink',
placeHolder: 'Linkedin',
relationObjectMetadataNameSingular: '',
relationObjectMetadataNamePlural: '',
objectMetadataNameSingular: 'person',
options: null,
},
iconName: 'IconBrandLinkedin',
type: 'LINK',
position: 7,
size: 150,
isLabelIdentifier: false,
isVisible: true,
viewFieldId: '45496857-28ed-49fe-91d6-03aa369a4c03',
isSortable: false,
isFilterable: true,
defaultValue: {
url: "''",
label: "''",
},
},
{
fieldMetadataId: 'c470144b-6692-47cb-a28f-04610d9d641c',
label: 'X',
metadata: {
fieldName: 'xLink',
placeHolder: 'X',
relationObjectMetadataNameSingular: '',
relationObjectMetadataNamePlural: '',
objectMetadataNameSingular: 'person',
options: null,
},
iconName: 'IconBrandX',
type: 'LINK',
position: 8,
size: 150,
isLabelIdentifier: false,
isVisible: true,
viewFieldId: '37344257-17f0-48f4-a523-1211948cbe99',
isSortable: false,
isFilterable: true,
defaultValue: {
url: "''",
label: "''",
},
},
],
};

View File

@ -1,12 +1,16 @@
import { createContext } from 'react';
import React, { createContext } from 'react';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RecordChipData } from '@/object-record/record-field/types/RecordChipData';
import { HandleContainerMouseEnterArgs } from '@/object-record/record-table/hooks/internal/useHandleContainerMouseEnter';
import { OpenTableCellArgs } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocusDirection';
import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
type RecordTableContextProps = {
export type RecordTableContextProps = {
objectMetadataItem: ObjectMetadataItem;
onUpsertRecord: ({
persistField,
@ -23,6 +27,11 @@ type RecordTableContextProps = {
onMoveSoftFocusToCell: (cellPosition: TableCellPosition) => void;
onContextMenu: (event: React.MouseEvent, recordId: string) => void;
onCellMouseEnter: (args: HandleContainerMouseEnterArgs) => void;
recordChipDataGeneratorPerFieldName: Record<
string,
(record: ObjectRecord) => RecordChipData
>;
visibleTableColumns: ColumnDefinition<FieldMetadata>[];
};
export const RecordTableContext = createContext<RecordTableContextProps>(

View File

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

View File

@ -0,0 +1,32 @@
.td-in-edit-mode {
z-index: 4 !important;
}
.td-not-in-edit-mode {
z-index: 3;
}
.td-is-selected {
background: var(--twentycrm-accent-quaternary);
}
.td-is-not-selected {
background: var(--twentycrm-background-primary);
}
.cell-base-container {
align-items: center;
box-sizing: border-box;
cursor: pointer;
display: flex;
height: 32px;
position: relative;
user-select: none;
}
.cell-base-container-soft-focus {
background: var(--twentycrm-background-transparent-secondary);
border-radius: var(--twentycrm-border-radius-sm);
outline: 1px solid var(--twentycrm-font-color-extra-light);
}

View File

@ -1,5 +1,5 @@
import React, { ReactElement, useContext, useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { clsx } from 'clsx';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
@ -14,27 +14,7 @@ import { RecordTableCellDisplayMode } from './RecordTableCellDisplayMode';
import { RecordTableCellEditMode } from './RecordTableCellEditMode';
import { RecordTableCellSoftFocusMode } from './RecordTableCellSoftFocusMode';
const StyledTd = styled.td<{ isSelected: boolean; isInEditMode: boolean }>`
background: ${({ isSelected, theme }) =>
isSelected ? theme.accent.quaternary : theme.background.primary};
z-index: ${({ isInEditMode }) => (isInEditMode ? '4 !important' : '3')};
`;
const StyledCellBaseContainer = styled.div<{ softFocus: boolean }>`
align-items: center;
box-sizing: border-box;
cursor: pointer;
display: flex;
height: 32px;
position: relative;
user-select: none;
${(props) =>
props.softFocus
? `background: ${props.theme.background.transparent.secondary};
border-radius: ${props.theme.border.radius.sm};
outline: 1px solid ${props.theme.font.color.extraLight};`
: ''}
`;
import styles from './RecordTableCellContainer.module.css';
export type RecordTableCellContainerProps = {
editModeContent: ReactElement;
@ -84,10 +64,6 @@ export const RecordTableCellContainer = ({
setIsHovered(false);
};
const handleContainerMouseMove = () => {
handleContainerMouseEnter();
};
useEffect(() => {
const customEventListener = (event: any) => {
const newHasSoftFocus = event.detail;
@ -130,19 +106,26 @@ export const RecordTableCellContainer = ({
}, [cellPosition]);
return (
<StyledTd
isSelected={isSelected}
<td
className={clsx({
[styles.tdInEditMode]: isInEditMode,
[styles.tdNotInEditMode]: !isInEditMode,
[styles.tdIsSelected]: isSelected,
[styles.tdIsNotSelected]: !isSelected,
})}
onContextMenu={handleContextMenu}
isInEditMode={isInEditMode}
>
<CellHotkeyScopeContext.Provider
value={editHotkeyScope ?? DEFAULT_CELL_SCOPE}
>
<StyledCellBaseContainer
<div
onMouseEnter={handleContainerMouseEnter}
onMouseLeave={handleContainerMouseLeave}
onMouseMove={handleContainerMouseMove}
softFocus={hasSoftFocus}
onMouseMove={handleContainerMouseEnter}
className={clsx({
[styles.cellBaseContainer]: true,
[styles.cellBaseContainerSoftFocus]: hasSoftFocus,
})}
>
{isInEditMode ? (
<RecordTableCellEditMode>{editModeContent}</RecordTableCellEditMode>
@ -158,8 +141,8 @@ export const RecordTableCellContainer = ({
{nonEditModeContent}
</RecordTableCellDisplayMode>
)}
</StyledCellBaseContainer>
</div>
</CellHotkeyScopeContext.Provider>
</StyledTd>
</td>
);
};

View File

@ -0,0 +1,24 @@
.cell-display-outer-container {
align-items: center;
display: flex;
height: 100%;
overflow: hidden;
padding-left: 8px;
padding-right: 4px;
width: 100%;
}
.cell-display-outer-container-soft-focus {
background: var(--twentycrm-background-transparent-secondary);
border-radius: var(--twentycrm-border-radius-sm);
outline: 1px solid var(--twentycrm-font-color-extra-light);
}
.cell-display-inner-container {
align-items: center;
display: flex;
height: 100%;
overflow: hidden;
width: 100%;
}

View File

@ -1,5 +1,7 @@
import { Ref } from 'react';
import styled from '@emotion/styled';
import clsx from 'clsx';
import styles from './RecordTableCellDisplayContainer.module.css';
export type EditableCellDisplayContainerProps = {
softFocus?: boolean;
@ -8,48 +10,23 @@ export type EditableCellDisplayContainerProps = {
isHovered?: boolean;
};
const StyledEditableCellDisplayModeOuterContainer = styled.div<
Pick<EditableCellDisplayContainerProps, 'softFocus' | 'isHovered'>
>`
align-items: center;
display: flex;
height: 100%;
overflow: hidden;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(1)};
width: 100%;
${(props) =>
props.softFocus
? `background: ${props.theme.background.transparent.secondary};
border-radius: ${props.theme.border.radius.sm};
outline: 1px solid ${props.theme.font.color.extraLight};`
: ''}
`;
const StyledEditableCellDisplayModeInnerContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
overflow: hidden;
width: 100%;
`;
export const RecordTableCellDisplayContainer = ({
children,
softFocus,
onClick,
scrollRef,
}: React.PropsWithChildren<EditableCellDisplayContainerProps>) => (
<StyledEditableCellDisplayModeOuterContainer
<div
data-testid={
softFocus ? 'editable-cell-soft-focus-mode' : 'editable-cell-display-mode'
}
onClick={onClick}
softFocus={softFocus}
className={clsx({
[styles.cellDisplayOuterContainer]: true,
[styles.cellDisplayOuterContainerSoftFocus]: softFocus,
})}
ref={scrollRef}
>
<StyledEditableCellDisplayModeInnerContainer>
{children}
</StyledEditableCellDisplayModeInnerContainer>
</StyledEditableCellDisplayModeOuterContainer>
<div className={clsx(styles.cellDisplayInnerContainer)}>{children}</div>
</div>
);