[Issue-5772] Add sort feature on settings tables (#5787)

## 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 <felix.malfait@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Anand Krishnan M J
2024-08-14 20:41:17 +05:30
committed by GitHub
parent 0f75e14ab2
commit 59e14fabb4
40 changed files with 1229 additions and 445 deletions

View File

@ -1,9 +1,9 @@
import { Reference, StoreObject } from '@apollo/client';
import { ReadFieldFunction } from '@apollo/client/cache/core/types/common';
import { OrderBy } from '@/object-metadata/types/OrderBy';
import { RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
import { OrderBy } from '@/types/OrderBy';
import { isDefined } from '~/utils/isDefined';
import { sortAsc, sortDesc, sortNullsFirst, sortNullsLast } from '~/utils/sort';

View File

@ -1,7 +1,8 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { OrderBy } from '@/object-metadata/types/OrderBy';
import { getOrderByFieldForObjectMetadataItem } from '@/object-metadata/utils/getObjectOrderByField';
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
import { OrderBy } from '@/types/OrderBy';
export const useGetObjectOrderByField = ({
objectNameSingular,

View File

@ -1,8 +1,9 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { OrderBy } from '@/object-metadata/types/OrderBy';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { getOrderByForFieldMetadataType } from '@/object-metadata/utils/getOrderByForFieldMetadataType';
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
import { OrderBy } from '@/types/OrderBy';
import { isDefined } from '~/utils/isDefined';
export const getOrderByFieldForObjectMetadataItem = (

View File

@ -1,7 +1,8 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { OrderBy } from '@/object-metadata/types/OrderBy';
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
import { OrderBy } from '@/types/OrderBy';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const getOrderByForFieldMetadataType = (

View File

@ -1,4 +1,4 @@
import { OrderBy } from '@/object-metadata/types/OrderBy';
import { OrderBy } from '@/types/OrderBy';
export type RecordGqlOperationOrderBy = Array<{
[fieldName: string]: OrderBy | { [subFieldName: string]: OrderBy };

View File

@ -0,0 +1,48 @@
import { useQuery } from '@apollo/client';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature';
import { useGenerateCombinedFindManyRecordsQuery } from '@/object-record/multiple-objects/hooks/useGenerateCombinedFindManyRecordsQuery';
import { MultiObjectRecordQueryResult } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
export const useCombinedGetTotalCount = ({
objectMetadataItems,
skip = false,
}: {
objectMetadataItems: ObjectMetadataItem[];
skip?: boolean;
}) => {
const operationSignatures = objectMetadataItems.map(
(objectMetadataItem) =>
({
objectNameSingular: objectMetadataItem.nameSingular,
variables: {},
fields: {
id: true,
},
}) satisfies RecordGqlOperationSignature,
);
const findManyQuery = useGenerateCombinedFindManyRecordsQuery({
operationSignatures,
});
const { data } = useQuery<MultiObjectRecordQueryResult>(
findManyQuery ?? EMPTY_QUERY,
{
skip,
},
);
const totalCountByObjectMetadataItemNamePlural = Object.fromEntries(
Object.entries(data ?? {}).map(([namePlural, objectRecordConnection]) => [
namePlural,
objectRecordConnection.totalCount,
]),
);
return {
totalCountByObjectMetadataItemNamePlural,
};
};

View File

@ -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 = (

View File

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

View File

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

View File

@ -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<FieldIdentifierType>;
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 (
<StyledObjectFieldTableRow to={to}>
<StyledObjectFieldTableRow
to={mode === 'view' ? linkToNavigate : undefined}
>
<StyledNameTableCell>
{!!Icon && (
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
)}
{fieldMetadataItem.label}
</StyledNameTableCell>
<TableCell>
{variant === 'field-type' &&
(isRemoteObjectField
? 'Remote'
: fieldMetadataItem.isCustom
? 'Custom'
: 'Standard')}
{variant === 'identifier' &&
!!identifierType &&
(identifierType === 'label' ? 'Record text' : 'Record image')}
</TableCell>
<TableCell>{typeLabel}</TableCell>
<TableCell>
<SettingsObjectFieldDataType
Icon={RelationIcon}
@ -105,7 +182,48 @@ export const SettingsObjectFieldItemTableRow = ({
value={fieldType}
/>
</TableCell>
<StyledIconTableCell>{ActionIcon}</StyledIconTableCell>
<StyledIconTableCell>
{status === 'active' ? (
mode === 'view' ? (
<SettingsObjectFieldActiveActionDropdown
isCustomField={fieldMetadataItem.isCustom === true}
scopeKey={fieldMetadataItem.id}
onEdit={() => navigate(linkToNavigate)}
onSetAsLabelIdentifier={
canBeSetAsLabelIdentifier
? () => handleSetLabelIdentifierField(fieldMetadataItem)
: undefined
}
onDeactivate={
isLabelIdentifier
? undefined
: () => handleDisableField(fieldMetadataItem)
}
/>
) : (
canToggleField && (
<LightIconButton
Icon={IconMinus}
accent="tertiary"
onClick={handleToggleField}
/>
)
)
) : mode === 'view' ? (
<SettingsObjectFieldInactiveActionDropdown
isCustomField={fieldMetadataItem.isCustom === true}
scopeKey={fieldMetadataItem.id}
onActivate={() => activateMetadataField(fieldMetadataItem)}
onDelete={() => deleteMetadataField(fieldMetadataItem)}
/>
) : (
<LightIconButton
Icon={IconPlus}
accent="tertiary"
onClick={handleToggleField}
/>
)}
</StyledIconTableCell>
</StyledObjectFieldTableRow>
);
};

View File

@ -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 (
<StyledObjectTableRow key={objectItem.namePlural} to={to}>
<StyledObjectTableRow key={objectMetadataItem.namePlural} to={link}>
<StyledNameTableCell>
{!!Icon && (
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
)}
{objectItem.labelPlural}
{objectMetadataItem.labelPlural}
</StyledNameTableCell>
<TableCell>
<SettingsDataModelObjectTypeTag objectTypeLabel={objectTypeLabel} />
</TableCell>
<TableCell align="right">
{objectItem.fields.filter((field) => !field.isSystem).length}
{objectMetadataItem.fields.filter((field) => !field.isSystem).length}
</TableCell>
<TableCell align="right">{totalCount}</TableCell>
<TableCell align="right">{totalObjectCount}</TableCell>
<StyledActionTableCell>{action}</StyledActionTableCell>
</StyledObjectTableRow>
);

View File

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

View File

@ -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<Data> = DataGridProps<Data> & {
type SpreadsheetImportTableProps<Data> = DataGridProps<Data> & {
rowHeight?: number;
hiddenHeader?: boolean;
};
export const Table = <Data,>({
export const SpreadsheetImportTable = <Data,>({
className,
columns,
components,
@ -123,7 +123,7 @@ export const Table = <Data,>({
onRowsChange,
onSelectedRowsChange,
selectedRows,
}: TableProps<Data>) => {
}: SpreadsheetImportTableProps<Data>) => {
const { rtl } = useSpreadsheetImportInternal();
if (!rows?.length || !columns?.length) return null;

View File

@ -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 (
<Table
<SpreadsheetImportTable
// Todo: remove usage of react-data-grid
rowKeyGetter={(row: any) => importedRows.indexOf(row)}
rows={importedRows}

View File

@ -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 = <T extends string>({
const data = useMemo(() => generateExampleRow(fields), [fields]);
const columns = useMemo(() => generateColumns(fields), [fields]);
return <Table rows={data} columns={columns} className={'rdg-example'} />;
return (
<SpreadsheetImportTable
rows={data}
columns={columns}
className={'rdg-example'}
/>
);
};

View File

@ -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 = <T extends string>({
/>
</StyledToolbar>
<StyledScrollContainer>
<Table
<SpreadsheetImportTable
rowKeyGetter={rowKeyGetter}
rows={tableData}
onRowsChange={updateRow}

View File

@ -6,7 +6,7 @@ import {
Placement,
useFloating,
} from '@floating-ui/react';
import { useRef } from 'react';
import { MouseEvent, useRef } from 'react';
import { Keys } from 'react-hotkeys-hook';
import { Key } from 'ts-key-enum';
@ -93,6 +93,14 @@ export const Dropdown = ({
toggleDropdown();
};
const handleClickableComponentClick = (event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
toggleDropdown();
onClickOutside?.();
};
useListenClickOutside({
refs: [refs.floating],
callback: () => {
@ -126,10 +134,7 @@ export const Dropdown = ({
{clickableComponent && (
<div
ref={refs.setReference}
onClick={() => {
toggleDropdown();
onClickOutside?.();
}}
onClick={handleClickableComponentClick}
className={className}
>
{clickableComponent}

View File

@ -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 (
<TableHeader align={align} onClick={handleClick}>
{isSortActive && align === 'right' ? (
isAsc ? (
<IconArrowUp size="14" />
) : (
<IconArrowDown size="14" />
)
) : null}
{label}
{isSortActive && align === 'left' ? (
isAsc ? (
<IconArrowUp size="14" />
) : (
<IconArrowDown size="14" />
)
) : null}
</TableHeader>
);
};

View File

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

View File

@ -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<WrapperProps> = ({ children, initializeState }) => (
<RecoilRoot initializeState={initializeState}>{children}</RecoilRoot>
);
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 }) => (
<Wrapper
initializeState={initializeState('labelPlural', 'AscNullsLast')}
>
{children}
</Wrapper>
),
},
);
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 }) => (
<Wrapper
initializeState={initializeState('labelPlural', 'DescNullsLast')}
>
{children}
</Wrapper>
),
},
);
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 }) => (
<Wrapper
initializeState={initializeState('fieldsCount', 'AscNullsLast')}
>
{children}
</Wrapper>
),
},
);
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 }) => (
<Wrapper
initializeState={initializeState('fieldsCount', 'DescNullsLast')}
>
{children}
</Wrapper>
),
},
);
const sortedData = result.current;
expect(sortedData).toEqual(tableDataSortedByFieldsCountInDescendingOrder);
});
});

View File

@ -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 = <T>(
arrayToSort: T[],
tableMetadata: TableMetadata<T>,
): 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;
};

View File

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

View File

@ -0,0 +1,6 @@
export type TableFieldMetadata<ItemType> = {
fieldLabel: string;
fieldName: keyof ItemType;
fieldType: 'string' | 'number';
align: 'left' | 'right';
};

View File

@ -0,0 +1,8 @@
import { TableFieldMetadata } from '@/ui/layout/table/types/TableFieldMetadata';
import { TableSortValue } from '@/ui/layout/table/types/TableSortValue';
export type TableMetadata<ItemType> = {
tableId: string;
fields: TableFieldMetadata<ItemType>[];
initialSort?: TableSortValue;
};

View File

@ -0,0 +1,6 @@
import { OrderBy } from '@/types/OrderBy';
export type TableSortValue = {
fieldName: string;
orderBy: OrderBy;
};