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

@@ -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",

View File

@@ -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 (
<ThemeProvider theme={theme}>
<Story />

View File

@@ -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,
};

View File

@@ -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';
};

View File

@@ -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 '';
};

View File

@@ -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;
};

View File

@@ -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 '';
};

View File

@@ -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;
};

View File

@@ -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<WorkspaceMember> & {
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,

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>
);

View File

@@ -4,8 +4,8 @@ import {
IconCurrencyDollar,
IconCurrencyEuro,
IconCurrencyFrank,
IconCurrencyKroneSwedish,
IconCurrencyKroneCzech,
IconCurrencyKroneSwedish,
IconCurrencyPound,
IconCurrencyRiyal,
IconCurrencyYen,

View File

@@ -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 <ThemeProvider theme={theme}>{children}</ThemeProvider>;
};

View File

@@ -62,5 +62,10 @@ export default defineConfig(({ command, mode }) => {
REACT_APP_SERVER_BASE_URL,
},
},
css: {
modules: {
localsConvention: 'camelCaseOnly',
},
},
};
});

View File

@@ -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 (
<ThemeProvider theme={theme}>
<Story />

View File

@@ -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%;
}

View File

@@ -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 (
<StyledAvatar
className={className}
avatarUrl={avatarUrl}
placeholder={placeholder}
size={size}
type={type}
entityId={entityId}
<div
className={clsx({
[styles.avatar]: true,
[styles.rounded]: type === 'rounded',
[styles.avatarOnClick]: !isUndefined(onClick),
})}
onClick={onClick}
color={fixedColor}
backgroundColor={fixedBackgroundColor}
style={{
color: fixedColor,
backgroundColor: showBackgroundColor ? fixedBackgroundColor : 'none',
width: propertiesBySize[size].width,
height: propertiesBySize[size].width,
fontSize: propertiesBySize[size].fontSize,
}}
>
{(noAvatarUrl || isInvalidAvatarUrl) &&
placeholder?.[0]?.toLocaleUpperCase()}
</StyledAvatar>
{showPlaceholder ? (
placeholderChar
) : (
<img
src={avatarUrl}
className={styles.avatarImage}
onError={handleImageError}
alt=""
/>
)}
</div>
);
};

View File

@@ -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;
}

View File

@@ -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 (
<StyledContainer
<div
data-testid="chip"
clickable={clickable}
variant={variant}
accent={accent}
size={size}
disabled={disabled}
className={className}
maxWidth={maxWidth}
className={clsx({
[styles.chip]: true,
[styles.clickable]: clickable,
[styles.disabled]: disabled,
[styles.accentTextPrimary]: accent === ChipAccent.TextPrimary,
[styles.accentTextSecondary]: accent === ChipAccent.TextSecondary,
[styles.sizeLarge]: size === ChipSize.Large,
[styles.variantRegular]: variant === ChipVariant.Regular,
[styles.variantHighlighted]: variant === ChipVariant.Highlighted,
[styles.variantRounded]: variant === ChipVariant.Rounded,
[styles.variantTransparent]: variant === ChipVariant.Transparent,
})}
onClick={onClick}
as={to ? Link : 'div'}
to={to ? to : undefined}
>
{leftComponent}
<StyledLabel>
<StyledOverflowingTextWithTooltip size={size} text={label} />
</StyledLabel>
<div className={styles.label}>
<OverflowingTextWithTooltip
size={size === ChipSize.Large ? 'large' : 'small'}
text={label}
/>
</div>
{rightComponent}
</StyledContainer>
</div>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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);
}

View File

@@ -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<HTMLDivElement | null>(null);
const isTitleOverflowing =
(text?.length ?? 0) > 0 &&
!!textElement &&
(textElement.scrollHeight > textElement.clientHeight ||
textElement.scrollWidth > textElement.clientWidth);
const textRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
event.stopPropagation();
@@ -44,23 +43,29 @@ export const OverflowingTextWithTooltip = ({
return (
<>
<StyledOverflowingText
<div
data-testid="tooltip"
className={className}
ref={setTextElement}
className={clsx({
[styles.main]: true,
[styles.cursor]: isTitleOverflowing,
[styles.large]: size === 'large',
})}
ref={textRef}
id={textElementId}
cursorPointer={isTitleOverflowing}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{text}
</StyledOverflowingText>
</div>
{isTitleOverflowing &&
createPortal(
<div onClick={handleTooltipClick}>
<AppTooltip
anchorSelect={`#${textElementId}`}
content={mutliline ? undefined : text ?? ''}
delayHide={0}
delayHide={1}
offset={5}
isOpen
noArrow
place="bottom"
positionStrategy="absolute"

View File

@@ -1,7 +1,7 @@
export const BORDER_COMMON = {
radius: {
xs: '2px',
sm: '4px',
sm: 'var(--twentycrm-border-radius-sm)',
md: '8px',
xl: '20px',
pill: '999px',

View File

@@ -10,7 +10,7 @@ export const FONT_COMMON = {
},
weight: {
regular: 400,
medium: 500,
medium: 'var(--twentycrm-border-radius-sm)',
semiBold: 600,
},
family: 'Inter, sans-serif',

View File

@@ -1,15 +1,21 @@
// ThemeProvider.tsx
import * as React from 'react';
import { ReactNode, useEffect } from 'react';
import { ThemeProvider as EmotionThemeProvider } from '@emotion/react';
import { ThemeType } from '..';
import './theme.css';
type ThemeProviderProps = {
theme: ThemeType;
children: React.ReactNode;
children: ReactNode;
};
const ThemeProvider: React.FC<ThemeProviderProps> = ({ theme, children }) => {
const ThemeProvider = ({ theme, children }: ThemeProviderProps) => {
useEffect(() => {
document.documentElement.className =
theme.name === 'dark' ? 'dark' : 'light';
}, [theme]);
return <EmotionThemeProvider theme={theme}>{children}</EmotionThemeProvider>;
};

View File

@@ -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);
}

View File

@@ -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"