From 59e14fabb4c8ff3b38e773c3bc0be4b5da4d7faa Mon Sep 17 00:00:00 2001 From: Anand Krishnan M J <87609792+Anand-Krishnan-M-J@users.noreply.github.com> Date: Wed, 14 Aug 2024 20:41:17 +0530 Subject: [PATCH] [Issue-5772] Add sort feature on settings tables (#5787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Proposed Changes - Introduce a new custom hook - useTableSort to sort table content - Add test cases for the new custom hook - Integrate useTableSort hook on to the table in settings object and settings object field pages ## Related Issue https://github.com/twentyhq/twenty/issues/5772 ## Evidence https://github.com/twentyhq/twenty/assets/87609792/8be456ce-2fa5-44ec-8bbd-70fb6c8fdb30 ## Evidence after addressing review comments https://github.com/twentyhq/twenty/assets/87609792/c267e3da-72f9-4c0e-8c94-a38122d6395e ## Further comments Apologies for the large PR. Looking forward for the review --------- Co-authored-by: Félix Malfait Co-authored-by: Lucas Bordeau --- packages/twenty-front/src/App.tsx | 4 +- .../utils/sortCachedObjectEdges.ts | 2 +- .../hooks/useGetObjectOrderByField.ts | 3 +- .../utils/getObjectOrderByField.ts | 3 +- .../utils/getOrderByForFieldMetadataType.ts | 3 +- .../types/RecordGqlOperationOrderBy.ts | 2 +- .../hooks/useCombinedGetTotalCount.ts | 48 ++++ .../utils/turnSortsIntoOrderBy.ts | 3 +- .../select/hooks/useRecordsForSelect.ts | 3 +- .../hooks/useFilteredSearchEntityQuery.ts | 2 +- .../SettingsObjectFieldItemTableRow.tsx | 178 ++++++++++--- .../components/SettingsObjectItemTableRow.tsx | 34 ++- .../states/settingsObjectFieldsFamilyState.ts | 14 ++ .../{Table.tsx => SpreadsheetImportTable.tsx} | 10 +- .../components/SelectHeaderTable.tsx | 4 +- .../UploadStep/components/ExampleTable.tsx | 10 +- .../ValidationStep/ValidationStep.tsx | 4 +- .../{object-metadata => }/types/OrderBy.ts | 0 .../layout/dropdown/components/Dropdown.tsx | 15 +- .../table/components/SortableTableHeader.tsx | 67 +++++ .../layout/table/components/TableHeader.tsx | 6 +- .../hooks/__tests__/useSortedArray.test.tsx | 119 +++++++++ .../ui/layout/table/hooks/useSortedArray.ts | 52 ++++ .../states/sortedFieldByTableFamilyState.ts | 14 ++ .../layout/table/types/TableFieldMetadata.ts | 6 + .../ui/layout/table/types/TableMetadata.ts | 8 + .../ui/layout/table/types/TableSortValue.ts | 6 + .../data-model/SettingsObjectDetail.tsx | 237 ------------------ .../data-model/SettingsObjectDetailPage.tsx | 30 +++ .../SettingsObjectDetailPageContent.tsx | 101 ++++++++ .../data-model/SettingsObjectFieldTable.tsx | 194 ++++++++++++++ .../SettingsObjectNewFieldStep1.tsx | 133 +++------- .../settings/data-model/SettingsObjects.tsx | 132 ++++++++-- .../SettingsObjectDetail.stories.tsx | 6 +- .../constants/SettingsObjectTableMetadata.ts | 37 +++ ...dataItemToSettingsObjectDetailTableItem.ts | 46 ++++ .../types/SettingsObjectDetailTableItem.ts | 12 + .../types/SettingsObjectTableItem.ts | 9 + .../utils/getSettingsObjectFieldType.ts | 30 +++ .../src/testing/mock-data/tableData.ts | 71 ++++++ 40 files changed, 1221 insertions(+), 437 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useCombinedGetTotalCount.ts create mode 100644 packages/twenty-front/src/modules/settings/data-model/object-details/states/settingsObjectFieldsFamilyState.ts rename packages/twenty-front/src/modules/spreadsheet-import/components/{Table.tsx => SpreadsheetImportTable.tsx} (94%) rename packages/twenty-front/src/modules/{object-metadata => }/types/OrderBy.ts (100%) create mode 100644 packages/twenty-front/src/modules/ui/layout/table/components/SortableTableHeader.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/table/hooks/__tests__/useSortedArray.test.tsx create mode 100644 packages/twenty-front/src/modules/ui/layout/table/hooks/useSortedArray.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/table/states/sortedFieldByTableFamilyState.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/table/types/TableFieldMetadata.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/table/types/TableMetadata.ts create mode 100644 packages/twenty-front/src/modules/ui/layout/table/types/TableSortValue.ts delete mode 100644 packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetail.tsx create mode 100644 packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPage.tsx create mode 100644 packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetailPageContent.tsx create mode 100644 packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldTable.tsx create mode 100644 packages/twenty-front/src/pages/settings/data-model/constants/SettingsObjectTableMetadata.ts create mode 100644 packages/twenty-front/src/pages/settings/data-model/hooks/useMapFieldMetadataItemToSettingsObjectDetailTableItem.ts create mode 100644 packages/twenty-front/src/pages/settings/data-model/types/SettingsObjectDetailTableItem.ts create mode 100644 packages/twenty-front/src/pages/settings/data-model/types/SettingsObjectTableItem.ts create mode 100644 packages/twenty-front/src/pages/settings/data-model/utils/getSettingsObjectFieldType.ts create mode 100644 packages/twenty-front/src/testing/mock-data/tableData.ts diff --git a/packages/twenty-front/src/App.tsx b/packages/twenty-front/src/App.tsx index a8eff68866fd..f3668ff0b2da 100644 --- a/packages/twenty-front/src/App.tsx +++ b/packages/twenty-front/src/App.tsx @@ -59,7 +59,7 @@ import { SettingsAccountsEmails } from '~/pages/settings/accounts/SettingsAccoun import { SettingsNewAccount } from '~/pages/settings/accounts/SettingsNewAccount'; import { SettingsCRMMigration } from '~/pages/settings/crm-migration/SettingsCRMMigration'; import { SettingsNewObject } from '~/pages/settings/data-model/SettingsNewObject'; -import { SettingsObjectDetail } from '~/pages/settings/data-model/SettingsObjectDetail'; +import { SettingsObjectDetailPage } from '~/pages/settings/data-model/SettingsObjectDetailPage'; import { SettingsObjectEdit } from '~/pages/settings/data-model/SettingsObjectEdit'; import { SettingsObjectFieldEdit } from '~/pages/settings/data-model/SettingsObjectFieldEdit'; import { SettingsObjectNewFieldStep1 } from '~/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep1'; @@ -218,7 +218,7 @@ const createRouter = ( /> } + element={} /> { + const operationSignatures = objectMetadataItems.map( + (objectMetadataItem) => + ({ + objectNameSingular: objectMetadataItem.nameSingular, + variables: {}, + fields: { + id: true, + }, + }) satisfies RecordGqlOperationSignature, + ); + + const findManyQuery = useGenerateCombinedFindManyRecordsQuery({ + operationSignatures, + }); + + const { data } = useQuery( + findManyQuery ?? EMPTY_QUERY, + { + skip, + }, + ); + + const totalCountByObjectMetadataItemNamePlural = Object.fromEntries( + Object.entries(data ?? {}).map(([namePlural, objectRecordConnection]) => [ + namePlural, + objectRecordConnection.totalCount, + ]), + ); + + return { + totalCountByObjectMetadataItemNamePlural, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy.ts index 5a33d53991cc..b38c0bba8ddf 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy.ts +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy.ts @@ -1,5 +1,5 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { OrderBy } from '@/object-metadata/types/OrderBy'; + import { hasPositionField } from '@/object-metadata/utils/hasPositionField'; import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy'; import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; @@ -8,6 +8,7 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { getOrderByForFieldMetadataType } from '@/object-metadata/utils/getOrderByForFieldMetadataType'; +import { OrderBy } from '@/types/OrderBy'; import { Sort } from '../types/Sort'; export const turnSortsIntoOrderBy = ( diff --git a/packages/twenty-front/src/modules/object-record/select/hooks/useRecordsForSelect.ts b/packages/twenty-front/src/modules/object-record/select/hooks/useRecordsForSelect.ts index cd759451c529..905d4fd8f138 100644 --- a/packages/twenty-front/src/modules/object-record/select/hooks/useRecordsForSelect.ts +++ b/packages/twenty-front/src/modules/object-record/select/hooks/useRecordsForSelect.ts @@ -2,13 +2,14 @@ import { isNonEmptyString } from '@sniptt/guards'; import { useGetObjectOrderByField } from '@/object-metadata/hooks/useGetObjectOrderByField'; import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier'; -import { OrderBy } from '@/object-metadata/types/OrderBy'; + import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { SelectableRecord } from '@/object-record/select/types/SelectableRecord'; import { getObjectFilterFields } from '@/object-record/select/utils/getObjectFilterFields'; import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables'; import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables'; +import { OrderBy } from '@/types/OrderBy'; export const useRecordsForSelect = ({ searchFilterText, diff --git a/packages/twenty-front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts b/packages/twenty-front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts index d50f4bee0604..06ba43f92e60 100644 --- a/packages/twenty-front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts +++ b/packages/twenty-front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts @@ -1,7 +1,6 @@ import { isNonEmptyString } from '@sniptt/guards'; import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier'; -import { OrderBy } from '@/object-metadata/types/OrderBy'; import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit'; import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; @@ -10,6 +9,7 @@ import { EntityForSelect } from '@/object-record/relation-picker/types/EntityFor import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables'; import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables'; +import { OrderBy } from '@/types/OrderBy'; import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields'; import { isDefined } from '~/utils/isDefined'; diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx index f564892e05d2..92c7afecba9e 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow.tsx @@ -1,28 +1,36 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { ReactNode, useMemo } from 'react'; -import { Nullable, useIcons } from 'twenty-ui'; +import { useMemo } from 'react'; +import { IconMinus, IconPlus, isDefined, useIcons } from 'twenty-ui'; import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug'; -import { FieldIdentifierType } from '@/settings/data-model/types/FieldIdentifierType'; import { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableRow } from '@/ui/layout/table/components/TableRow'; import { RELATION_TYPES } from '../../constants/RelationTypes'; +import { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from '@/object-metadata/constants/LabelIdentifierFieldMetadataTypes'; +import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; +import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem'; +import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug'; +import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; +import { SettingsObjectFieldActiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldActiveActionDropdown'; +import { SettingsObjectFieldInactiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldDisabledActionDropdown'; +import { settingsObjectFieldsFamilyState } from '@/settings/data-model/object-details/states/settingsObjectFieldsFamilyState'; +import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; +import { useNavigate } from 'react-router-dom'; +import { useRecoilState } from 'recoil'; import { RelationMetadataType } from '~/generated-metadata/graphql'; +import { SettingsObjectDetailTableItem } from '~/pages/settings/data-model/types/SettingsObjectDetailTableItem'; import { SettingsObjectFieldDataType } from './SettingsObjectFieldDataType'; type SettingsObjectFieldItemTableRowProps = { - ActionIcon: ReactNode; - fieldMetadataItem: FieldMetadataItem; - identifierType?: Nullable; - variant?: 'field-type' | 'identifier'; - isRemoteObjectField?: boolean; - to?: string; + settingsObjectDetailTableItem: SettingsObjectDetailTableItem; + status: 'active' | 'disabled'; + mode: 'view' | 'new-field'; }; export const StyledObjectFieldTableRow = styled(TableRow)` @@ -40,13 +48,19 @@ const StyledIconTableCell = styled(TableCell)` `; export const SettingsObjectFieldItemTableRow = ({ - ActionIcon, - fieldMetadataItem, - identifierType, - variant = 'field-type', - isRemoteObjectField, - to, + settingsObjectDetailTableItem, + mode, + status, }: SettingsObjectFieldItemTableRowProps) => { + const { fieldMetadataItem, identifierType, objectMetadataItem } = + settingsObjectDetailTableItem; + + const isRemoteObjectField = objectMetadataItem.isRemote; + + const variant = objectMetadataItem.isCustom ? 'identifier' : 'field-type'; + + const navigate = useNavigate(); + const theme = useTheme(); const { getIcon } = useIcons(); const Icon = getIcon(fieldMetadataItem.icon); @@ -62,31 +76,94 @@ export const SettingsObjectFieldItemTableRow = ({ const fieldType = fieldMetadataItem.type; const isFieldTypeSupported = isFieldTypeSupportedInSettings(fieldType); - if (!isFieldTypeSupported) return null; - const RelationIcon = relationType ? RELATION_TYPES[relationType].Icon : undefined; + const isLabelIdentifier = isLabelIdentifierField({ + fieldMetadataItem, + objectMetadataItem, + }); + + const canToggleField = !isLabelIdentifier; + + const canBeSetAsLabelIdentifier = + objectMetadataItem.isCustom && + !isLabelIdentifier && + LABEL_IDENTIFIER_FIELD_METADATA_TYPES.includes(fieldMetadataItem.type); + + const linkToNavigate = `./${getFieldSlug(fieldMetadataItem)}`; + + const { + activateMetadataField, + deactivateMetadataField, + deleteMetadataField, + } = useFieldMetadataItem(); + + const handleDisableField = (activeFieldMetadatItem: FieldMetadataItem) => { + deactivateMetadataField(activeFieldMetadatItem); + }; + + const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem(); + + const handleSetLabelIdentifierField = ( + activeFieldMetadatItem: FieldMetadataItem, + ) => + updateOneObjectMetadataItem({ + idToUpdate: objectMetadataItem.id, + updatePayload: { + labelIdentifierFieldMetadataId: activeFieldMetadatItem.id, + }, + }); + + const [, setActiveSettingsObjectFields] = useRecoilState( + settingsObjectFieldsFamilyState({ + objectMetadataItemId: objectMetadataItem.id, + }), + ); + + const handleToggleField = () => { + setActiveSettingsObjectFields((previousFields) => { + const newFields = isDefined(previousFields) + ? previousFields?.map((field) => + field.id === fieldMetadataItem.id + ? { ...field, isActive: !field.isActive } + : field, + ) + : null; + + return newFields; + }); + }; + + const typeLabel = + variant === 'field-type' + ? isRemoteObjectField + ? 'Remote' + : fieldMetadataItem.isCustom + ? 'Custom' + : 'Standard' + : variant === 'identifier' + ? isDefined(identifierType) + ? identifierType === 'label' + ? 'Record text' + : 'Record image' + : '' + : ''; + + if (!isFieldTypeSupported) return null; + return ( - + {!!Icon && ( )} {fieldMetadataItem.label} - - {variant === 'field-type' && - (isRemoteObjectField - ? 'Remote' - : fieldMetadataItem.isCustom - ? 'Custom' - : 'Standard')} - {variant === 'identifier' && - !!identifierType && - (identifierType === 'label' ? 'Record text' : 'Record image')} - + {typeLabel} - {ActionIcon} + + {status === 'active' ? ( + mode === 'view' ? ( + navigate(linkToNavigate)} + onSetAsLabelIdentifier={ + canBeSetAsLabelIdentifier + ? () => handleSetLabelIdentifierField(fieldMetadataItem) + : undefined + } + onDeactivate={ + isLabelIdentifier + ? undefined + : () => handleDisableField(fieldMetadataItem) + } + /> + ) : ( + canToggleField && ( + + ) + ) + ) : mode === 'view' ? ( + activateMetadataField(fieldMetadataItem)} + onDelete={() => deleteMetadataField(fieldMetadataItem)} + /> + ) : ( + + )} + ); }; diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectItemTableRow.tsx b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectItemTableRow.tsx index ed5c7134162d..0c69f4b81afe 100644 --- a/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectItemTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/components/SettingsObjectItemTableRow.tsx @@ -1,19 +1,19 @@ -import { ReactNode } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { ReactNode } from 'react'; import { useIcons } from 'twenty-ui'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { SettingsDataModelObjectTypeTag } from '@/settings/data-model/objects/SettingsDataModelObjectTypeTag'; import { getObjectTypeLabel } from '@/settings/data-model/utils/getObjectTypeLabel'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableRow } from '@/ui/layout/table/components/TableRow'; -type SettingsObjectItemTableRowProps = { +export type SettingsObjectMetadataItemTableRowProps = { action: ReactNode; - objectItem: ObjectMetadataItem; - to?: string; + objectMetadataItem: ObjectMetadataItem; + link?: string; + totalObjectCount: number; }; export const StyledObjectTableRow = styled(TableRow)` @@ -30,35 +30,33 @@ const StyledActionTableCell = styled(TableCell)` padding-right: ${({ theme }) => theme.spacing(1)}; `; -export const SettingsObjectItemTableRow = ({ +export const SettingsObjectMetadataItemTableRow = ({ action, - objectItem, - to, -}: SettingsObjectItemTableRowProps) => { + objectMetadataItem, + link, + totalObjectCount, +}: SettingsObjectMetadataItemTableRowProps) => { const theme = useTheme(); - const { totalCount } = useFindManyRecords({ - objectNameSingular: objectItem.nameSingular, - }); const { getIcon } = useIcons(); - const Icon = getIcon(objectItem.icon); - const objectTypeLabel = getObjectTypeLabel(objectItem); + const Icon = getIcon(objectMetadataItem.icon); + const objectTypeLabel = getObjectTypeLabel(objectMetadataItem); return ( - + {!!Icon && ( )} - {objectItem.labelPlural} + {objectMetadataItem.labelPlural} - {objectItem.fields.filter((field) => !field.isSystem).length} + {objectMetadataItem.fields.filter((field) => !field.isSystem).length} - {totalCount} + {totalObjectCount} {action} ); diff --git a/packages/twenty-front/src/modules/settings/data-model/object-details/states/settingsObjectFieldsFamilyState.ts b/packages/twenty-front/src/modules/settings/data-model/object-details/states/settingsObjectFieldsFamilyState.ts new file mode 100644 index 000000000000..daf3c3c33dbf --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/object-details/states/settingsObjectFieldsFamilyState.ts @@ -0,0 +1,14 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; + +export type SortedFieldByTableFamilyStateKey = { + objectMetadataItemId: string; +}; + +export const settingsObjectFieldsFamilyState = createFamilyState< + FieldMetadataItem[] | null, + SortedFieldByTableFamilyStateKey +>({ + key: 'settingsObjectFieldsFamilyState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/Table.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/SpreadsheetImportTable.tsx similarity index 94% rename from packages/twenty-front/src/modules/spreadsheet-import/components/Table.tsx rename to packages/twenty-front/src/modules/spreadsheet-import/components/SpreadsheetImportTable.tsx index ea953e057591..c898e0c24e26 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/Table.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/SpreadsheetImportTable.tsx @@ -1,6 +1,6 @@ -// @ts-expect-error // Todo: remove usage of react-data-grid -import DataGrid, { DataGridProps } from 'react-data-grid'; import styled from '@emotion/styled'; +// @ts-expect-error // Todo: remove usage of react-data-grid +import DataGrid, { DataGridProps } from 'react-data-grid'; import { RGBA } from 'twenty-ui'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; @@ -107,12 +107,12 @@ const StyledDataGrid = styled(DataGrid)` } ` as typeof DataGrid; -type TableProps = DataGridProps & { +type SpreadsheetImportTableProps = DataGridProps & { rowHeight?: number; hiddenHeader?: boolean; }; -export const Table = ({ +export const SpreadsheetImportTable = ({ className, columns, components, @@ -123,7 +123,7 @@ export const Table = ({ onRowsChange, onSelectedRowsChange, selectedRows, -}: TableProps) => { +}: SpreadsheetImportTableProps) => { const { rtl } = useSpreadsheetImportInternal(); if (!rows?.length || !columns?.length) return null; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectHeaderTable.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectHeaderTable.tsx index 3d5124fb91e3..f236f4319ee1 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectHeaderTable.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectHeaderTable.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { Table } from '@/spreadsheet-import/components/Table'; +import { SpreadsheetImportTable } from '@/spreadsheet-import/components/SpreadsheetImportTable'; import { ImportedRow } from '@/spreadsheet-import/types'; import { generateSelectionColumns } from './SelectColumn'; @@ -22,7 +22,7 @@ export const SelectHeaderTable = ({ ); return ( - importedRows.indexOf(row)} rows={importedRows} diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/ExampleTable.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/ExampleTable.tsx index bbbaab975ae7..562b3c4788f8 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/ExampleTable.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/ExampleTable.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { Table } from '@/spreadsheet-import/components/Table'; +import { SpreadsheetImportTable } from '@/spreadsheet-import/components/SpreadsheetImportTable'; import { Fields } from '@/spreadsheet-import/types'; import { generateExampleRow } from '@/spreadsheet-import/utils/generateExampleRow'; @@ -16,5 +16,11 @@ export const ExampleTable = ({ const data = useMemo(() => generateExampleRow(fields), [fields]); const columns = useMemo(() => generateColumns(fields), [fields]); - return
; + return ( + + ); }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx index 2c20ed969ccd..bcd0405becaf 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx @@ -5,8 +5,8 @@ import { RowsChangeData } from 'react-data-grid'; import { IconTrash } from 'twenty-ui'; import { Heading } from '@/spreadsheet-import/components/Heading'; +import { SpreadsheetImportTable } from '@/spreadsheet-import/components/SpreadsheetImportTable'; import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton'; -import { Table } from '@/spreadsheet-import/components/Table'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; import { Columns, @@ -277,7 +277,7 @@ export const ValidationStep = ({ /> -
{ + event.stopPropagation(); + event.preventDefault(); + + toggleDropdown(); + onClickOutside?.(); + }; + useListenClickOutside({ refs: [refs.floating], callback: () => { @@ -126,10 +134,7 @@ export const Dropdown = ({ {clickableComponent && (
{ - toggleDropdown(); - onClickOutside?.(); - }} + onClick={handleClickableComponentClick} className={className} > {clickableComponent} diff --git a/packages/twenty-front/src/modules/ui/layout/table/components/SortableTableHeader.tsx b/packages/twenty-front/src/modules/ui/layout/table/components/SortableTableHeader.tsx new file mode 100644 index 000000000000..fcdb02ebf543 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/table/components/SortableTableHeader.tsx @@ -0,0 +1,67 @@ +import { TableHeader } from '@/ui/layout/table/components/TableHeader'; +import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState'; +import { TableSortValue } from '@/ui/layout/table/types/TableSortValue'; +import { useRecoilState } from 'recoil'; +import { IconArrowDown, IconArrowUp } from 'twenty-ui'; + +export const SortableTableHeader = ({ + tableId, + fieldName, + label, + align = 'left', + initialSort, +}: { + tableId: string; + fieldName: string; + label: string; + align?: 'left' | 'center' | 'right'; + initialSort?: TableSortValue; +}) => { + const [sortedFieldByTable, setSortedFieldByTable] = useRecoilState( + sortedFieldByTableFamilyState({ tableId }), + ); + + const sortValue = sortedFieldByTable ?? initialSort; + + const isSortOnThisField = sortValue?.fieldName === fieldName; + + const sortDirection = isSortOnThisField ? sortValue.orderBy : null; + + const isAsc = + sortDirection === 'AscNullsLast' || sortDirection === 'AscNullsFirst'; + const isDesc = + sortDirection === 'DescNullsLast' || sortDirection === 'DescNullsFirst'; + + const isSortActive = isAsc || isDesc; + + const handleClick = () => { + setSortedFieldByTable({ + fieldName, + orderBy: isSortOnThisField + ? sortValue.orderBy === 'AscNullsLast' + ? 'DescNullsLast' + : 'AscNullsLast' + : 'DescNullsLast', + }); + }; + + return ( + + {isSortActive && align === 'right' ? ( + isAsc ? ( + + ) : ( + + ) + ) : null} + {label} + {isSortActive && align === 'left' ? ( + isAsc ? ( + + ) : ( + + ) + ) : null} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/table/components/TableHeader.tsx b/packages/twenty-front/src/modules/ui/layout/table/components/TableHeader.tsx index 39e3a9ccc2ac..c00a80c0eef1 100644 --- a/packages/twenty-front/src/modules/ui/layout/table/components/TableHeader.tsx +++ b/packages/twenty-front/src/modules/ui/layout/table/components/TableHeader.tsx @@ -1,6 +1,9 @@ import styled from '@emotion/styled'; -const StyledTableHeader = styled.div<{ align?: 'left' | 'center' | 'right' }>` +const StyledTableHeader = styled.div<{ + align?: 'left' | 'center' | 'right'; + onClick?: () => void; +}>` align-items: center; border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; color: ${({ theme }) => theme.font.color.tertiary}; @@ -15,6 +18,7 @@ const StyledTableHeader = styled.div<{ align?: 'left' | 'center' | 'right' }>` : 'flex-start'}; padding: 0 ${({ theme }) => theme.spacing(2)}; text-align: ${({ align }) => align ?? 'left'}; + cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')}; `; export { StyledTableHeader as TableHeader }; diff --git a/packages/twenty-front/src/modules/ui/layout/table/hooks/__tests__/useSortedArray.test.tsx b/packages/twenty-front/src/modules/ui/layout/table/hooks/__tests__/useSortedArray.test.tsx new file mode 100644 index 000000000000..05298000eb4f --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/table/hooks/__tests__/useSortedArray.test.tsx @@ -0,0 +1,119 @@ +import { renderHook } from '@testing-library/react'; +import React, { ReactNode } from 'react'; +import { MutableSnapshot, RecoilRoot } from 'recoil'; + +import { + mockedTableMetadata, + MockedTableType, + mockedTableData as tableData, + tableDataSortedByFieldsCountInAscendingOrder, + tableDataSortedByFieldsCountInDescendingOrder, + tableDataSortedBylabelInAscendingOrder, + tableDataSortedBylabelInDescendingOrder, +} from '~/testing/mock-data/tableData'; + +import { OrderBy } from '@/types/OrderBy'; +import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState'; + +import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray'; + +interface WrapperProps { + children: ReactNode; + initializeState?: (mutableSnapshot: MutableSnapshot) => void; +} + +const Wrapper: React.FC = ({ children, initializeState }) => ( + {children} +); + +describe('useSortedArray hook', () => { + const initializeState = + (fieldName: keyof MockedTableType, orderBy: OrderBy) => + ({ set }: MutableSnapshot) => { + set( + sortedFieldByTableFamilyState({ + tableId: mockedTableMetadata.tableId, + }), + { + fieldName, + orderBy, + }, + ); + }; + + test('initial sorting behavior for string fields - Ascending', () => { + const { result } = renderHook( + () => useSortedArray(tableData, mockedTableMetadata), + { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }, + ); + + const sortedData = result.current; + + expect(sortedData).toEqual(tableDataSortedBylabelInAscendingOrder); + }); + + test('initial sorting behavior for string fields - Descending', () => { + const { result } = renderHook( + () => useSortedArray(tableData, mockedTableMetadata), + { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }, + ); + + const sortedData = result.current; + + expect(sortedData).toEqual(tableDataSortedBylabelInDescendingOrder); + }); + + test('initial sorting behavior for number fields - Ascending', () => { + const { result } = renderHook( + () => useSortedArray(tableData, mockedTableMetadata), + { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }, + ); + + const sortedData = result.current; + + expect(sortedData).toEqual(tableDataSortedByFieldsCountInAscendingOrder); + }); + + test('initial sorting behavior for number fields - Descending', () => { + const { result } = renderHook( + () => useSortedArray(tableData, mockedTableMetadata), + { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }, + ); + + const sortedData = result.current; + + expect(sortedData).toEqual(tableDataSortedByFieldsCountInDescendingOrder); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/layout/table/hooks/useSortedArray.ts b/packages/twenty-front/src/modules/ui/layout/table/hooks/useSortedArray.ts new file mode 100644 index 000000000000..bc582345ff3c --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/table/hooks/useSortedArray.ts @@ -0,0 +1,52 @@ +import { sortedFieldByTableFamilyState } from '@/ui/layout/table/states/sortedFieldByTableFamilyState'; +import { TableMetadata } from '@/ui/layout/table/types/TableMetadata'; +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-ui'; + +export const useSortedArray = ( + arrayToSort: T[], + tableMetadata: TableMetadata, +): T[] => { + const sortedFieldByTable = useRecoilValue( + sortedFieldByTableFamilyState({ tableId: tableMetadata.tableId }), + ); + + const initialSort = tableMetadata.initialSort; + + const sortedArray = useMemo(() => { + const sortValueToUse = isDefined(sortedFieldByTable) + ? sortedFieldByTable + : initialSort; + + if (!isDefined(sortValueToUse)) { + return arrayToSort; + } + + const sortFieldName = sortValueToUse.fieldName as keyof T; + const sortFieldType = tableMetadata.fields.find( + (field) => field.fieldName === sortFieldName, + )?.fieldType; + const sortOrder = sortValueToUse.orderBy; + + return [...arrayToSort].sort((a: T, b: T) => { + if (sortFieldType === 'string') { + return sortOrder === 'AscNullsLast' || sortOrder === 'AscNullsFirst' + ? (a[sortFieldName] as string)?.localeCompare( + b[sortFieldName] as string, + ) + : (b[sortFieldName] as string)?.localeCompare( + a[sortFieldName] as string, + ); + } else if (sortFieldType === 'number') { + return sortOrder === 'AscNullsLast' || sortOrder === 'AscNullsFirst' + ? (a[sortFieldName] as number) - (b[sortFieldName] as number) + : (b[sortFieldName] as number) - (a[sortFieldName] as number); + } else { + return 0; + } + }); + }, [arrayToSort, tableMetadata, initialSort, sortedFieldByTable]); + + return sortedArray; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/table/states/sortedFieldByTableFamilyState.ts b/packages/twenty-front/src/modules/ui/layout/table/states/sortedFieldByTableFamilyState.ts new file mode 100644 index 000000000000..7e088dab72d9 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/table/states/sortedFieldByTableFamilyState.ts @@ -0,0 +1,14 @@ +import { TableSortValue } from '@/ui/layout/table/types/TableSortValue'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; + +export type SortedFieldByTableFamilyStateKey = { + tableId: string; +}; + +export const sortedFieldByTableFamilyState = createFamilyState< + TableSortValue | null, + SortedFieldByTableFamilyStateKey +>({ + key: 'sortedFieldByTableFamilyState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/ui/layout/table/types/TableFieldMetadata.ts b/packages/twenty-front/src/modules/ui/layout/table/types/TableFieldMetadata.ts new file mode 100644 index 000000000000..4e807bda6533 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/table/types/TableFieldMetadata.ts @@ -0,0 +1,6 @@ +export type TableFieldMetadata = { + fieldLabel: string; + fieldName: keyof ItemType; + fieldType: 'string' | 'number'; + align: 'left' | 'right'; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/table/types/TableMetadata.ts b/packages/twenty-front/src/modules/ui/layout/table/types/TableMetadata.ts new file mode 100644 index 000000000000..b4ba856f1407 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/table/types/TableMetadata.ts @@ -0,0 +1,8 @@ +import { TableFieldMetadata } from '@/ui/layout/table/types/TableFieldMetadata'; +import { TableSortValue } from '@/ui/layout/table/types/TableSortValue'; + +export type TableMetadata = { + tableId: string; + fields: TableFieldMetadata[]; + initialSort?: TableSortValue; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/table/types/TableSortValue.ts b/packages/twenty-front/src/modules/ui/layout/table/types/TableSortValue.ts new file mode 100644 index 000000000000..f72ba0b5b979 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/table/types/TableSortValue.ts @@ -0,0 +1,6 @@ +import { OrderBy } from '@/types/OrderBy'; + +export type TableSortValue = { + fieldName: string; + orderBy: OrderBy; +}; diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetail.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetail.tsx deleted file mode 100644 index 3f551f7728f1..000000000000 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectDetail.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import styled from '@emotion/styled'; -import { useEffect } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { H2Title, IconPlus, IconSettings } from 'twenty-ui'; - -import { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from '@/object-metadata/constants/LabelIdentifierFieldMetadataTypes'; -import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; -import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; -import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem'; -import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems'; -import { getDisabledFieldMetadataItems } from '@/object-metadata/utils/getDisabledFieldMetadataItems'; -import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug'; -import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; -import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; -import { SettingsObjectFieldActiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldActiveActionDropdown'; -import { SettingsObjectFieldInactiveActionDropdown } from '@/settings/data-model/object-details/components/SettingsObjectFieldDisabledActionDropdown'; -import { - SettingsObjectFieldItemTableRow, - StyledObjectFieldTableRow, -} from '@/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow'; -import { SettingsObjectSummaryCard } from '@/settings/data-model/object-details/components/SettingsObjectSummaryCard'; -import { getFieldIdentifierType } from '@/settings/data-model/utils/getFieldIdentifierType'; -import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; -import { AppPath } from '@/types/AppPath'; -import { SettingsPath } from '@/types/SettingsPath'; -import { Button } from '@/ui/input/button/components/Button'; -import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; -import { Section } from '@/ui/layout/section/components/Section'; -import { Table } from '@/ui/layout/table/components/Table'; -import { TableHeader } from '@/ui/layout/table/components/TableHeader'; -import { TableSection } from '@/ui/layout/table/components/TableSection'; -import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; -import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink'; - -const StyledDiv = styled.div` - display: flex; - justify-content: flex-end; - padding-top: ${({ theme }) => theme.spacing(2)}; -`; - -export const SettingsObjectDetail = () => { - const navigate = useNavigate(); - - const { objectSlug = '' } = useParams(); - const { findActiveObjectMetadataItemBySlug } = - useFilteredObjectMetadataItems(); - const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem(); - - const activeObjectMetadataItem = - findActiveObjectMetadataItemBySlug(objectSlug); - - useEffect(() => { - if (!activeObjectMetadataItem) navigate(AppPath.NotFound); - }, [activeObjectMetadataItem, navigate]); - - const { - activateMetadataField, - deactivateMetadataField, - deleteMetadataField, - } = useFieldMetadataItem(); - - if (!activeObjectMetadataItem) return null; - - const activeMetadataFields = getActiveFieldMetadataItems( - activeObjectMetadataItem, - ); - const deactivatedMetadataFields = getDisabledFieldMetadataItems( - activeObjectMetadataItem, - ); - - const handleDisableObject = async () => { - await updateOneObjectMetadataItem({ - idToUpdate: activeObjectMetadataItem.id, - updatePayload: { isActive: false }, - }); - navigate(getSettingsPagePath(SettingsPath.Objects)); - }; - - const handleDisableField = (activeFieldMetadatItem: FieldMetadataItem) => { - deactivateMetadataField(activeFieldMetadatItem); - }; - - const handleSetLabelIdentifierField = ( - activeFieldMetadatItem: FieldMetadataItem, - ) => - updateOneObjectMetadataItem({ - idToUpdate: activeObjectMetadataItem.id, - updatePayload: { - labelIdentifierFieldMetadataId: activeFieldMetadatItem.id, - }, - }); - - const shouldDisplayAddFieldButton = !activeObjectMetadataItem.isRemote; - - return ( - - - -
- - navigate('./edit')} - /> -
-
- -
- - Name - - {activeObjectMetadataItem.isCustom - ? 'Identifier' - : 'Field type'} - - Data type - - - {!!activeMetadataFields.length && ( - - {activeMetadataFields.map((activeMetadataField) => { - const isLabelIdentifier = isLabelIdentifierField({ - fieldMetadataItem: activeMetadataField, - objectMetadataItem: activeObjectMetadataItem, - }); - const canBeSetAsLabelIdentifier = - activeObjectMetadataItem.isCustom && - !isLabelIdentifier && - LABEL_IDENTIFIER_FIELD_METADATA_TYPES.includes( - activeMetadataField.type, - ); - - return ( - - navigate(`./${getFieldSlug(activeMetadataField)}`) - } - onSetAsLabelIdentifier={ - canBeSetAsLabelIdentifier - ? () => - handleSetLabelIdentifierField( - activeMetadataField, - ) - : undefined - } - onDeactivate={ - isLabelIdentifier - ? undefined - : () => handleDisableField(activeMetadataField) - } - /> - } - /> - ); - })} - - )} - {!!deactivatedMetadataFields.length && ( - - {deactivatedMetadataFields.map((deactivatedMetadataField) => ( - - activateMetadataField(deactivatedMetadataField) - } - onDelete={() => - deleteMetadataField(deactivatedMetadataField) - } - /> - } - /> - ))} - - )} -
- {shouldDisplayAddFieldButton && ( - - -