Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to provide initial order per DataTable column #1564

Merged
merged 1 commit into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions src/components/data/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useCallback, useContext, useEffect, useMemo } from 'preact/hooks';
import { useArrowKeyNavigation } from '../../hooks/use-arrow-key-navigation';
import { useStableCallback } from '../../hooks/use-stable-callback';
import { useSyncedRef } from '../../hooks/use-synced-ref';
import type { CompositeProps, Order } from '../../types';
import type { CompositeProps, Order, OrderDirection } from '../../types';
import { downcastRef } from '../../util/typing';
import { ArrowDownIcon, ArrowUpIcon, SpinnerSpokesIcon } from '../icons';
import { Button } from '../input';
Expand Down Expand Up @@ -80,8 +80,13 @@ type ComponentProps<Row> = Pick<
* Columns that can be used to order the table. Ignored if `onOrderChange` is
* not provided.
* No columns will be orderable if this is not provided.
*
* This can be a map of columns and order directions, to indicate the initial
* direction to use when a column becomes the ordered one
*/
orderableColumns?: Array<keyof Row>;
orderableColumns?:
| Array<keyof Row>
| Partial<Record<keyof Row, OrderDirection>>;

/** Callback to render an individual table cell */
renderItem?: (r: Row, field: keyof Row) => ComponentChildren;
Expand All @@ -98,9 +103,13 @@ function defaultRenderItem<Row>(r: Row, field: keyof Row): ComponentChildren {
function calculateNewOrder<T extends string | number | symbol>(
newField: T,
prevOrder?: Order<T>,
initialOrderForColumn?: Partial<Record<T, OrderDirection>>,
): Order<T> {
if (newField !== prevOrder?.field) {
return { field: newField, direction: 'ascending' };
return {
field: newField,
direction: initialOrderForColumn?.[newField] ?? 'ascending',
};
}

const newDirection =
Expand Down Expand Up @@ -162,12 +171,23 @@ export default function DataTable<Row>({
}: DataTableProps<Row>) {
const tableRef = useSyncedRef(elementRef);
const scrollContext = useContext(ScrollContext);
const [orderableColumnsList, initialOrderForColumn] = useMemo(
() =>
Array.isArray(orderableColumns)
? [orderableColumns, {}]
: [Object.keys(orderableColumns) as Array<keyof Row>, orderableColumns],
[orderableColumns],
);
const updateOrder = useCallback(
(newField: keyof Row) => {
const newOrder = calculateNewOrder(newField, order);
const newOrder = calculateNewOrder(
newField,
order,
initialOrderForColumn,
);
onOrderChange?.(newOrder);
},
[onOrderChange, order],
[initialOrderForColumn, onOrderChange, order],
);

const noContent = loading || (!rows.length && emptyMessage);
Expand Down Expand Up @@ -321,7 +341,7 @@ export default function DataTable<Row>({
<TableRow>
{columns.map(column => {
const isOrderable =
!!onOrderChange && orderableColumns.includes(column.field);
!!onOrderChange && orderableColumnsList.includes(column.field);
const isActiveOrder = order?.field === column.field;

return (
Expand Down
93 changes: 63 additions & 30 deletions src/components/data/test/DataTable-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe('DataTable', () => {
);
}

const createComponent = (Component, props = {}) => {
const createComponent = ({ Component = DataTable, ...props } = {}) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not directly related with the main changes in this PR, but to avoid duplicating createComponent(DataTable) over and over, I added a new Component pseudo-prop/option here, which defaults to DataTable.

With this, we can just call createComponent() or createComponent(props) in most of the cases, and when a different component is needed, we can additionally pass createComponent({ Component: Whatever, ...otherProps }).

const wrapper = mount(
<Component columns={fakeColumns} rows={fakeRows} {...props} />,

Expand All @@ -87,8 +87,8 @@ describe('DataTable', () => {
};

it('sets appropriate table attributes', () => {
const wrapper = createComponent(DataTable);
const interactiveWrapper = createComponent(DataTable, {
const wrapper = createComponent();
const interactiveWrapper = createComponent({
onSelectRow: sinon.stub(),
});
const outer = wrapper.find('Table');
Expand All @@ -105,7 +105,7 @@ describe('DataTable', () => {

describe('table columns', () => {
it('renders a column header for each column', () => {
const wrapper = createComponent(DataTable);
const wrapper = createComponent();
const tableHead = wrapper.find('TableHead');

assert.equal(tableHead.find('th').length, 3);
Expand All @@ -115,7 +115,7 @@ describe('DataTable', () => {
});

it('applies extra column classes', () => {
const wrapper = createComponent(DataTable);
const wrapper = createComponent();
const tableHead = wrapper.find('TableHead');

assert.isTrue(tableHead.find('th').at(0).hasClass('w-[50%]'));
Expand All @@ -124,15 +124,15 @@ describe('DataTable', () => {

describe('table rows', () => {
it('renders one table row per row provided', () => {
const wrapper = createComponent(DataTable);
const wrapper = createComponent();
assert.equal(
wrapper.find('TableBody').find('TableRow').length,
fakeRows.length,
);
});

it('renders fields that are defined by columns', () => {
const wrapper = createComponent(DataTable);
const wrapper = createComponent();
const firstRow = wrapper.find('TableBody').find('TableRow').first();

assert.equal(firstRow.find('td').at(0).text(), 'Chocolate Cake');
Expand All @@ -157,7 +157,7 @@ describe('DataTable', () => {

const rows = [fakeRows[3]];

const wrapper = createComponent(DataTable, {
const wrapper = createComponent({
renderItem: formatCell,
rows,
columns,
Expand All @@ -177,7 +177,7 @@ describe('DataTable', () => {
describe('interacting with row data', () => {
it('invokes `onSelectRow` callback when row is clicked', () => {
const onSelectRow = sinon.stub();
const wrapper = createComponent(DataTable, {
const wrapper = createComponent({
onSelectRow,
});

Expand All @@ -188,7 +188,8 @@ describe('DataTable', () => {

it('invokes `onSelectRows` callback when rows are clicked', () => {
const onSelectRows = sinon.stub();
const wrapper = createComponent(MultiSelectDataTable, {
const wrapper = createComponent({
Component: MultiSelectDataTable,
onSelectRows,
});

Expand All @@ -212,7 +213,7 @@ describe('DataTable', () => {

it('invokes `onSelectRow` when row is selected with arrow keys', () => {
const onSelectRow = sinon.stub();
const wrapper = createComponent(DataTable, {
const wrapper = createComponent({
onSelectRow,
});

Expand All @@ -229,7 +230,8 @@ describe('DataTable', () => {

it('invokes `onSelectRows` callback when rows are selected with arrow keys', () => {
const onSelectRows = sinon.stub();
const wrapper = createComponent(MultiSelectDataTable, {
const wrapper = createComponent({
Component: MultiSelectDataTable,
onSelectRows,
});

Expand All @@ -252,7 +254,7 @@ describe('DataTable', () => {

it('invokes `onConfirmRow` callback when row is double-clicked', () => {
const onConfirmRow = sinon.stub();
const wrapper = createComponent(DataTable, {
const wrapper = createComponent({
onConfirmRow,
});

Expand All @@ -263,7 +265,7 @@ describe('DataTable', () => {

it('invokes `onConfirmRow` callback when `Enter` is pressed on a row', () => {
const onConfirmRow = sinon.stub();
const wrapper = createComponent(DataTable, {
const wrapper = createComponent({
onConfirmRow,
});

Expand All @@ -275,46 +277,46 @@ describe('DataTable', () => {

context('when loading', () => {
it('renders a loading spinner', () => {
const wrapper = createComponent(DataTable, { loading: true });
const wrapper = createComponent({ loading: true });
assert.isTrue(wrapper.find('SpinnerSpokesIcon').exists());
});

it('does not render any data', () => {
const wrapper = createComponent(DataTable, { loading: true });
const wrapper = createComponent({ loading: true });
// One row, which holds the spinner
assert.equal(wrapper.find('tbody tr').length, 1);
});

it('still renders headings', () => {
const wrapper = createComponent(DataTable, { loading: true });
const wrapper = createComponent({ loading: true });
assert.equal(wrapper.find('thead tr th').length, 3);
});

it('does not render a TableFoot', () => {
const wrapper = createComponent(DataTable, { loading: true });
const wrapper = createComponent({ loading: true });
assert.isFalse(wrapper.find('[data-component="TableFoot"]').exists());
});
});

context('when empty', () => {
it('shows an empty message if provided', () => {
const wrapper = createComponent(DataTable, {
const wrapper = createComponent({
emptyMessage: <strong>Nope</strong>,
rows: [],
});
assert.equal(wrapper.find('tbody tr td').at(0).text(), 'Nope');
});

it('still renders headings', () => {
const wrapper = createComponent(DataTable, {
const wrapper = createComponent({
emptyMessage: <strong>Nope</strong>,
rows: [],
});
assert.equal(wrapper.find('thead tr th').length, 3);
});

it('does not render a TableFoot', () => {
const wrapper = createComponent(DataTable, {
const wrapper = createComponent({
rows: [],
});
assert.isFalse(wrapper.find('[data-component="TableFoot"]').exists());
Expand Down Expand Up @@ -412,7 +414,7 @@ describe('DataTable', () => {
{ direction: 'descending', expectedArrow: 'ArrowDownIcon' },
].forEach(({ direction, expectedArrow }) => {
it('shows initial active order', () => {
const wrapper = createComponent(DataTable, {
const wrapper = createComponent({
order: { field: 'color', direction },
});
const colorTableCell = wrapper.find('TableCell').at(1);
Expand All @@ -425,7 +427,7 @@ describe('DataTable', () => {
[
// Clicking the same column when initially ascending, transitions to descending
{
initialOrder: { field: 'name', direction: 'ascending' },
startingOrder: { field: 'name', direction: 'ascending' },
acelaya marked this conversation as resolved.
Show resolved Hide resolved
clickedColumn: 'name',
expectedNewOrder: {
field: 'name',
Expand All @@ -434,26 +436,57 @@ describe('DataTable', () => {
},
// Clicking the same column when initially descending, transitions to ascending
{
initialOrder: { field: 'name', direction: 'descending' },
startingOrder: { field: 'name', direction: 'descending' },
clickedColumn: 'name',
expectedNewOrder: { field: 'name', direction: 'ascending' },
},
// Clicking another column sets direction as ascending
{
initialOrder: { field: 'name', direction: 'ascending' },
startingOrder: { field: 'name', direction: 'ascending' },
clickedColumn: 'consistency',
expectedNewOrder: {
field: 'consistency',
direction: 'ascending',
},
},
].forEach(({ initialOrder, clickedColumn, expectedNewOrder }) => {
// Change sort column to a column with no initial order specified
{
startingOrder: { field: 'name', direction: 'ascending' },
clickedColumn: 'color',
expectedNewOrder: {
field: 'color',
direction: 'descending',
},
},
// Change sort column to a column with no initial order specified
{
startingOrder: undefined,
clickedColumn: 'consistency',
expectedNewOrder: {
field: 'consistency',
direction: 'ascending',
},
},
// Change sort column to a column with an initial order specified
{
startingOrder: undefined,
clickedColumn: 'color',
expectedNewOrder: {
field: 'color',
direction: 'descending',
},
},
].forEach(({ startingOrder, clickedColumn, expectedNewOrder }) => {
it('can update order by clicking columns', () => {
const onOrderChange = sinon.stub();
const wrapper = createComponent(DataTable, {
const wrapper = createComponent({
onOrderChange,
order: initialOrder,
orderableColumns: ['name', 'color', 'consistency'],
order: startingOrder,
orderableColumns: {
name: 'ascending',
color: 'descending',
consistency: 'ascending',
},
});

wrapper
Expand All @@ -471,7 +504,7 @@ describe('DataTable', () => {
undefined,
].forEach(orderableColumns => {
it('can restrict which columns are orderable', () => {
const wrapper = createComponent(DataTable, {
const wrapper = createComponent({
onOrderChange: sinon.stub(),
orderableColumns,
});
Expand Down
21 changes: 18 additions & 3 deletions src/pattern-library/components/patterns/data/DataTablePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -702,11 +702,12 @@ export default function DataTablePage() {
<Library.Info>
<Library.InfoItem label="description">
If provided together with <code>onOrderChange</code>, it allows
to restrict which columns can be used to order the table.
Defaults to all columns.
to restrict which columns can be used to order the table, or
define the initial ordering direction for every orderable
column. Defaults to no columns.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>{`Field[] | undefined`}</code>
<code>{`Field[] | Partial<Record<Field, 'ascending' | 'descending'>> | undefined`}</code>
</Library.InfoItem>
<Library.InfoItem label="default">
<code>undefined</code>
Expand All @@ -723,6 +724,20 @@ export default function DataTablePage() {
/>
</div>
</Library.Demo>

<Library.Demo title="Year orders descending by default">
<div className="w-full">
<ClientOrderableDataTable
title="Some of Nabokov's novels"
rows={nabokovRows}
columns={nabokovColumns}
orderableColumns={{
title: 'ascending',
year: 'descending',
}}
/>
</div>
</Library.Demo>
</Library.Example>

<Library.Example title="...htmlAttributes">
Expand Down
4 changes: 3 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export type TransitionComponent = FunctionComponent<{
onTransitionEnd?: (direction: 'in' | 'out') => void;
}>;

export type OrderDirection = 'ascending' | 'descending';

export type Order<Field extends string | number | symbol> = {
field: Field;
direction: 'ascending' | 'descending';
direction: OrderDirection;
};