Skip to content

Commit 8c1bf5b

Browse files
fix(plugin-list): a stored view sort the platform refuses is not sent, so the list loads; an explicit column honours its field's sortable: false under client-side sorting (#30032)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBNVuXH5MLbuSJQfjBFcLvKn)_ Fixes #30024 Clause-②: no Metadata-protocol: none **Landing order with #30021:** #30021 merged first (`140d8ce8`, 2026-11-04T08:35Z) and this branch carries it (merge of `origin/main` `c618e890`). Land this PR before the next release cut so both ship in the same release: a release with #30021 and without this PR fails to load any existing view whose `sort` names a `sortable: false` field. This PR drops only entries the served projection refuses, so it needs no change of its own when the projection gains a verdict. ## 维护者速读 - 改了什么:列表视图发给服务器的排序,先按平台随对象元数据下发的「可排序投影」去掉平台拒收的那几项(取数、服务端导出、看板/日历/时间线/甘特各自取数都一样),剩下的按原顺序照发;表格里显式列出的列,字段写了 `sortable: false` 时,浏览器内排序也不再给排序点击。 - 为什么改:已存视图的 `sort` 若指向平台拒收的字段(今天是公式字段,#30021 落地后还有 `sortable: false` 字段),列表首次取数就把它发出去,接口答 400 `INVALID_SORT`,整张列表显示加载失败而不是数据。 - 风险与回滚:只少发平台本来就拒收的排序项,其余请求字节不变;没有投影时(旧平台、内联数据)行为不变;被拒那一项仍留在排序面板里「仅可移除」,下次保存时被丢弃(原有行为)。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-list/src/ListView.tsx` 的 `serverSort`(新的请求侧排序,及 `platformSortability` 上移到 `objectDef` 旁)、`ui/packages/plugin-grid/src/objectGridGenerateColumns.tsx` 第一条路径的 `sortable` 行、`ui/packages/plugin-list/src/__tests__/ListView.refusedSortNotSent-30024.test.tsx`。 - 两分钟验证:`cd ui && pnpm exec vitest run packages/plugin-list/src/__tests__/ListView.refusedSortNotSent-30024.test.tsx packages/plugin-grid/src/__tests__/clientSortAuthorUnsortable-30024.test.tsx`,预期 8 个用例全过。 - 需要你做的:无,席位落地。 ## What changes - **Fetch side (`ListView.tsx`).** `serverSort` is `currentSort` passed through `filterPlatformSortableSort(currentSort, platformSortability)`, the same reader the header display (`ObjectGrid` `manualSort`) and the persist path (`emitSortChange`) already use. The data fetch's `$orderby`, the server export's `sort`, and the `sort` handed to child views read it. The kanban, calendar, timeline and gantt fetch their own rows from that `sort`, so a refused entry reaches the server from none of them. With no projection served, `serverSort` is `currentSort` unchanged. - **One projection read.** `platformSortability` moved up beside `objectDef`, because the fetch effect's dependency list is evaluated where the effect is declared and the old declaration sat about 2,000 lines below it. The sort picker and `emitSortChange` read the same memo as before. No second reader and no spec key. - **What stays.** `currentSort` keeps the refused entry, so the sort picker still lists it removable only (#6455, objectui#11943), and the picker's value and the toolbar's sort count read it. `pageResetSignature` still reads `currentSort` by value. - **Dropped-sort notice:** the list has no notice for a refused or dropped entry today. The only existing affordance is the picker's removable-only row. No new UI was added, as the order asked. - **#6455 comments revised.** The prop docblock and the `emitSortChange` docblock said the refused entry stays "the order this list asks the server for". They now say that no request carries it either. - **Header under client-side sorting (`objectGridGenerateColumns.tsx`, path A).** Seat ruling B: an explicit column now reads its field's `sortable: false` (`objectDefField?.sortable !== false`) beside the column's own flag, as paths B-D (`fieldDef?.sortable !== false`) already did. Where the sort stays in the browser, the served projection still does not judge the header. That keeps the #3950 doctrine stated in `ObjectGrid.tsx` `withSortability` ("Only under manual sorting. A client-side sort keys off the value the cell renders ... which is honest, so those headers stay"), pinned by `ObjectGrid.unmaterializedSort.test.tsx` "keeps the formula header live where the sort never leaves the browser". Where the sort leaves the browser, `withSortability` still applies the projection, unchanged. ## Tests - `ListView.refusedSortNotSent-30024.test.tsx` (6 cases). The data source double answers `400 INVALID_SORT` (`status` 400, `code` `INVALID_SORT`) for any `$orderby` naming a refused field, as the Data API does. The projection is the platform's own `resolveObjectSortability` over an object with a `formula` field (refused as `virtual-type`) and a `sortable: false` text field (refused as `author-declared` since #30021). No case hand-writes a verdict. - A stored sort naming only the refused field: every request carries no `$orderby`, and no load-error panel is drawn. - CONTROL: a sortable field's sort is sent unchanged (`amount desc`). - A mixed sort `[name asc, expected_revenue desc, amount desc]` sends `[name asc, amount desc]`. - A stored sort `[internal_code asc, amount desc]` on a `text` field marked `sortable: false` (projection `{ sortable: false, reason: 'author-declared' }` since #30021) sends `[amount desc]`, and the list loads. - CONTROL: with no projection served, the refused entry is sent as before and the double's 400 shows the load-error panel. This proves the harness can show the failure that the fixed cells do not. - `clientSortAuthorUnsortable-30024.test.tsx` (2 cases, inline rows so the sort stays in the browser). A column on a `sortable: false` field offers no sort click, while `Amount` and `Name` on the same table still do, and no request is made. The `formula` header stays live with a refusing projection served (#3950 doctrine). ## Acceptance notes - **Live check on a dev boot: in progress at PR open.** #30021 is now on this branch, so a view whose `sort` names a `sortable: false` field can be loaded against the real door; the reading goes into the os-dev-report on #30024. - First fetch: the data fetch waits on `objectDefLoaded`, and `setObjectDef(def)` and `setObjectDefLoaded(true)` are set in the same async continuation (batched), so the first request already sees the projection. When `getObjectSchema` fails or is absent, there is no projection and the sort is sent unchanged, as before. - `ObjectGrid`'s `manualSort` still filters the header arrows for display. ListView's grid hand-off (`sort: currentSort` in the manual-sort block) is unchanged, because `ObjectGrid` already filters it there and the grid on that path does not fetch. - Serial constraints, re-checked at PR time: no other in-flight `domain:ui` claim touches `plugin-list` or `plugin-grid` (seat read 2026-11-04T08:31Z, in the claim). ## Verification Local runs at head `a3a33266` (merge of `origin/main` `c618e890` + this PR's two commits): <details><summary>Suites, type-check, lint</summary> - `cd ui && pnpm exec vitest run packages/plugin-grid/` (under `os-verify-lock`): Test Files 277 passed (277), Tests 2376 passed (2376), exit 0. - `cd ui && pnpm exec vitest run packages/plugin-list/`: Test Files 189 passed (189), Tests 1796 passed (1796), exit 0. - `pnpm --filter @object-ui/plugin-list --filter @object-ui/plugin-grid run type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`, both packages): exit 0. - `npx eslint` on the four changed `src` files from each package dir (CI's ui lint form): 0 errors. - Prerequisite: `npx turbo run build --filter='@object-ui/plugin-list...' --filter='@object-ui/plugin-grid...' --concurrency=2` after the main merge: 54 tasks, exit 0. </details> <details><summary>Ablation (fix committed first, mutation through scripts/ablation-replace.mjs, restored to the HEAD blob)</summary> - Fetch-side filter removed (`serverSort` → `currentSort` at the `$orderby` build), post-merge: 3 of 6 cases red (the `sortable: false` case, the formula-only case, the mixed case); both CONTROLs and the sanity case stay green. Restore: blob == HEAD, `git diff HEAD` empty, marker count 0. The same mutation before the merge: 2 of 5 red. - Header change removed (`objectDefField?.sortable !== false` dropped from path A): both grid cases red (the `#3950` case waits on the `sortable: false` header first). Restore verified the same way. - Both test files import the code under test from `src` (relative import / ui's `@object-ui/*` source alias), so no dist rebuild sits between the mutation and the run. </details> <details><summary>dispatch-gates --commands (35 derived, serial, unlocked except where a gate takes the lock itself)</summary> At PR open, 24 of 35 had run, every one `exit 0`: the ADR-0087 pair, `check-changeset-no-major`, closing-keyword parity pair, comment-mask corpus, empty-changeset pair, `check-sdui-manifest` pair, release-rehearsal and release-pending-publish self-tests, `pnpm --dir ui exec vitest run scripts/`, spec `check:duration-unit-keys`, `check:changeset-gate-self-tests`, `check:closed-ceremony-text`, `check:cross-package-test-inputs`, `check:dispatcher-error-vocabulary`, `check:driver-memory-census`, `check:engine-double-contract`, `check:gitlink-declared`, `check:nul-bytes`, `check:published-files`, `check:query-options-erasure`. The remaining 11 (ending with `check:type-check-debt`) were still running; the full `--ran` reconciliation goes into the os-dev-report on #30024. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzi9idgN8uB7bkz9nTg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c618e89 commit 8c1bf5b

5 files changed

Lines changed: 424 additions & 21 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
'@object-ui/plugin-grid': patch
4+
---
5+
6+
fix(plugin-list): a stored view sort the platform refuses is no longer sent, so the list loads instead of failing; a column on a `sortable: false` field offers no sort when the grid sorts in the browser
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
**Visible behaviour change on a released page.** A list view whose stored `sort` (or a restored user sort) names a field the platform will not order by used to send that sort with its data request. The Data API answers such a sort `400 INVALID_SORT`, so the list showed its load error instead of its rows. Fields the platform refuses are the ones its sortability projection, served with the object's metadata, marks unsortable: a `formula` field today, and a field marked `sortable: false` once the platform honours that flag.
12+
13+
- **`@object-ui/plugin-list`:** the list's data request, its server export and the kanban, calendar, timeline and gantt views (which fetch their own rows) now send the sort without the refused entries. The entries left keep their original order, and a sort with nothing refused in it is sent unchanged. The refused entry is still listed in the sort picker as removable only, as before, and the next sort the list saves leaves it out. With no projection served (an older platform, inline rows), the sort is sent as before.
14+
- **`@object-ui/plugin-grid`:** a column listed in a view's `columns` now reads its field's `sortable: false`, as columns derived from the object's fields already did. Where the grid sorts the rows it holds (inline rows), such a column no longer offers a sort click. A `formula` column keeps its sort click there, since the browser orders by the value the cell shows; where the sort is sent to the server, the platform's projection still decides, as before.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* objectstack#30024 — under client-side sorting an explicit column honours its
3+
* FIELD's `sortable: false`.
4+
*
5+
* The grid's column generator has four paths. Paths B-D read the field's own
6+
* flag (`fieldDef?.sortable !== false`); path A, the explicit `columns` path,
7+
* read only the COLUMN's `sortable`. So a view listing a field its author
8+
* marked `sortable: false` still offered a sort click wherever the sort stays
9+
* in the browser (`manualSortingOn` off: inline rows).
10+
*
11+
* Seat ruling B: under client-side sorting the header reads the two author
12+
* flags (column and field), and the served projection judges only a sort that
13+
* leaves the browser (`withSortability`). That keeps the #3950 doctrine
14+
* (ObjectGrid's `withSortability` docblock, pinned by
15+
* `ObjectGrid.unmaterializedSort.test.tsx`): a client-side sort orders by the
16+
* value the cell shows, so a `formula` header stays live, even when a
17+
* projection that refuses it was served.
18+
*
19+
* Every negative cell sits beside a positive control on the same table.
20+
*/
21+
import { describe, it, expect, vi, beforeAll } from 'vitest';
22+
import { render, screen, waitFor } from '@testing-library/react';
23+
import '@testing-library/jest-dom';
24+
import React from 'react';
25+
import { resolveObjectSortability } from '@objectstack/spec/api';
26+
import { attachObjectSortability } from '@object-ui/core';
27+
28+
import { ObjectGrid } from '../ObjectGrid';
29+
import { registerAllFields } from '@object-ui/fields';
30+
import { ActionProvider } from '@object-ui/react';
31+
32+
registerAllFields();
33+
34+
beforeAll(() => {
35+
if (!Element.prototype.hasPointerCapture) {
36+
Element.prototype.hasPointerCapture = vi.fn(() => false) as any;
37+
}
38+
if (!Element.prototype.scrollIntoView) {
39+
Element.prototype.scrollIntoView = vi.fn() as any;
40+
}
41+
});
42+
43+
const OBJECT = {
44+
name: 'crm_opportunity',
45+
fields: {
46+
id: { type: 'text' },
47+
name: { type: 'text', label: 'Name' },
48+
amount: { type: 'currency', label: 'Amount' },
49+
internal_code: { type: 'text', label: 'Internal Code', sortable: false },
50+
expected_revenue: { type: 'formula', label: 'Expected Revenue', expression: 'amount * 2' },
51+
},
52+
};
53+
54+
function makeDataSource() {
55+
return {
56+
find: vi.fn(async () => ({ data: [], total: 0 })),
57+
getObjectSchema: async () => {
58+
const schema = JSON.parse(JSON.stringify(OBJECT));
59+
attachObjectSortability(schema, resolveObjectSortability(OBJECT));
60+
return schema;
61+
},
62+
} as any;
63+
}
64+
65+
function renderClientSortedGrid() {
66+
const ds = makeDataSource();
67+
const schema: any = {
68+
type: 'object-grid',
69+
objectName: 'crm_opportunity',
70+
columns: [
71+
{ field: 'name', label: 'Name' },
72+
{ field: 'amount', label: 'Amount' },
73+
{ field: 'internal_code', label: 'Internal Code' },
74+
{ field: 'expected_revenue', label: 'Expected Revenue' },
75+
],
76+
// Inline rows: the sort never leaves the browser (`manualSortingOn` off).
77+
data: {
78+
provider: 'value',
79+
items: [
80+
{ id: 'a', name: 'A', amount: 10, internal_code: 'Z-1', expected_revenue: 20 },
81+
{ id: 'b', name: 'B', amount: 30, internal_code: 'A-9', expected_revenue: 60 },
82+
],
83+
},
84+
};
85+
const utils = render(
86+
<ActionProvider>
87+
<ObjectGrid schema={schema} dataSource={ds} />
88+
</ActionProvider>,
89+
);
90+
return { ...utils, ds };
91+
}
92+
93+
const headerCell = (container: HTMLElement, label: string) =>
94+
Array.from(container.querySelectorAll('thead th')).find((th) =>
95+
th.textContent?.trim() === label,
96+
) as HTMLElement;
97+
98+
/** DataTable makes a sortable header `cursor-pointer`, and not an unsortable one. */
99+
const offersSort = (th: HTMLElement) => th.className.includes('cursor-pointer');
100+
101+
describe('client-side sorting honours the field\'s own sortable: false (objectstack#30024)', () => {
102+
it('a column on a sortable: false field offers no sort, while a sortable sibling still does', async () => {
103+
const { container, ds } = renderClientSortedGrid();
104+
await waitFor(() => expect(screen.getByText('A')).toBeInTheDocument());
105+
// Wait for the object schema to land, so the field flag is what is read.
106+
await waitFor(() => expect(offersSort(headerCell(container, 'Internal Code'))).toBe(false));
107+
expect(offersSort(headerCell(container, 'Amount'))).toBe(true);
108+
expect(offersSort(headerCell(container, 'Name'))).toBe(true);
109+
// The sort is the browser's: no request was asked to order anything.
110+
expect(ds.find).not.toHaveBeenCalled();
111+
});
112+
113+
it('keeps the #3950 doctrine: the formula header stays live under client-side sorting, even with a refusing projection served', async () => {
114+
const served = resolveObjectSortability(OBJECT) as { fields: Record<string, any> };
115+
expect(served.fields.expected_revenue?.sortable).toBe(false);
116+
const { container } = renderClientSortedGrid();
117+
await waitFor(() => expect(screen.getByText('A')).toBeInTheDocument());
118+
await waitFor(() => expect(offersSort(headerCell(container, 'Internal Code'))).toBe(false));
119+
expect(offersSort(headerCell(container, 'Expected Revenue'))).toBe(true);
120+
});
121+
});

‎ui/packages/plugin-grid/src/objectGridGenerateColumns.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,15 @@ export function useObjectGridGenerateColumns({
552552
...(!isEssential && { className: 'hidden sm:table-cell' }),
553553
...(col.width && { width: col.width }),
554554
...(inferredAlign && { align: inferredAlign }),
555-
sortable: col.sortable !== false,
555+
// objectstack#30024 — the column's own `sortable: false` AND its
556+
// field's, as paths B-D read the field's. An explicit column on a
557+
// field its author marked `sortable: false` used to keep its sort
558+
// click wherever the sort stays in the browser. Where the sort
559+
// leaves the browser, `withSortability` (ObjectGrid) adds the
560+
// served projection's verdict; a client-side sort keeps the
561+
// #3950 doctrine (a formula header stays live) and reads only
562+
// these two author flags.
563+
sortable: col.sortable !== false && objectDefField?.sortable !== false,
556564
...(col.resizable !== undefined && { resizable: col.resizable }),
557565
...(cellRenderer && { cell: cellRenderer }),
558566
...(col.pinned && { pinned: col.pinned }),

‎ui/packages/plugin-list/src/ListView.tsx‎

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@ export interface ListViewProps {
324324
* the element (readonly T[] in, T[] out) and so preserves whatever it is
325325
* given. Normalized-vs-raw therefore does not move the TYPE here; it only
326326
* decides whether platform-unsortable entries are still present (#6455).
327+
* Under a served projection they never are: the emit drops them, as every
328+
* request this list sends already does (objectstack#30024).
327329
*/
328330
onSortChange?: (sort: SortItem[]) => void;
329331
/**
@@ -1881,6 +1883,25 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
18811883
const [objectDef, setObjectDef] = React.useState<any>(null);
18821884
const [objectDefLoaded, setObjectDefLoaded] = React.useState(false);
18831885

1886+
/**
1887+
* ONE read of the served per-column sortability projection
1888+
* (objectstack#10235 ruling A, consumed through `@object-ui/core`), for every
1889+
* leg of this component that asks it: the sort this list SENDS (`serverSort`
1890+
* below), the sort picker's list, and the sort it emits for a host to persist
1891+
* (`emitSortChange`). Read more than once, two copies could answer
1892+
* differently about the same field on the same render, which is the drift
1893+
* `isPlatformSortableField` was consolidated to end.
1894+
*
1895+
* Declared beside `objectDef` because the data fetch reads it, and that
1896+
* effect's dependency list is evaluated where the effect is declared.
1897+
* `undefined` is "no signal served" (an older deployment, an inline or mock
1898+
* data source, or `objectDef` not yet read), never "nothing is sortable".
1899+
*/
1900+
const platformSortability = React.useMemo(
1901+
() => readObjectSortability(objectDef),
1902+
[objectDef],
1903+
);
1904+
18841905
/**
18851906
* objectui#12966 — the object's `search` resolves to no field, so the server
18861907
* refuses any term with `400 INVALID_FIELD`: no quick-search box, and a term
@@ -1983,6 +2004,32 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
19832004
// eslint-disable-next-line react-hooks/exhaustive-deps
19842005
}, [schemaSortKey]);
19852006

2007+
/**
2008+
* objectstack#30024 — the sort this list ASKS THE SERVER FOR: `currentSort`
2009+
* less every entry the served projection refuses.
2010+
*
2011+
* `currentSort` is seeded from the stored view `sort` (and a URL or
2012+
* restored user sort), so it can name a field the platform will not order
2013+
* by: a `formula`, an unknown name, or one its author marked
2014+
* `sortable: false`. The Data API answers such a sort `400 INVALID_SORT`, so
2015+
* sending it failed the whole list on its first fetch. Every request this
2016+
* component makes, and every child view that fetches its own rows from the
2017+
* `sort` handed to it, reads this list instead, so a refused entry never
2018+
* reaches the server and the rows load in the order of the entries left,
2019+
* kept in their original order.
2020+
*
2021+
* The refused entry stays in `currentSort`: the sort picker still lists it
2022+
* removable-only, and the persist boundary (`emitSortChange`) drops it from
2023+
* the next write. No signal served ⇒ sent unchanged, as before
2024+
* objectstack#10235, because there is no verdict to filter by.
2025+
*/
2026+
const serverSort = React.useMemo(
2027+
() => (platformSortability
2028+
? filterPlatformSortableSort(currentSort, platformSortability)
2029+
: currentSort),
2030+
[currentSort, platformSortability],
2031+
);
2032+
19862033
/**
19872034
* The view's DECLARED sort, for the "reset to default" affordance
19882035
* (objectui#4243).
@@ -3183,8 +3230,10 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
31833230

31843231
// Convert sort to query format
31853232
// Use array format to ensure order is preserved (Object keys are not guaranteed ordered)
3186-
const sort: any = currentSort.length > 0
3187-
? currentSort
3233+
// `serverSort`, not `currentSort`: a stored entry the platform's
3234+
// sortability projection refuses never reaches the server (objectstack#30024).
3235+
const sort: any = serverSort.length > 0
3236+
? serverSort
31883237
.filter(item => item.field) // Ensure field is selected
31893238
.map(item => ({ field: item.field, order: item.order }))
31903239
: undefined;
@@ -3827,7 +3876,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
38273876
// silently un-suppresses nothing, because the finding it was suppressing
38283877
// simply moves elsewhere. Add prose ABOVE this point, never below it.
38293878
// eslint-disable-next-line react-hooks/exhaustive-deps
3830-
}, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, appliedQueueSittingConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, schema.kanban, schema.calendar, schema.gallery, schema.timeline, schema.gantt, schema.map, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, predicateProjectionKey, invalidationNonce, groupingNeedsHeaderQuery, calendarWindowKey, rowsCarryConcurrencyToken]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change; `rowsCarryConcurrencyToken` (objectui#12292) re-reads the rows with their token when inline edit turns on
3879+
}, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, serverSort, appliedFilters, appliedUserFilterConditions, appliedQueueSittingConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, schema.kanban, schema.calendar, schema.gallery, schema.timeline, schema.gantt, schema.map, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, predicateProjectionKey, invalidationNonce, groupingNeedsHeaderQuery, calendarWindowKey, rowsCarryConcurrencyToken]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change; `rowsCarryConcurrencyToken` (objectui#12292) re-reads the rows with their token when inline edit turns on
38313880

38323881
// Any change to the result-defining inputs (object, filters, sort, search,
38333882
// grouping, page size) invalidates the current page number — snap back to
@@ -4316,7 +4365,10 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
43164365
// chart branch below, and any of these rendered standalone) never saw the
43174366
// view's base filter at all.
43184367
filter: authoredFilter,
4319-
sort: currentSort,
4368+
// `serverSort`: the kanban, calendar, timeline and gantt fetch their own
4369+
// rows from this `sort`, so it carries no entry the platform refuses
4370+
// (objectstack#30024).
4371+
sort: serverSort,
43204372
className: "h-full w-full",
43214373
// Disable internal controls that clash with ListView toolbar
43224374
showSearch: false,
@@ -4923,7 +4975,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
49234975
// asynchronously (`/me/permissions`) and `objectDef` loads into state, so a
49244976
// grid schema built before either resolved must be rebuilt when they do —
49254977
// otherwise `editable` keeps the pre-verdict answer for the session.
4926-
}, [currentView, schema, authoredFilter, currentSort, effectiveFields, hasAuthoredColumns, groupingConfig, rowColorConfig, navigation.handleClick, hostRoutesRowClicks, density.mode, galleryCardSize, inlineEdit, inlineEditOffered, objectDef, selfQueryFilter, ganttSearchTerm, gridOwnsGroupedFetch, calendarWindowed, handleCalendarVisibleRange, kanbanLanesPaged]);
4978+
}, [currentView, schema, authoredFilter, serverSort, effectiveFields, hasAuthoredColumns, groupingConfig, rowColorConfig, navigation.handleClick, hostRoutesRowClicks, density.mode, galleryCardSize, inlineEdit, inlineEditOffered, objectDef, selfQueryFilter, ganttSearchTerm, gridOwnsGroupedFetch, calendarWindowed, handleCalendarVisibleRange, kanbanLanesPaged]);
49274979

49284980
// The count the toolbar shows: a record-mode amount's code condition is
49294981
// part of its amount row (objectstack#27188), not a filter of its own, and a
@@ -5169,15 +5221,10 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
51695221
// the type read when no projection is served). A field the two rules list
51705222
// anyway carries no flag, whether or not the sort names it.
51715223
//
5172-
// ONE read of the served projection, for BOTH legs below — the list this
5173-
// picker renders, and the sort it emits for a host to persist. Read twice,
5174-
// the two copies could answer differently about the same field on the same
5175-
// render, which is the drift `isPlatformSortableField` was consolidated to
5176-
// end. `undefined` stays "no signal served", never "nothing is sortable".
5177-
const platformSortability = React.useMemo(
5178-
() => readObjectSortability(objectDef),
5179-
[objectDef],
5180-
);
5224+
// Both legs below — the list this picker renders, and the sort it emits for
5225+
// a host to persist — read `platformSortability`, the ONE read of the served
5226+
// projection declared beside `objectDef` (the same read `serverSort` filters
5227+
// the request by).
51815228

51825229
const { sortFields, sortHasRelationalField, sortPicksRelationalField } = React.useMemo(() => {
51835230
const inUse = new Set(currentSort.map((item) => item.field).filter(Boolean));
@@ -5234,9 +5281,15 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
52345281
* So the two legs part company HERE, at the one boundary every emit crosses,
52355282
* exactly as #5729 parted them at the grid seam (`ObjectGrid`'s
52365283
* `manualSort` / `manualOnSortChange` pair). The refused entry stays in
5237-
* `currentSort` — listed, removable, and still the order this list asks the
5238-
* server for — while no write ever carries it. Removing it persists the
5239-
* removal; a sort with nothing refused in it is emitted unchanged.
5284+
* `currentSort`, listed and removable, while no write ever carries it.
5285+
* Removing it persists the removal; a sort with nothing refused in it is
5286+
* emitted unchanged.
5287+
*
5288+
* Nor does any REQUEST carry it (objectstack#30024): the fetch, the export
5289+
* and the child views read `serverSort`, which drops it through the same
5290+
* `filterPlatformSortableSort` call. This boundary used to leave it in "the
5291+
* order this list asks the server for", and the server answers such a sort
5292+
* `400 INVALID_SORT`, so a stored view naming one failed its first fetch.
52405293
*
52415294
* Every `onSortChange` in this component goes through here rather than each
52425295
* call site filtering for itself: a builder edit, a header click and a
@@ -5385,8 +5438,10 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
53855438
.map((f: any) => columnIdentity(f))
53865439
.filter(Boolean) as string[];
53875440

5388-
const sort = currentSort.length > 0
5389-
? currentSort
5441+
// The rows on screen were asked for with `serverSort`, so the file is too:
5442+
// a refused entry would fail the export the way it failed the list.
5443+
const sort = serverSort.length > 0
5444+
? serverSort
53905445
.filter(item => item.field)
53915446
.map(item => ({ field: item.field, direction: item.order as 'asc' | 'desc' }))
53925447
: undefined;
@@ -5493,7 +5548,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
54935548
setShowExport(false);
54945549
// `searchTerm` / `searchableFields` belong here: the export now narrows by
54955550
// the active search, so a stale closure would export the wrong row set.
5496-
}, [data, effectiveFields, resolvedExportOptions, schema.objectName, authoredFilter, schema.searchableFields, exportPermitted, dataSource, appliedFilters, appliedUserFilterConditions, appliedQueueSittingConditions, currentSort, searchTerm, objectDef, resolveObjectLabel]);
5551+
}, [data, effectiveFields, resolvedExportOptions, schema.objectName, authoredFilter, schema.searchableFields, exportPermitted, dataSource, appliedFilters, appliedUserFilterConditions, appliedQueueSittingConditions, serverSort, searchTerm, objectDef, resolveObjectLabel]);
54975552

54985553
// All available fields for hide/show (with i18n)
54995554
//

0 commit comments

Comments
 (0)