Skip to content
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
44 changes: 44 additions & 0 deletions .changeset/server-side-column-header-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@object-ui/types": minor
"@object-ui/components": minor
"@object-ui/plugin-grid": minor
"@object-ui/plugin-list": minor
---

feat(components,grid,list): a column-header sort orders the whole list, not the page you can see — #3106

Clicking a column header under server pagination sorted **the current page**.
The user saw "sorted by this column" and got "these fifty rows are in order;
page 2 starts over". The sort was real — its scope was not the one the screen
implied — and it had no way out of `data-table` at all: the sort lived in two
`useState`s with no callback, so the layer that issues the request could not
see it even in principle.

`DataTable` gains `manualSorting` + a controlled `sort` + `onSortChange`. In
that mode it sorts nothing, reports what a header click asks for, and renders
`sort` as the indicator — keeping **no** sort state of its own, because a
private copy beside a controlled prop is the shape the defect had.

`ObjectGrid` turns that into a `$orderby` in both of its server modes (its own
fetch, and a parent-driven one), and `ListView` lands it in `currentSort` — the
same state the toolbar's sort builder writes. One sort, two controls: that is
what makes "does a header sort outrank the saved view's sort?" a non-question
rather than a precedence rule someone has to remember.

Three details that are decisions, not incidentals:

- **A header click replaces the order** instead of appending to it, so the
column under the cursor is the one the list is sorted by. Multi-key orders
still come from the sort builder, and the headers render them numbered.
- **It cannot ask for "no sort".** In client mode the third click clears, and
that is meaningful there — the rows return to the order they arrived in.
Across a server-paged collection there is no such order (objectstack#4363), so
a header offering it would hand the user a worse lie than the one being fixed.
Clearing stays with the sort builder, which can restore the view's default.
- **Relational columns render no sort affordance** under server sorting. A
`lookup` column shows a related record's name while `$orderby` can only order
by the stored id (objectstack#4256) — the same reason #3096 removed them from
the toolbar's sort picker. Client-side sorting keys off the rendered label, so
those headers stay live there.

Client-side tables are untouched: same three-state cycle, same local sort.
207 changes: 207 additions & 0 deletions packages/components/src/__tests__/data-table-manual-sorting.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Server-side ("manual") sorting for DataTable (objectui#3106).
*
* The defect: a column-header click sorted whatever rows the table was holding.
* Under server pagination that is ONE PAGE, so the header said "sorted by this
* column" about fifty rows and nothing about the list they came from — and the
* sort had no way out of the component to reach the layer that fetches.
*
* In manual mode the table sorts nothing, reports the requested sort through
* `onSortChange`, and renders `sort` as the indicator. It keeps **no** sort
* state of its own: a private copy alongside a controlled prop is the shape the
* bug had, so these tests assert the rows are untouched and that the indicator
* follows the prop rather than the click.
*/
import { describe, it, expect, vi } from 'vitest';
import { fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderComponent } from './test-utils';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout` (objectui#3010/#3021).
import '../renderers';

/** Deliberately NOT in `status` order — a local sort would visibly reorder it. */
const pageData = [
{ id: 'r1', name: 'Alpha', status: 'open' },
{ id: 'r2', name: 'Bravo', status: 'done' },
{ id: 'r3', name: 'Charlie', status: 'hold' },
];

const columns = [
{ header: 'Name', accessorKey: 'name' },
{ header: 'Status', accessorKey: 'status' },
];

const baseSchema = {
type: 'data-table' as const,
columns,
data: pageData,
manualSorting: true,
sort: [],
} as any;

const rowOrder = (container: HTMLElement) =>
Array.from(container.querySelectorAll('tbody tr')).map(
(tr) => tr.querySelector('td')?.textContent?.trim(),
);

const headerCell = (container: HTMLElement, label: string) =>
Array.from(container.querySelectorAll('thead th')).find((th) =>
th.textContent?.includes(label),
) as HTMLElement;

describe('data-table — manual (server-side) sorting', () => {
it('does not reorder the rows it was handed', () => {
const { container } = renderComponent({
...baseSchema,
sort: [{ field: 'status', order: 'asc' }],
onSortChange: vi.fn(),
});
// `status` ascending would be done, hold, open. The rows arrive in the
// server's order and stay in it — sorting this window is the whole defect.
expect(rowOrder(container)).toEqual(['Alpha', 'Bravo', 'Charlie']);
});

it('reports a header click instead of sorting locally', () => {
const onSortChange = vi.fn();
const { container } = renderComponent({ ...baseSchema, onSortChange });

fireEvent.click(headerCell(container, 'Status'));

expect(onSortChange).toHaveBeenCalledWith([{ field: 'status', order: 'asc' }]);
expect(rowOrder(container)).toEqual(['Alpha', 'Bravo', 'Charlie']);
});

it('toggles the direction of the column already sorted', () => {
const onSortChange = vi.fn();
const { container } = renderComponent({
...baseSchema,
sort: [{ field: 'status', order: 'asc' }],
onSortChange,
});

fireEvent.click(headerCell(container, 'Status'));
expect(onSortChange).toHaveBeenCalledWith([{ field: 'status', order: 'desc' }]);
});

it('never asks for "no sort" — the third click returns to ascending', () => {
// Client mode cycles asc → desc → none, and none is meaningful there (the
// rows return to the order they arrived in). Across a server-paged
// collection there is no such order: an unsorted paged read is arbitrary
// per page (objectstack#4363), so a header must not be able to request it.
const onSortChange = vi.fn();
const { container } = renderComponent({
...baseSchema,
sort: [{ field: 'status', order: 'desc' }],
onSortChange,
});

fireEvent.click(headerCell(container, 'Status'));
expect(onSortChange).toHaveBeenCalledWith([{ field: 'status', order: 'asc' }]);
// Not `[]`, and not a call with an empty array anywhere.
for (const call of onSortChange.mock.calls) expect(call[0]).not.toHaveLength(0);
});

it('REPLACES the order when a different column is clicked', () => {
// The column under the cursor becomes the one the list is sorted by. A
// header that appended would grow an invisible sort stack — the arrow says
// "Name" while the rows are ordered by Status first.
const onSortChange = vi.fn();
const { container } = renderComponent({
...baseSchema,
sort: [{ field: 'status', order: 'asc' }],
onSortChange,
});

fireEvent.click(headerCell(container, 'Name'));
expect(onSortChange).toHaveBeenCalledWith([{ field: 'name', order: 'asc' }]);
});

it('holds no sort state of its own — the indicator follows the prop, not the click', () => {
// The controlled value never changes, so nothing may appear to have been
// sorted. A table keeping a private copy would light up its own arrow here,
// which is exactly how a sort ends up somewhere the fetching layer cannot
// see it.
const { container } = renderComponent({ ...baseSchema, onSortChange: vi.fn() });
const before = headerCell(container, 'Status').innerHTML;

fireEvent.click(headerCell(container, 'Status'));

expect(headerCell(container, 'Status').innerHTML).toBe(before);
});

it('renders every key of a multi-key sort, numbered', () => {
// Multi-key orders come from a host's sort builder. Marking only the first
// column would say "sorted by Status" about a list ordered by Status then
// Name.
const { container } = renderComponent({
...baseSchema,
sort: [{ field: 'status', order: 'asc' }, { field: 'name', order: 'desc' }],
onSortChange: vi.fn(),
});

expect(headerCell(container, 'Status').textContent).toContain('1');
expect(headerCell(container, 'Name').textContent).toContain('2');
});

it('renders no rank badge for an ordinary single-column sort', () => {
const { container } = renderComponent({
...baseSchema,
sort: [{ field: 'status', order: 'asc' }],
onSortChange: vi.fn(),
});
expect(headerCell(container, 'Status').textContent?.trim()).toBe('Status');
});

it('renders inert headers when no onSortChange was supplied', () => {
// A manual-sorting table with nowhere to report a click must not look
// clickable: a dead affordance is the same class of lie as a sort that
// silently covers one page.
const { container } = renderComponent(baseSchema);
const status = headerCell(container, 'Status');
expect(status.className).not.toContain('cursor-pointer');
// No sort chevron. (Other header chrome — the column drag grip — stays.)
expect(status.querySelector('[class*="chevron"]')).toBeNull();
});

it('honours a column that opts out of sorting', () => {
// How a relational column is withheld: its label is a related record's
// name, but a server `$orderby` can only order by the stored id (#3096).
const onSortChange = vi.fn();
const { container } = renderComponent({
...baseSchema,
columns: [columns[0], { ...columns[1], sortable: false }],
onSortChange,
});

fireEvent.click(headerCell(container, 'Status'));
expect(onSortChange).not.toHaveBeenCalled();
});

it('leaves client-side sorting exactly as it was', () => {
// No `manualSorting`: the table owns the rows, so it sorts them and keeps
// its three-state cycle. This mode is untouched by #3106.
const { container } = renderComponent({
type: 'data-table',
columns,
data: pageData,
} as any);

fireEvent.click(headerCell(container, 'Status'));
expect(rowOrder(container)).toEqual(['Bravo', 'Charlie', 'Alpha']); // done, hold, open

fireEvent.click(headerCell(container, 'Status'));
expect(rowOrder(container)).toEqual(['Alpha', 'Charlie', 'Bravo']); // open, hold, done

fireEvent.click(headerCell(container, 'Status'));
expect(rowOrder(container)).toEqual(['Alpha', 'Bravo', 'Charlie']); // cleared
});
});
Loading
Loading