diff --git a/homedocs/src/pages/docs/tables/grid.mdx b/homedocs/src/pages/docs/tables/grid.mdx index 60b02134b..709e02a00 100644 --- a/homedocs/src/pages/docs/tables/grid.mdx +++ b/homedocs/src/pages/docs/tables/grid.mdx @@ -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: @@ -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`. | diff --git a/litmus/bugs/grid-sorting-without-field.js b/litmus/bugs/grid-sorting-without-field.js index 239a50cbe..766349533 100644 --- a/litmus/bugs/grid-sorting-without-field.js +++ b/litmus/bugs/grid-sorting-without-field.js @@ -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 }, @@ -95,10 +97,10 @@ export default () => (
-

3. Precedence: value silently wins over field/sortField

+

3. Precedence: sortValue > sortField > value > field

- 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 sortField. + Expected on ASC: field + value sorts by the label (P1, P10, P2), sortField + value sorts + by the numeric field (P1, P2, P10), sortValue sorts by the negated priority (P10, P2, P1).

( 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, + }, ]} />
diff --git a/packages/cx/src/widgets/grid/Grid.sorting.spec.tsx b/packages/cx/src/widgets/grid/Grid.sorting.spec.tsx new file mode 100644 index 000000000..73a8e575b --- /dev/null +++ b/packages/cx/src/widgets/grid/Grid.sorting.spec.tsx @@ -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 = ( + + + + ); + + 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 = ( + + -p), + value: { tpl: "P{$record.priority}" }, + sortable: true, + }, + ]} + /> + + ); + + 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 = ( + + + + ); + + 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 = ( + + + + ); + + 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 = ( + + + + ); + + 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"); + }); +}); diff --git a/packages/cx/src/widgets/grid/Grid.tsx b/packages/cx/src/widgets/grid/Grid.tsx index 1db9b0e27..dffbd6708 100644 --- a/packages/cx/src/widgets/grid/Grid.tsx +++ b/packages/cx/src/widgets/grid/Grid.tsx @@ -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; style?: StyleProp; trimWhitespace?: boolean; @@ -944,9 +953,15 @@ export class Grid extends ContainerBase, 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; @@ -1312,13 +1327,19 @@ export class Grid extends ContainerBase, 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()); @@ -1494,7 +1515,9 @@ export class Grid extends ContainerBase, 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;