[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

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