mirror of
https://github.com/lingble/twenty.git
synced 2025-10-29 20:02:29 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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 '';
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 '';
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { AvatarType } from 'twenty-ui';
|
||||
|
||||
export type RecordChipData = {
|
||||
name: string | number;
|
||||
avatarType: AvatarType;
|
||||
avatarUrl: string;
|
||||
linkToShowPage: string;
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "''",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
IconCurrencyDollar,
|
||||
IconCurrencyEuro,
|
||||
IconCurrencyFrank,
|
||||
IconCurrencyKroneSwedish,
|
||||
IconCurrencyKroneCzech,
|
||||
IconCurrencyKroneSwedish,
|
||||
IconCurrencyPound,
|
||||
IconCurrencyRiyal,
|
||||
IconCurrencyYen,
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -62,5 +62,10 @@ export default defineConfig(({ command, mode }) => {
|
||||
REACT_APP_SERVER_BASE_URL,
|
||||
},
|
||||
},
|
||||
css: {
|
||||
modules: {
|
||||
localsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
85
packages/twenty-ui/src/theme/provider/theme.css
Normal file
85
packages/twenty-ui/src/theme/provider/theme.css
Normal 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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user