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;