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
9 changes: 6 additions & 3 deletions homedocs/src/pages/docs/tables/grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,19 @@ columns={[
]}
```

Use `value` for computed display values while keeping `field` for sorting:
Use `value` for computed display values and `sortField` to sort by the raw data:

```tsx
{
header: "Status",
field: "active",
sortField: "active",
value: { expr: "{$record.active} ? 'Yes' : 'No'" }
}
```

When multiple sort sources are defined on a column, the sort key is picked in this
order of precedence: `sortValue` > `sortField` > `value` > `field`.

### Headers

The `header` property can be a simple string or a configuration object for advanced scenarios like multi-row headers, custom tools, or spanning columns:
Expand Down Expand Up @@ -361,7 +364,7 @@ See also: [Row Drag and Drop](/docs/tables/row-drag-and-drop)
| `align` | `string` | Column alignment: `left`, `right`, or `center`. |
| `value` | `selector` | Selector (binding, template, expression or computable) for the displayed cell value. Use it for computed values or columns without a direct record field. |
| `sortable` | `boolean` | Set to `true` to make the column sortable. |
| `sortField` | `string` | Alternative field used for sorting. |
| `sortField` | `string` | Alternative field used for sorting. Takes precedence over the displayed `value`. |
| `sortValue` | `selector` | Selector used for sorting instead of the field or the displayed value. Takes precedence over all other sort sources. |
| `primarySortDirection` | `string` | Initial sort direction on first click: `ASC` or `DESC`. |
| `aggregate` | `string` | Aggregate function: `sum`, `count`, `distinct`, `avg`. |
Expand Down
27 changes: 18 additions & 9 deletions litmus/bugs/grid-sorting-without-field.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ import { Grid, PureContainer } from "cx/widgets";
// direction of the previous sort (onHeaderClick falls back to data.sortField
// when the column has no field, so it always "matches" the current sorter).
//
// Grid 3 (precedence):
// - "Priority" defines both `field` (numeric) and `value` (display label).
// Clicking the header sorts by the DISPLAYED label (P1, P10, P2) instead of
// the numeric field (1, 2, 10), although docs say `field` is kept for sorting.
// - "Priority (sortField)" additionally sets `sortField: "priority"` and still
// sorts by the display label — `value` silently wins over `sortField`.
// Grid 3 (precedence — sort key is picked as sortValue > sortField > value > field):
// - "Priority (field + value)" sorts by the DISPLAYED label (P1, P10, P2):
// the displayed `value` takes precedence over `field`.
// - "Priority (sortField + value)" sorts by the NUMERIC field (P1, P2, P10):
// an explicit `sortField` takes precedence over the displayed `value`.
// - "Priority (sortValue)" sorts by the negated priority (P10, P2, P1 on ASC):
// `sortValue` takes precedence over everything else.
// Covered by unit tests in packages/cx/src/widgets/grid/Grid.sorting.spec.tsx.

let records = [
{ id: 1, firstName: "Ann", lastName: "Zimmer", q1: 5, q2: 40, priority: 2 },
Expand Down Expand Up @@ -95,10 +97,10 @@ export default () => (
</div>

<div>
<h4>3. Precedence: value silently wins over field/sortField</h4>
<h4>3. Precedence: sortValue &gt; sortField &gt; value &gt; field</h4>
<p>
Both priority columns sort by the displayed label (P1, P10, P2) instead of the numeric field (1, 2,
10) — even the one with an explicit <code>sortField</code>.
Expected on ASC: <b>field + value</b> sorts by the label (P1, P10, P2), <b>sortField + value</b> sorts
by the numeric field (P1, P2, P10), <b>sortValue</b> sorts by the negated priority (P10, P2, P1).
</p>
<Grid
records-bind="$page.records"
Expand All @@ -116,6 +118,13 @@ export default () => (
value: { tpl: "P{$record.priority}" },
sortable: true,
},
{
header: "Priority (sortValue)",
sortField: "priority",
sortValue: computable("$record.priority", (p) => -p),
value: { tpl: "P{$record.priority}" },
sortable: true,
},
]}
/>
</div>
Expand Down
188 changes: 188 additions & 0 deletions packages/cx/src/widgets/grid/Grid.sorting.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import assert from "assert";
import { Store } from "../../data/Store";
import { Grid } from "./Grid";
import { bind } from "../../ui/bind";
import { computable } from "../../data/computable";
import { createTestRenderer, act } from "../../util/test/createTestRenderer";

// priorities 1, 2, 10 sort differently as numbers (1, 2, 10) than their
// "P{priority}" labels do as strings (P1, P10, P2), which makes the sort key
// precedence (sortValue > sortField > value > field) observable in the output
let records = [
{ id: 1, priority: 2 },
{ id: 2, priority: 10 },
{ id: 3, priority: 1 },
];

function textOf(node: any): string {
if (node == null) return "";
if (typeof node == "string") return node;
if (Array.isArray(node)) return node.map(textOf).join("");
return textOf(node.children);
}

function findAll(node: any, predicate: (el: any) => boolean, result: any[] = []): any[] {
if (node == null || typeof node == "string") return result;
if (Array.isArray(node)) {
node.forEach((child) => findAll(child, predicate, result));
return result;
}
if (predicate(node)) result.push(node);
findAll(node.children, predicate, result);
return result;
}

function getRenderedLabels(component: any): string[] {
let tds = findAll(component.toJSON(), (el) => el.type == "td");
return tds.map(textOf);
}

async function clickHeader(component: any, text: string) {
let th = component.root.findAllByType("th").find((t: any) => thText(t) == text);
assert.ok(th, `Header cell "${text}" not found`);
await act(async () => {
th.props.onClick({ preventDefault() {}, stopPropagation() {} });
});
}

function thText(th: any): string {
return th.children.map((c: any) => (typeof c == "string" ? c : "")).join("");
}

describe("Grid sorting precedence", () => {
it("sorts by sortField instead of the displayed value when both are set", async () => {
let widget = (
<cx>
<Grid
records={bind("records")}
columns={[
{
header: "Priority",
sortField: "priority",
value: { tpl: "P{$record.priority}" },
sortable: true,
},
]}
/>
</cx>
);

let store = new Store({ data: { records } });
const component = await createTestRenderer(store, widget);

await clickHeader(component, "Priority");
assert.deepStrictEqual(getRenderedLabels(component), ["P1", "P2", "P10"]);

await clickHeader(component, "Priority");
assert.deepStrictEqual(getRenderedLabels(component), ["P10", "P2", "P1"]);
});

it("sortValue takes precedence over sortField", async () => {
let widget = (
<cx>
<Grid
records={bind("records")}
columns={[
{
header: "Priority",
sortField: "priority",
sortValue: computable("$record.priority", (p: number) => -p),
value: { tpl: "P{$record.priority}" },
sortable: true,
},
]}
/>
</cx>
);

let store = new Store({ data: { records } });
const component = await createTestRenderer(store, widget);

await clickHeader(component, "Priority");
assert.deepStrictEqual(getRenderedLabels(component), ["P10", "P2", "P1"]);
});

it("sorts by the displayed value when no sortField is set", async () => {
let widget = (
<cx>
<Grid
records={bind("records")}
columns={[
{
header: "Priority",
field: "priority",
value: { tpl: "P{$record.priority}" },
sortable: true,
},
]}
/>
</cx>
);

let store = new Store({ data: { records } });
const component = await createTestRenderer(store, widget);

await clickHeader(component, "Priority");
assert.deepStrictEqual(getRenderedLabels(component), ["P1", "P10", "P2"]);
});

it("applies sortField precedence to sorters restored from sortField/sortDirection bindings", async () => {
let widget = (
<cx>
<Grid
records={bind("records")}
sortField={bind("sortField")}
sortDirection={bind("sortDirection")}
columns={[
{
header: "Priority",
sortField: "priority",
value: { tpl: "P{$record.priority}" },
sortable: true,
},
]}
/>
</cx>
);

let store = new Store({
data: { records, sortField: "priority", sortDirection: "ASC" },
});
const component = await createTestRenderer(store, widget);

assert.deepStrictEqual(getRenderedLabels(component), ["P1", "P2", "P10"]);
});

it("marks only the clicked column as sorted", async () => {
let widget = (
<cx>
<Grid
records={bind("records")}
columns={[
{
header: "Priority",
sortField: "priority",
value: { tpl: "P{$record.priority}" },
sortable: true,
},
{
header: "Label",
value: { tpl: "P{$record.priority}" },
sortable: true,
},
]}
/>
</cx>
);

let store = new Store({ data: { records } });
const component = await createTestRenderer(store, widget);

await clickHeader(component, "Priority");

let ths = component.root.findAllByType("th");
let sorted = ths.filter((th: any) => (th.props.className || "").includes("sorted-asc"));
assert.strictEqual(sorted.length, 1);
assert.strictEqual(thText(sorted[0]), "Priority");
});
});
33 changes: 28 additions & 5 deletions packages/cx/src/widgets/grid/Grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,16 @@ export interface GridColumnConfig {
children?: ChildNode | ChildNode[];
key?: string;
pad?: boolean;
/**
* Record field used for sorting instead of `field` or the displayed `value`.
* Use it when the column displays a computed value but should sort by raw data.
* Sort key precedence: `sortValue` > `sortField` > `value` > `field`.
*/
sortField?: string;
/**
* Selector (binding, template, expression or computable) used for sorting instead
* of `field`, `sortField` or the displayed `value`. Takes the highest precedence.
*/
sortValue?: Prop<any>;
style?: StyleProp;
trimWhitespace?: boolean;
Expand Down Expand Up @@ -944,9 +953,15 @@ export class Grid<T = unknown> extends ContainerBase<GridConfig<T>, GridInstance
if (sortField) {
for (let l = 0; l < 10; l++) {
let line = instance.row[`line${l}`];
let sortColumn = line && line.columns && line.columns.find((c: any) => (c.sortField || c.field) == sortField);
let sortColumn =
line && line.columns && line.columns.find((c: any) => (c.sortField || c.field) == sortField);
if (sortColumn) {
data.sorters[0].value = isDefined(sortColumn.sortValue) ? sortColumn.sortValue : sortColumn.value;
// precedence: sortValue > sortField > value > field
data.sorters[0].value = isDefined(sortColumn.sortValue)
? sortColumn.sortValue
: sortColumn.sortField
? undefined
: sortColumn.value;
data.sorters[0].comparer = sortColumn.comparer;
data.sorters[0].sortOptions = sortColumn.sortOptions;
break;
Expand Down Expand Up @@ -1312,13 +1327,19 @@ export class Grid<T = unknown> extends ContainerBase<GridConfig<T>, GridInstance
mods.push("sortable");
let sorter = data.sorters && data.sorters[0];
let sortColumnField = hdwidget.sortField || hdwidget.field;
let sortColumnValue = isDefined(hdwidget.sortValue) ? hdwidget.sortValue : hdwidget.value;
let sortColumnValue = isDefined(hdwidget.sortValue)
? hdwidget.sortValue
: hdwidget.sortField
? undefined
: hdwidget.value;
// a sort is identified by its (field, value selector) pair, so columns
// sorting by the same field through different value selectors don't both match
let sorted =
sorter &&
!!sorter.direction &&
(sortColumnField ? sorter.field == sortColumnField : !sorter.field && isDefined(sortColumnValue)) &&
(sortColumnField
? sorter.field == sortColumnField
: !sorter.field && isDefined(sortColumnValue)) &&
sorter.value === sortColumnValue;
if (sorted) {
mods.push("sorted-" + sorter.direction.toLowerCase());
Expand Down Expand Up @@ -1494,7 +1515,9 @@ export class Grid<T = unknown> extends ContainerBase<GridConfig<T>, GridInstance
let header = column.components[`header${headerLine + 1}`];

let field = column.sortField || column.field;
let value = isDefined(column.sortValue) ? column.sortValue : column.value;
// precedence: sortValue > sortField > value > field; the comparer prefers value
// over field, so value must not be attached when an explicit sortField is set
let value = isDefined(column.sortValue) ? column.sortValue : column.sortField ? undefined : column.value;
let comparer = column.comparer;
let sortOptions = column.sortOptions;

Expand Down
Loading