From 91cec45db00203252bc64c343dda23aea118d237 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:38:19 +0000 Subject: [PATCH 1/3] test(plugin-list): pin that the sort picker leaves out a field the object marks hidden Claude-Session: https://claude.ai/code/session_01Utp6CY1ikAq74XRbNdsyUC Co-authored-by: Claude --- .changeset/30037-sort-picker-hidden-fields.md | 14 ++++++++++++++ .../ListView.sortFieldRead-11943.test.tsx | 16 ++++++++++++++++ .../ListView.sortRemovableOnly-11943.test.tsx | 7 ++++++- 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 .changeset/30037-sort-picker-hidden-fields.md diff --git a/.changeset/30037-sort-picker-hidden-fields.md b/.changeset/30037-sort-picker-hidden-fields.md new file mode 100644 index 00000000000..05e12eb50fa --- /dev/null +++ b/.changeset/30037-sort-picker-hidden-fields.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-list': patch +--- + +fix(plugin-list): the list's sort picker no longer offers a field the object marks hidden, such as the queue column or the search index + +Clause-②: no +Metadata-protocol: none + +**Visible behaviour change on a released page.** A list's 排序 / Sort picker offered every field of the object, including the injected system columns the platform marks `hidden: true` (the queue column, the `__search` companion shown as an untranslated "Search Index", `organization_id`). The Filter panel's field list already left those out; the sort picker now follows the same rule. + +- A field the object definition marks `hidden: true` is no longer listed in the sort picker, and "Add sort" never starts on one. +- A view or saved sort that already orders by such a field keeps working: the field stays listed in that row as removable only (disabled), as the picker already does for an unreadable or refused field, so the row is not blank and can be removed. +- A hidden lookup (such as `organization_id`) no longer raises the "relational fields cannot be sorted" hint under the rows, since the picker never offered it. diff --git a/ui/packages/plugin-list/src/__tests__/ListView.sortFieldRead-11943.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.sortFieldRead-11943.test.tsx index ee101563197..4b59c71eb45 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.sortFieldRead-11943.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.sortFieldRead-11943.test.tsx @@ -222,3 +222,19 @@ describe('the Sort picker field list asks the column read check (objectui#11943) expect(screen.getByTestId('sort-relational-info')).toBeInTheDocument(); }); }); + +describe('a lookup the definition marks hidden does not raise the relational hint (objectstack#30037)', () => { + it('withheld like an unreadable lookup: not offered and no hint; a visible lookup still raises it', async () => { + // `organization_id` is the platform's hidden lookup: the picker never + // offers it, so the hint has nothing to explain. + const hiddenLookup = { ...FIELDS, org: { type: 'lookup', label: 'Organization', reference: 'sys_org', hidden: true } }; + mount({ perms: FULL_READ, fields: hiddenLookup }); + expect(await openSortOptions()).toEqual(FULL_READ_LABELS); + expect(screen.queryByTestId('sort-relational-info')).toBeNull(); + cleanup(); + const visibleLookup = { ...FIELDS, org: { type: 'lookup', label: 'Organization', reference: 'sys_org' } }; + mount({ perms: FULL_READ, fields: visibleLookup }); + await openSortOptions(); + expect(screen.getByTestId('sort-relational-info')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.sortRemovableOnly-11943.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.sortRemovableOnly-11943.test.tsx index 6b47de81a80..4fb81f2dcef 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.sortRemovableOnly-11943.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.sortRemovableOnly-11943.test.tsx @@ -18,6 +18,9 @@ * - UNREADABLE: field-level read denies it (`canReadField`, objectui#11943). * - PLATFORM-REFUSED: the served projection refuses to order by it (#6455). * - RELATIONAL: a lookup, listed as ordering by ID (objectui#4243). + * - HIDDEN: the object definition marks it `hidden: true` (objectstack#30037), + * the rule the Filter panel's list already follows. `search_index` stands + * in for the platform's `__search` companion and its `queue` column. * * In each case the field is declared FIRST on the object, so an "Add sort" that * still seeded the first entry would seed it. The controls: a field the picker @@ -44,6 +47,7 @@ const FIELD_DEFS: Record> = { owner: { type: 'lookup', label: 'Owner', reference: 'sys_user' }, title: { type: 'text', label: 'Title' }, priority: { type: 'number', label: 'Priority' }, + search_index: { type: 'text', label: 'Search Index', hidden: true }, }; /** The object definition with `first` declared first, the rest in `FIELD_DEFS` order. */ @@ -74,7 +78,7 @@ function attachProjection(def: ReturnType) { const resolved = resolveObjectSortability(def) as { fields: Record }; const fields: Record = { ...resolved.fields }; // The base the refusal is measured against: every other field is sortable. - for (const name of ['secret_note', 'owner', 'title', 'priority']) { + for (const name of ['secret_note', 'owner', 'title', 'priority', 'search_index']) { expect(fields[name]).toEqual({ sortable: true }); } fields.remote_status = { sortable: false }; @@ -175,6 +179,7 @@ const CASES = [ { name: 'unreadable', field: 'secret_note', label: 'Secret Note' }, { name: 'platform-refused', field: 'remote_status', label: 'Remote Status' }, { name: 'relational', field: 'owner', label: 'Owner (by ID)' }, + { name: 'hidden', field: 'search_index', label: 'Search Index' }, ] as const; describe('a field the Sort picker keeps only for the current sort is removable only (objectui#11943)', () => { From c3b69a916a90e7ca8ac07c9824d5ddc88f94b228 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:44:21 +0000 Subject: [PATCH 2/3] fix(plugin-list): the sort picker leaves out a field the object marks hidden, as the filter list does Claude-Session: https://claude.ai/code/session_01Utp6CY1ikAq74XRbNdsyUC Co-authored-by: Claude --- ui/packages/plugin-list/src/ListView.tsx | 40 ++++++++++++++++++------ 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index 18a65db8569..eaef8110c3a 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -1690,6 +1690,22 @@ function canReadField( return !perms?.isLoaded || !objectName || perms.checkField(objectName, field, 'read'); } +/** + * Whether the object definition marks a field `hidden: true`: the injected + * system columns `@objectstack/spec` hides from pickers (`organization_id`, + * `queue`, the `__search` companion). The toolbar's field lists ask this one + * predicate. The Filter panel's list and the Sort picker's list leave such a + * field out unless the current filter or sort already names it, so that row + * is not blank and can be removed; the hide-fields lists never offer one to + * add. A plain function of the definition's field map, like `canReadField`. + */ +function isDeclaredHidden( + defs: Record | undefined, + field: string, +): boolean { + return defs?.[field]?.hidden === true; +} + /** The item a value none of a picker's options carries is shown by. */ const OUTSIDE_OPTIONS = 'outside'; @@ -5173,7 +5189,7 @@ export const ListView = React.forwardRef(({ */ const filterFields = React.useMemo(() => { const defs: Record | undefined = objectDef?.fields; - const isHidden = (name: string) => defs?.[name]?.hidden === true; + const isHidden = (name: string) => isDeclaredHidden(defs, name); const held = new Set((currentFilters.conditions ?? []).map((c) => c.field)); const columnRank = new Map(); for (const column of effectiveFields) { @@ -5257,14 +5273,19 @@ export const ListView = React.forwardRef(({ // field the current sort already names (a stored or URL sort) stays listed, // so its row is not blank and can be removed. // + // A field the object definition marks `hidden: true` (`isDeclaredHidden`, + // the rule the Filter panel's list follows) is withheld the same way as an + // unreadable one: not offered, no relational hint, and kept by the in-use + // exception alone. + // // An entry the exception alone keeps is listed REMOVABLE ONLY // (objectui#11943): it carries `disabled`, which `SortBuilder` renders as an // unavailable option. Its own row still shows its label and can be changed // to another field or removed, but no row's dropdown offers it as a choice // and "Add sort" never seeds it. That holds for each reason the exception - // keeps a field: unreadable, relational, or refused by the platform (or by - // the type read when no projection is served). A field the two rules list - // anyway carries no flag, whether or not the sort names it. + // keeps a field: unreadable, hidden, relational, or refused by the platform + // (or by the type read when no projection is served). A field the two rules + // list anyway carries no flag, whether or not the sort names it. // // Both legs below — the list this picker renders, and the sort it emits for // a host to persist — read `platformSortability`, the ONE read of the served @@ -5279,16 +5300,17 @@ export const ListView = React.forwardRef(({ // stored ID: said in one line under the rows, only while it is true. let picked = false; const fields: Array<{ value: string; label: string; disabled?: boolean; type?: string }> = []; + const defs: Record | undefined = objectDef?.fields; for (const field of candidateFields) { - const readable = canReadField(perms, schema.objectName, field.value); - if (!readable && !inUse.has(field.value)) continue; + const offered = canReadField(perms, schema.objectName, field.value) && !isDeclaredHidden(defs, field.value); + if (!offered && !inUse.has(field.value)) continue; const relational = EXPANDABLE_FIELD_TYPES.has(field.type); const platformSortable = platformSortability ? isPlatformSortableField(platformSortability, field.value) : !UNMATERIALIZED_FIELD_TYPES.has(field.type); // `type` lets the picker name a date's direction as time // (objectstack#27520): "Oldest → Newest", not "A → Z". - if (readable && !relational && platformSortable) { + if (offered && !relational && platformSortable) { fields.push({ value: field.value, label: field.label, type: field.type }); continue; } @@ -5306,7 +5328,7 @@ export const ListView = React.forwardRef(({ if (relational) excluded = true; } return { sortFields: fields, sortHasRelationalField: excluded, sortPicksRelationalField: picked }; - }, [candidateFields, currentSort, t, platformSortability, perms, schema.objectName]); + }, [candidateFields, currentSort, t, platformSortability, perms, schema.objectName, objectDef]); /** * [#6455] THE persist boundary: what this picker LISTS is not what it @@ -5670,7 +5692,7 @@ export const ListView = React.forwardRef(({ const defs = objectDef.fields as Record; const listed = new Set(allFields.map((f) => f.name)); const candidates = Object.entries(defs).filter(([name, def]) => - !listed.has(name) && def?.hidden !== true && canReadField(perms, schema.objectName, name)); + !listed.has(name) && !isDeclaredHidden(defs, name) && canReadField(perms, schema.objectName, name)); for (const tier of [false, true]) { for (const [name, def] of candidates) { if (isSystemManagedField(name, def) !== tier) continue; From 26c8060026b1c5b031365c6a5d54e0cc82ffec28 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 08:04:36 +0000 Subject: [PATCH 3/3] fix(plugin-list): drop the unused binding the shared hidden predicate left Claude-Session: https://claude.ai/code/session_01Utp6CY1ikAq74XRbNdsyUC Co-authored-by: Claude --- ui/packages/plugin-list/src/ListView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index eaef8110c3a..b273b81f895 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -5691,7 +5691,7 @@ export const ListView = React.forwardRef(({ if (onAddedFieldsChange && objectDef?.fields) { const defs = objectDef.fields as Record; const listed = new Set(allFields.map((f) => f.name)); - const candidates = Object.entries(defs).filter(([name, def]) => + const candidates = Object.entries(defs).filter(([name]) => !listed.has(name) && !isDeclaredHidden(defs, name) && canReadField(perms, schema.objectName, name)); for (const tier of [false, true]) { for (const [name, def] of candidates) {