From a0178478d4cac36287e6554c9ca28f12a12b7277 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Fri, 24 May 2024 18:53:37 +0200 Subject: [PATCH] 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 --- package.json | 2 +- packages/twenty-front/.storybook/preview.tsx | 6 + .../.storybook/test-runner-jest.config.js | 2 +- .../object-metadata/utils/getAvatarType.ts | 13 ++ .../object-metadata/utils/getAvatarUrl.ts | 36 +++ .../utils/getImageIdentifierFieldValue.ts | 14 ++ .../utils/getLabelIdentifierFieldValue.ts | 24 ++ .../utils/getLinkToShowPage.ts | 22 ++ .../utils/getObjectRecordIdentifier.ts | 77 ++----- .../object-record/components/RecordChip.tsx | 3 - .../components/RelationFieldDisplay.tsx | 29 ++- .../RelationFieldDisplay.perf.stories.tsx | 44 ++-- .../hooks/useRelationFieldDisplay.ts | 9 + .../record-field/types/RecordChipData.ts | 8 + .../record-table/components/RecordTable.tsx | 14 +- .../perf/RecordTableCell.perf.stories.tsx | 152 +++++++------ .../components/__stories__/perf/mock.ts | 213 +++++++++++++++++- .../contexts/RecordTableContext.ts | 13 +- .../hooks/useRecordChipDataGenerator.ts | 86 +++++++ .../RecordTableCellContainer.module.css | 32 +++ .../components/RecordTableCellContainer.tsx | 51 ++--- ...RecordTableCellDisplayContainer.module.css | 24 ++ .../RecordTableCellDisplayContainer.tsx | 43 +--- .../constants/SettingsFieldCurrencyCodes.ts | 2 +- .../ui/theme/components/AppThemeProvider.tsx | 6 + packages/twenty-front/vite.config.ts | 5 + packages/twenty-ui/.storybook/preview.tsx | 6 + .../avatar/components/Avatar.module.css | 23 ++ .../src/display/avatar/components/Avatar.tsx | 103 ++++----- .../display/chip/components/Chip.module.css | 84 +++++++ .../src/display/chip/components/Chip.tsx | 159 ++----------- .../display/chip/components/EntityChip.tsx | 7 +- .../OverflowingTextWithTooltip.module.css | 20 ++ .../tooltip/OverflowingTextWithTooltip.tsx | 63 +++--- .../src/theme/constants/BorderCommon.ts | 2 +- .../src/theme/constants/FontCommon.ts | 2 +- .../src/theme/provider/ThemeProvider.tsx | 14 +- .../twenty-ui/src/theme/provider/theme.css | 85 +++++++ yarn.lock | 9 +- 39 files changed, 1045 insertions(+), 462 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/getAvatarType.ts create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldValue.ts create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldValue.ts create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/getLinkToShowPage.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/types/RecordChipData.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordChipDataGenerator.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.module.css create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDisplayContainer.module.css create mode 100644 packages/twenty-ui/src/display/avatar/components/Avatar.module.css create mode 100644 packages/twenty-ui/src/display/chip/components/Chip.module.css create mode 100644 packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.module.css create mode 100644 packages/twenty-ui/src/theme/provider/theme.css diff --git a/package.json b/package.json index c8786eca8..c9f200883 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "bullmq": "^4.14.0", "bytes": "^3.1.2", "class-transformer": "^0.5.1", - "clsx": "^1.2.1", + "clsx": "^2.1.1", "cross-env": "^7.0.3", "crypto-js": "^4.2.0", "danger-plugin-todos": "^1.3.1", diff --git a/packages/twenty-front/.storybook/preview.tsx b/packages/twenty-front/.storybook/preview.tsx index c0955e59b..d32e37482 100644 --- a/packages/twenty-front/.storybook/preview.tsx +++ b/packages/twenty-front/.storybook/preview.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { ThemeProvider } from '@emotion/react'; import { Preview } from '@storybook/react'; import { initialize, mswDecorator } from 'msw-storybook-addon'; @@ -31,6 +32,11 @@ const preview: Preview = { (Story) => { const theme = useDarkMode() ? THEME_DARK : THEME_LIGHT; + useEffect(() => { + document.documentElement.className = + theme.name === 'dark' ? 'dark' : 'light'; + }, [theme]); + return ( diff --git a/packages/twenty-front/.storybook/test-runner-jest.config.js b/packages/twenty-front/.storybook/test-runner-jest.config.js index 29994547e..b08d8e6af 100644 --- a/packages/twenty-front/.storybook/test-runner-jest.config.js +++ b/packages/twenty-front/.storybook/test-runner-jest.config.js @@ -11,5 +11,5 @@ export default { /** Add your own overrides below * @see https://jestjs.io/docs/configuration */ - testTimeout: 2 * MINUTES_IN_MS, + testTimeout: 5 * MINUTES_IN_MS, }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getAvatarType.ts b/packages/twenty-front/src/modules/object-metadata/utils/getAvatarType.ts new file mode 100644 index 000000000..b227959d1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getAvatarType.ts @@ -0,0 +1,13 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; + +export const getAvatarType = (objectNameSingular: string) => { + if (objectNameSingular === CoreObjectNameSingular.WorkspaceMember) { + return 'rounded'; + } + + if (objectNameSingular === CoreObjectNameSingular.Company) { + return 'squared'; + } + + return 'rounded'; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts b/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts new file mode 100644 index 000000000..69e9a38c0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts @@ -0,0 +1,36 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { getLogoUrlFromDomainName } from '~/utils'; +import { isDefined } from '~/utils/isDefined'; + +import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue'; + +export const getAvatarUrl = ( + objectNameSingular: string, + record: ObjectRecord, + imageIdentifierFieldMetadataItem: FieldMetadataItem | undefined, +) => { + if (objectNameSingular === CoreObjectNameSingular.WorkspaceMember) { + return record.avatarUrl ?? undefined; + } + + if (objectNameSingular === CoreObjectNameSingular.Company) { + return getLogoUrlFromDomainName(record.domainName ?? ''); + } + + if (objectNameSingular === CoreObjectNameSingular.Person) { + return record.avatarUrl ?? ''; + } + + const imageIdentifierFieldValue = getImageIdentifierFieldValue( + record, + imageIdentifierFieldMetadataItem, + ); + + if (isDefined(imageIdentifierFieldValue)) { + return imageIdentifierFieldValue; + } + + return ''; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldValue.ts b/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldValue.ts new file mode 100644 index 000000000..2c04bfe10 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldValue.ts @@ -0,0 +1,14 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isDefined } from '~/utils/isDefined'; + +export const getImageIdentifierFieldValue = ( + record: ObjectRecord, + imageIdentifierFieldMetadataItem: FieldMetadataItem | undefined, +) => { + if (isDefined(imageIdentifierFieldMetadataItem?.name)) { + return record[imageIdentifierFieldMetadataItem.name] as string; + } + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldValue.ts b/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldValue.ts new file mode 100644 index 000000000..796004780 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldValue.ts @@ -0,0 +1,24 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; + +export const getLabelIdentifierFieldValue = ( + record: ObjectRecord, + labelIdentifierFieldMetadataItem: FieldMetadataItem | undefined, + objectNameSingular: string, +) => { + if ( + objectNameSingular === CoreObjectNameSingular.WorkspaceMember || + labelIdentifierFieldMetadataItem?.type === FieldMetadataType.FullName + ) { + return `${record.name?.firstName ?? ''} ${record.name?.lastName ?? ''}`; + } + + if (isDefined(labelIdentifierFieldMetadataItem?.name)) { + return record[labelIdentifierFieldMetadataItem.name] as string | number; + } + + return ''; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getLinkToShowPage.ts b/packages/twenty-front/src/modules/object-metadata/utils/getLinkToShowPage.ts new file mode 100644 index 000000000..c5b747a1e --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getLinkToShowPage.ts @@ -0,0 +1,22 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; + +export const getLinkToShowPage = ( + objectNameSingular: string, + record: ObjectRecord, +) => { + const basePathToShowPage = getBasePathToShowPage({ + objectNameSingular, + }); + + const isWorkspaceMemberObjectMetadata = + objectNameSingular === CoreObjectNameSingular.WorkspaceMember; + + const linkToShowPage = + isWorkspaceMemberObjectMetadata || !record.id + ? '' + : `${basePathToShowPage}${record.id}`; + + return linkToShowPage; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts index 6b2fb1dfa..c47872e56 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts @@ -1,12 +1,12 @@ -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier'; -import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { getLogoUrlFromDomainName } from '~/utils'; + +import { getAvatarType } from './getAvatarType'; +import { getAvatarUrl } from './getAvatarUrl'; +import { getLabelIdentifierFieldValue } from './getLabelIdentifierFieldValue'; +import { getLinkToShowPage } from './getLinkToShowPage'; export const getObjectRecordIdentifier = ({ objectMetadataItem, @@ -15,69 +15,32 @@ export const getObjectRecordIdentifier = ({ objectMetadataItem: ObjectMetadataItem; record: ObjectRecord; }): ObjectRecordIdentifier => { - switch (objectMetadataItem.nameSingular) { - case CoreObjectNameSingular.WorkspaceMember: { - const workspaceMember = record as Partial & { - id: string; - }; - - const name = workspaceMember.name - ? `${workspaceMember.name?.firstName ?? ''} ${ - workspaceMember.name?.lastName ?? '' - }` - : ''; - - return { - id: workspaceMember.id, - name, - avatarUrl: workspaceMember.avatarUrl ?? undefined, - avatarType: 'rounded', - }; - } - } - const labelIdentifierFieldMetadataItem = getLabelIdentifierFieldMetadataItem(objectMetadataItem); - const labelIdentifierFieldValue = - labelIdentifierFieldMetadataItem?.type === FieldMetadataType.FullName - ? `${record.name?.firstName ?? ''} ${record.name?.lastName ?? ''}` - : labelIdentifierFieldMetadataItem?.name - ? (record[labelIdentifierFieldMetadataItem.name] as string | number) - : ''; + const labelIdentifierFieldValue = getLabelIdentifierFieldValue( + record, + labelIdentifierFieldMetadataItem, + objectMetadataItem.nameSingular, + ); const imageIdentifierFieldMetadata = objectMetadataItem.fields.find( (field) => field.id === objectMetadataItem.imageIdentifierFieldMetadataId, ); - const imageIdentifierFieldValue = imageIdentifierFieldMetadata - ? (record[imageIdentifierFieldMetadata.name] as string) - : null; - - const avatarType = - objectMetadataItem.nameSingular === CoreObjectNameSingular.Company - ? 'squared' - : 'rounded'; + const avatarType = getAvatarType(objectMetadataItem.nameSingular); // TODO: This is a temporary solution before we seed imageIdentifierFieldMetadataId in the database - const avatarUrl = - (objectMetadataItem.nameSingular === CoreObjectNameSingular.Company - ? getLogoUrlFromDomainName(record.domainName ?? '') - : objectMetadataItem.nameSingular === CoreObjectNameSingular.Person - ? record.avatarUrl ?? '' - : imageIdentifierFieldValue) ?? ''; + const avatarUrl = getAvatarUrl( + objectMetadataItem.nameSingular, + record, + imageIdentifierFieldMetadata, + ); - const basePathToShowPage = getBasePathToShowPage({ - objectNameSingular: objectMetadataItem.nameSingular, - }); - - const isWorkspaceMemberObjectMetadata = - objectMetadataItem.nameSingular === CoreObjectNameSingular.WorkspaceMember; - - const linkToShowPage = - isWorkspaceMemberObjectMetadata || !record.id - ? '' - : `${basePathToShowPage}${record.id}`; + const linkToShowPage = getLinkToShowPage( + objectMetadataItem.nameSingular, + record, + ); return { id: record.id, diff --git a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx index a8e02f90e..ad5cc2217 100644 --- a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx +++ b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx @@ -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} /> diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFieldDisplay.tsx index 63a7bcbee..5c1669612 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFieldDisplay.tsx @@ -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 ( - ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationFieldDisplay.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationFieldDisplay.perf.stories.tsx index a9304d0d2..7dd23f372 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationFieldDisplay.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/RelationFieldDisplay.perf.stories.tsx @@ -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) => ( - ({ + name: objectRecord.name, + avatarType: 'rounded', + avatarUrl: getLogoUrlFromDomainName(objectRecord.domainName), + linkToShowPage: '/object-record/company', + }), + }, + } as any + } > - - - + + + + + ), ComponentDecorator, 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 index ef5689587..1e702886a 100644 --- 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 @@ -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, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/RecordChipData.ts b/packages/twenty-front/src/modules/object-record/record-field/types/RecordChipData.ts new file mode 100644 index 000000000..77c80b609 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/RecordChipData.ts @@ -0,0 +1,8 @@ +import { AvatarType } from 'twenty-ui'; + +export type RecordChipData = { + name: string | number; + avatarType: AvatarType; + avatarUrl: string; + linkToShowPage: string; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx index 24b03a7b7..1916d6516 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx @@ -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 ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx index 8ddd03dc7..bd03e8c2f 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx @@ -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) => ( - - {}, - onOpenTableCell: () => {}, - onMoveFocus: () => {}, - onCloseTableCell: () => {}, - onMoveSoftFocusToCell: () => {}, - onContextMenu: () => {}, - onCellMouseEnter: () => {}, - }} - > - {}}> - { + const recordChipDataGeneratorPerFieldName = useRecordChipDataGenerator({ + objectNameSingular: mockPerformance.objectMetadataItem.nameSingular, + visibleTableColumns: mockPerformance.visibleTableColumns as any, + }); + + return ( + + {}, + onOpenTableCell: () => {}, + onMoveFocus: () => {}, + onCloseTableCell: () => {}, + onMoveSoftFocusToCell: () => {}, + onContextMenu: () => {}, + onCellMouseEnter: () => {}, + recordChipDataGeneratorPerFieldName, + visibleTableColumns: mockPerformance.visibleTableColumns as any, + }} + > + {}} > - - - - - - - - - -
-
-
-
-
-
-
- ), + + + + + + + + +
+
+ + +
+ + + ); + }, ComponentDecorator, ], component: RecordTableCellFieldContextWrapper, 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 index 9b3191bae..ec60d500f 100644 --- 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 @@ -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: "''", + }, + }, + ], }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts b/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts index d3a9c7dbd..49445bfd9 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts @@ -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[]; }; export const RecordTableContext = createContext( diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordChipDataGenerator.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordChipDataGenerator.ts new file mode 100644 index 000000000..dd8cd1c58 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordChipDataGenerator.ts @@ -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[]; +}) => { + 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]); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.module.css b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.module.css new file mode 100644 index 000000000..eef506c67 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.module.css @@ -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); +} + 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 90200dc23..9d302bda9 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,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 ( - - {isInEditMode ? ( {editModeContent} @@ -158,8 +141,8 @@ export const RecordTableCellContainer = ({ {nonEditModeContent} )} - + - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDisplayContainer.module.css b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDisplayContainer.module.css new file mode 100644 index 000000000..d2184fcda --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDisplayContainer.module.css @@ -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%; +} + diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDisplayContainer.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDisplayContainer.tsx index 4a1c1d787..f2db2901d 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDisplayContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellDisplayContainer.tsx @@ -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 ->` - 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) => ( - - - {children} - - +
{children}
+ ); diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldCurrencyCodes.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldCurrencyCodes.ts index 53df2ab5c..c84df4323 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldCurrencyCodes.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldCurrencyCodes.ts @@ -4,8 +4,8 @@ import { IconCurrencyDollar, IconCurrencyEuro, IconCurrencyFrank, - IconCurrencyKroneSwedish, IconCurrencyKroneCzech, + IconCurrencyKroneSwedish, IconCurrencyPound, IconCurrencyRiyal, IconCurrencyYen, diff --git a/packages/twenty-front/src/modules/ui/theme/components/AppThemeProvider.tsx b/packages/twenty-front/src/modules/ui/theme/components/AppThemeProvider.tsx index e4d03580a..ff3d685a2 100644 --- a/packages/twenty-front/src/modules/ui/theme/components/AppThemeProvider.tsx +++ b/packages/twenty-front/src/modules/ui/theme/components/AppThemeProvider.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { ThemeProvider } from '@emotion/react'; import { THEME_DARK, THEME_LIGHT } from 'twenty-ui'; @@ -18,5 +19,10 @@ export const AppThemeProvider = ({ children }: AppThemeProviderProps) => { const theme = computedColorScheme === 'Dark' ? THEME_DARK : THEME_LIGHT; + useEffect(() => { + document.documentElement.className = + theme.name === 'dark' ? 'dark' : 'light'; + }, [theme]); + return {children}; }; diff --git a/packages/twenty-front/vite.config.ts b/packages/twenty-front/vite.config.ts index 53647c275..67dd65707 100644 --- a/packages/twenty-front/vite.config.ts +++ b/packages/twenty-front/vite.config.ts @@ -62,5 +62,10 @@ export default defineConfig(({ command, mode }) => { REACT_APP_SERVER_BASE_URL, }, }, + css: { + modules: { + localsConvention: 'camelCaseOnly', + }, + }, }; }); diff --git a/packages/twenty-ui/.storybook/preview.tsx b/packages/twenty-ui/.storybook/preview.tsx index 83da6ca43..a82808e74 100644 --- a/packages/twenty-ui/.storybook/preview.tsx +++ b/packages/twenty-ui/.storybook/preview.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { ThemeProvider } from '@emotion/react'; import { Preview } from '@storybook/react'; import { useDarkMode } from 'storybook-dark-mode'; @@ -10,6 +11,11 @@ const preview: Preview = { const mode = useDarkMode() ? 'Dark' : 'Light'; const theme = mode === 'Dark' ? THEME_DARK : THEME_LIGHT; + + useEffect(() => { + document.documentElement.className = mode === 'Dark' ? 'dark' : 'light'; + }, [mode]); + return ( diff --git a/packages/twenty-ui/src/display/avatar/components/Avatar.module.css b/packages/twenty-ui/src/display/avatar/components/Avatar.module.css new file mode 100644 index 000000000..ed560dead --- /dev/null +++ b/packages/twenty-ui/src/display/avatar/components/Avatar.module.css @@ -0,0 +1,23 @@ +.avatar { + align-items: center; + border-radius: 2px; + display: flex; + flex-shrink: 0; + justify-content: center; + overflow: hidden; + user-select: none; +} + +.rounded { + border-radius: 50%; +} + +.avatar-on-click:hover { + box-shadow: 0 0 0 4px var(--twentycrm-background-transparent-light); +} + +.avatar-image { + object-fit: cover; + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/packages/twenty-ui/src/display/avatar/components/Avatar.tsx b/packages/twenty-ui/src/display/avatar/components/Avatar.tsx index 16def94d2..d7024b347 100644 --- a/packages/twenty-ui/src/display/avatar/components/Avatar.tsx +++ b/packages/twenty-ui/src/display/avatar/components/Avatar.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from 'react'; -import styled from '@emotion/styled'; -import { isNonEmptyString } from '@sniptt/guards'; +import { useState } from 'react'; +import { isNonEmptyString, isUndefined } from '@sniptt/guards'; +import clsx from 'clsx'; import { Nullable, stringToHslColor } from '@ui/utilities'; +import styles from './Avatar.module.css'; + export type AvatarType = 'squared' | 'rounded'; export type AvatarSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs'; @@ -43,37 +45,8 @@ const propertiesBySize = { }, }; -export const StyledAvatar = styled.div< - AvatarProps & { color: string; backgroundColor: string } ->` - align-items: center; - background-color: ${({ backgroundColor }) => backgroundColor}; - ${({ avatarUrl }) => - isNonEmptyString(avatarUrl) ? `background-image: url(${avatarUrl});` : ''} - background-position: center; - background-size: cover; - border-radius: ${(props) => (props.type === 'rounded' ? '50%' : '2px')}; - color: ${({ color }) => color}; - cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')}; - display: flex; - - flex-shrink: 0; - font-size: ${({ size = 'md' }) => propertiesBySize[size].fontSize}; - font-weight: ${({ theme }) => theme.font.weight.medium}; - - height: ${({ size = 'md' }) => propertiesBySize[size].width}; - justify-content: center; - width: ${({ size = 'md' }) => propertiesBySize[size].width}; - - &:hover { - box-shadow: ${({ theme, onClick }) => - onClick ? '0 0 0 4px ' + theme.background.transparent.light : 'unset'}; - } -`; - export const Avatar = ({ avatarUrl, - className, size = 'md', placeholder, entityId = placeholder, @@ -82,42 +55,50 @@ export const Avatar = ({ color, backgroundColor, }: AvatarProps) => { - const noAvatarUrl = !isNonEmptyString(avatarUrl); const [isInvalidAvatarUrl, setIsInvalidAvatarUrl] = useState(false); - useEffect(() => { - if (isNonEmptyString(avatarUrl)) { - new Promise((resolve) => { - const img = new Image(); - img.onload = () => resolve(false); - img.onerror = () => resolve(true); - img.src = avatarUrl; - }).then((res) => { - setIsInvalidAvatarUrl(res as boolean); - }); - } - }, [avatarUrl]); + + const noAvatarUrl = !isNonEmptyString(avatarUrl); + + const placeholderChar = placeholder?.[0]?.toLocaleUpperCase(); + + const showPlaceholder = noAvatarUrl || isInvalidAvatarUrl; + + const handleImageError = () => { + setIsInvalidAvatarUrl(true); + }; const fixedColor = color ?? stringToHslColor(entityId ?? '', 75, 25); const fixedBackgroundColor = - backgroundColor ?? - (!isNonEmptyString(avatarUrl) - ? stringToHslColor(entityId ?? '', 75, 85) - : 'none'); + backgroundColor ?? stringToHslColor(entityId ?? '', 75, 85); + + const showBackgroundColor = showPlaceholder; return ( - - {(noAvatarUrl || isInvalidAvatarUrl) && - placeholder?.[0]?.toLocaleUpperCase()} - + {showPlaceholder ? ( + placeholderChar + ) : ( + + )} + ); }; diff --git a/packages/twenty-ui/src/display/chip/components/Chip.module.css b/packages/twenty-ui/src/display/chip/components/Chip.module.css new file mode 100644 index 000000000..1183d0201 --- /dev/null +++ b/packages/twenty-ui/src/display/chip/components/Chip.module.css @@ -0,0 +1,84 @@ +.label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chip { + --chip-horizontal-padding: calc(var(--twentycrm-spacing-multiplicator) * 1px); + --chip-vertical-padding: calc(var(--twentycrm-spacing-multiplicator) * 1px); + + align-items: center; + border-radius: var(--twentycrm-border-radius-sm); + + color: var(--twentycrm-font-color-secondary); + + display: inline-flex; + justify-content: center; + + gap: calc(var(--twentycrm-spacing-multiplicator) * 1px); + height: calc(var(--twentycrm-spacing-multiplicator) * 3px); + + max-width: calc(100% - var(--chip-horizontal-padding) * 2px); + overflow: hidden; + + padding: var(--chip-vertical-padding) var(--chip-horizontal-padding); + + user-select: none; +} + +.disabled { + cursor: not-allowed; + + color: var(--twentycrm-font-color-light); + +} + +.clickable { + cursor: pointer; +} + +.accent-text-primary { + color: var(--twentycrm-font-color-primary); +} + +.accent-text-secondary { + font-weight: var(--twentycrm-font-weight-medium); +} + +.size-large { + height: calc(var(--twentycrm-spacing-multiplicator) * 4px); +} + +.variant-regular:hover { + background-color: var(--twentycrm-background-transparent-light); +} + +.variant-regular:active { + background-color: var(--twentycrm-background-transparent-medium); +} + +.variant-highlighted { + background-color: var(--twentycrm-background-transparent-light); +} + +.variant-highlighted:hover { + background-color: var(--twentycrm-background-transparent-medium); +} + +.variant-highlighted:active { + background-color: var(--twentycrm-background-transparent-strong); +} + +.variant-rounded { + --chip-horizontal-padding: calc(var(--twentycrm-spacing-multiplicator) * 2px); + --chip-vertical-padding: 3px; + + background-color: var(--twentycrm-background-transparent-light); + border: 1px solid var(--twentycrm-border-color-medium); + border-radius: 50px; +} + +.variant-transparent { + cursor: inherit; +} \ No newline at end of file diff --git a/packages/twenty-ui/src/display/chip/components/Chip.tsx b/packages/twenty-ui/src/display/chip/components/Chip.tsx index 18369254b..ac1a9c303 100644 --- a/packages/twenty-ui/src/display/chip/components/Chip.tsx +++ b/packages/twenty-ui/src/display/chip/components/Chip.tsx @@ -1,11 +1,10 @@ import { MouseEvent, ReactNode } from 'react'; -import { Link } from 'react-router-dom'; -import isPropValid from '@emotion/is-prop-valid'; -import { css } from '@emotion/react'; -import styled from '@emotion/styled'; +import { clsx } from 'clsx'; import { OverflowingTextWithTooltip } from '@ui/display/tooltip/OverflowingTextWithTooltip'; +import styles from './Chip.module.css'; + export enum ChipSize { Large = 'large', Small = 'small', @@ -38,121 +37,6 @@ type ChipProps = { to?: string; }; -const StyledContainer = styled('div', { - shouldForwardProp: (prop) => - !['clickable', 'maxWidth'].includes(prop) && isPropValid(prop), -})< - Pick< - ChipProps, - 'accent' | 'clickable' | 'disabled' | 'maxWidth' | 'size' | 'variant' | 'to' - > ->` - --chip-horizontal-padding: ${({ theme }) => theme.spacing(1)}; - --chip-vertical-padding: ${({ theme }) => theme.spacing(1)}; - - text-decoration: none; - align-items: center; - border-radius: ${({ theme }) => theme.border.radius.sm}; - color: ${({ theme, disabled }) => - disabled ? theme.font.color.light : theme.font.color.secondary}; - cursor: ${({ clickable, disabled }) => - clickable ? 'pointer' : disabled ? 'not-allowed' : 'inherit'}; - display: inline-flex; - justify-content: center; - gap: ${({ theme }) => theme.spacing(1)}; - height: ${({ theme }) => theme.spacing(3)}; - max-width: ${({ maxWidth }) => - maxWidth - ? `calc(${maxWidth}px - 2 * var(--chip-horizontal-padding))` - : '200px'}; - overflow: hidden; - padding: var(--chip-vertical-padding) var(--chip-horizontal-padding); - user-select: none; - - // Accent style overrides - ${({ accent, disabled, theme }) => { - if (accent === ChipAccent.TextPrimary) { - return ( - !disabled && - css` - color: ${theme.font.color.primary}; - ` - ); - } - - if (accent === ChipAccent.TextSecondary) { - return css` - font-weight: ${theme.font.weight.medium}; - `; - } - }} - - // Variant style overrides - ${({ disabled, theme, variant }) => { - if (variant === ChipVariant.Regular) { - return ( - !disabled && - css` - :hover { - background-color: ${theme.background.transparent.light}; - } - - :active { - background-color: ${theme.background.transparent.medium}; - } - ` - ); - } - - if (variant === ChipVariant.Highlighted) { - return css` - background-color: ${theme.background.transparent.light}; - - ${!disabled && - css` - :hover { - background-color: ${theme.background.transparent.medium}; - } - - :active { - background-color: ${theme.background.transparent.strong}; - } - `} - `; - } - - if (variant === ChipVariant.Rounded) { - return css` - --chip-horizontal-padding: ${theme.spacing(2)}; - --chip-vertical-padding: 3px; - - background-color: ${theme.background.transparent.lighter}; - border: 1px solid ${theme.border.color.medium}; - border-radius: 50px; - `; - } - - if (variant === ChipVariant.Transparent) { - return css` - cursor: inherit; - `; - } - }} -`; - -const StyledLabel = styled.span` - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const StyledOverflowingTextWithTooltip = styled(OverflowingTextWithTooltip)<{ - size?: ChipSize; -}>` - height: ${({ theme, size }) => - size === ChipSize.Large ? theme.spacing(4) : 'auto'}; -`; - export const Chip = ({ size = ChipSize.Small, label, @@ -162,30 +46,33 @@ export const Chip = ({ leftComponent, rightComponent, accent = ChipAccent.TextPrimary, - maxWidth, - className, onClick, - to, }: ChipProps) => { return ( - {leftComponent} - - - +
+ +
{rightComponent} -
+ ); }; diff --git a/packages/twenty-ui/src/display/chip/components/EntityChip.tsx b/packages/twenty-ui/src/display/chip/components/EntityChip.tsx index 399c23a96..33aeffc84 100644 --- a/packages/twenty-ui/src/display/chip/components/EntityChip.tsx +++ b/packages/twenty-ui/src/display/chip/components/EntityChip.tsx @@ -3,11 +3,10 @@ import { useTheme } from '@emotion/react'; import { isNonEmptyString } from '@sniptt/guards'; import { Avatar, AvatarType } from '@ui/display/avatar/components/Avatar'; +import { Chip, ChipVariant } from '@ui/display/chip/components/Chip'; import { IconComponent } from '@ui/display/icon/types/IconComponent'; import { Nullable } from '@ui/utilities/types/Nullable'; -import { Chip, ChipVariant } from './Chip'; - export type EntityChipProps = { linkToEntity?: string; entityId: string; @@ -17,7 +16,6 @@ export type EntityChipProps = { variant?: EntityChipVariant; LeftIcon?: IconComponent; className?: string; - maxWidth?: number; }; export enum EntityChipVariant { @@ -34,7 +32,6 @@ export const EntityChip = ({ variant = EntityChipVariant.Regular, LeftIcon, className, - maxWidth, }: EntityChipProps) => { const theme = useTheme(); @@ -70,8 +67,6 @@ export const EntityChip = ({ clickable={!!linkToEntity} onClick={handleLinkClick} className={className} - maxWidth={maxWidth} - to={linkToEntity} /> ); }; diff --git a/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.module.css b/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.module.css new file mode 100644 index 000000000..2df2dca40 --- /dev/null +++ b/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.module.css @@ -0,0 +1,20 @@ +.main { + font-family: inherit; + font-size: inherit; + + font-weight: inherit; + max-width: 100%; + overflow: hidden; + text-decoration: inherit; + + text-overflow: ellipsis; + white-space: nowrap; +} + +.cursor { + cursor: pointer; +} + +.large { + height: calc(var(--twentycrm-spacing-multiplicator) * 4px); +} \ No newline at end of file diff --git a/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx b/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx index 71d0dbaea..6f0967e86 100644 --- a/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx +++ b/packages/twenty-ui/src/display/tooltip/OverflowingTextWithTooltip.tsx @@ -1,41 +1,40 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import styled from '@emotion/styled'; +import clsx from 'clsx'; import { v4 as uuidV4 } from 'uuid'; import { AppTooltip } from './AppTooltip'; -const StyledOverflowingText = styled.div<{ cursorPointer: boolean }>` - cursor: ${({ cursorPointer }) => (cursorPointer ? 'pointer' : 'inherit')}; - font-family: inherit; - font-size: inherit; - - font-weight: inherit; - max-width: 100%; - overflow: hidden; - text-decoration: inherit; - - text-overflow: ellipsis; - white-space: nowrap; -`; +import styles from './OverflowingTextWithTooltip.module.css'; export const OverflowingTextWithTooltip = ({ + size = 'small', text, - className, mutliline, }: { + size?: 'large' | 'small'; text: string | null | undefined; - className?: string; mutliline?: boolean; }) => { const textElementId = `title-id-${uuidV4()}`; - const [textElement, setTextElement] = useState(null); - const isTitleOverflowing = - (text?.length ?? 0) > 0 && - !!textElement && - (textElement.scrollHeight > textElement.clientHeight || - textElement.scrollWidth > textElement.clientWidth); + const textRef = useRef(null); + + const [isTitleOverflowing, setIsTitleOverflowing] = useState(false); + + const handleMouseEnter = () => { + const isOverflowing = + (text?.length ?? 0) > 0 && textRef.current + ? textRef.current?.scrollHeight > textRef.current?.clientHeight || + textRef.current.scrollWidth > textRef.current.clientWidth + : false; + + setIsTitleOverflowing(isOverflowing); + }; + + const handleMouseLeave = () => { + setIsTitleOverflowing(false); + }; const handleTooltipClick = (event: React.MouseEvent) => { event.stopPropagation(); @@ -44,23 +43,29 @@ export const OverflowingTextWithTooltip = ({ return ( <> - {text} - + {isTitleOverflowing && createPortal(
= ({ theme, children }) => { +const ThemeProvider = ({ theme, children }: ThemeProviderProps) => { + useEffect(() => { + document.documentElement.className = + theme.name === 'dark' ? 'dark' : 'light'; + }, [theme]); + return {children}; }; diff --git a/packages/twenty-ui/src/theme/provider/theme.css b/packages/twenty-ui/src/theme/provider/theme.css new file mode 100644 index 000000000..4f4f00700 --- /dev/null +++ b/packages/twenty-ui/src/theme/provider/theme.css @@ -0,0 +1,85 @@ +:root { + --twentycrm-spacing-multiplicator: 4; + --twentycrm-border-radius-sm: 4px; + --twentycrm-font-weight-medium: 500; + + /* Grays */ + --twentycrm-gray-100: #000000; + --twentycrm-gray-100-4: #0000000A; + --twentycrm-gray-100-10: #00000019; + --twentycrm-gray-100-16: #00000029; + --twentycrm-gray-90: #141414; + --twentycrm-gray-85: #171717; + --twentycrm-gray-85-80: #171717CC; + --twentycrm-gray-80: #1b1b1b; + --twentycrm-gray-80-80: #1b1b1bCC; + --twentycrm-gray-75: #1d1d1d; + --twentycrm-gray-70: #222222; + --twentycrm-gray-65: #292929; + --twentycrm-gray-60: #333333; + --twentycrm-gray-55: #4c4c4c; + --twentycrm-gray-50: #666666; + --twentycrm-gray-45: #818181; + --twentycrm-gray-40: #999999; + --twentycrm-gray-35: #b3b3b3; + --twentycrm-gray-30: #cccccc; + --twentycrm-gray-25: #d6d6d6; + --twentycrm-gray-20: #ebebeb; + --twentycrm-gray-15: #f1f1f1; + --twentycrm-gray-10: #fcfcfc; + --twentycrm-gray-10-80: #fcfcfcCC; + --twentycrm-gray-0: #ffffff; + --twentycrm-gray-0-6: #ffffff0f; + --twentycrm-gray-0-10: #ffffff19; + --twentycrm-gray-0-14: #ffffff23; + + /* Blues */ + --twentycrm-blue-accent-90: #141a25, + --twentycrm-blue-accent-10: #f5f9fd, +} + +:root.dark { + /* Accent color */ + --twentycrm-accent-quaternary: var(--twentycrm-blue-accent-90); + + /* Font color */ + --twentycrm-font-color-secondary: var(--twentycrm-gray-35); + --twentycrm-font-color-primary: var(--twentycrm-gray-20); + --twentycrm-font-color-light: var(--twentycrm-gray-50); + --twentycrm-font-color-extra-light: var(--twentycrm-gray-55); + + /* Background color */ + --twentycrm-background-primary: var(--twentycrm-gray-85); + + /* Background transparent color */ + --twentycrm-background-transparent-secondary: var(--twentycrm-gray-80-80); + --twentycrm-background-transparent-light: var(--twentycrm-gray-0-6); + --twentycrm-background-transparent-medium: var(--twentycrm-gray-0-10); + --twentycrm-background-transparent-strong: var(--twentycrm-gray-0-14); + + /* Border color */ + --twentycrm-border-color-medium: var(--twentycrm-gray-65); +} + +:root.light { + /* Accent color */ + --twentycrm-accent-quaternary: var(--twentycrm-blue-accent-10); + + /* Colors */ + --twentycrm-font-color-primary: var(--twentycrm-gray-60); + --twentycrm-font-color-secondary: var(--twentycrm-gray-50); + --twentycrm-font-color-light: var(--twentycrm-gray-35); + --twentycrm-font-color-extra-light: var(--twentycrm-gray-30); + + /* Background color */ + --twentycrm-background-primary: var(--twentycrm-gray-0); + + /* Background transparent color */ + --twentycrm-background-transparent-secondary: var(--twentycrm-gray-10-80); + --twentycrm-background-transparent-light: var(--twentycrm-gray-100-4); + --twentycrm-background-transparent-medium: var(--twentycrm-gray-100-10); + --twentycrm-background-transparent-strong: var(--twentycrm-gray-100-16); + + /* Border color */ + --twentycrm-border-color-medium: var(--twentycrm-gray-20); +} diff --git a/yarn.lock b/yarn.lock index 6efc5c216..0c30a1271 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22572,6 +22572,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:^2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 + languageName: node + linkType: hard + "cluster-key-slot@npm:1.1.2, cluster-key-slot@npm:^1.1.0": version: 1.1.2 resolution: "cluster-key-slot@npm:1.1.2" @@ -46479,7 +46486,7 @@ __metadata: bytes: "npm:^3.1.2" chromatic: "npm:^6.18.0" class-transformer: "npm:^0.5.1" - clsx: "npm:^1.2.1" + clsx: "npm:^2.1.1" concurrently: "npm:^8.2.2" cross-env: "npm:^7.0.3" cross-var: "npm:^1.1.0"