From 55a94d6575d92333bd4e9ea77cb9f072354a6bdf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 18:24:03 +0000 Subject: [PATCH 1/5] fix(plugin-list): the hide-fields, Group and Row color lists and the user-filter chips ask the field read (objectui#11984) The list the hide-fields popover, the Group editor and the Row color select share (`allFields`, which the compact toolbar's View settings popover reads too) now passes through `canReadField`, the predicate the Filter panel's and the Sort picker's lists use, behind the same `isLoaded` gate. So do the user-filter chips, with the in-use exception the Sort picker makes: a chip whose field a held selection names stays, so that filter can be cleared. A stored hidden-field entry or row-color rule on a field the caller may not read is withheld from the editors (not listed, counted or cleared) and kept as stored. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- packages/plugin-list/src/ListView.tsx | 136 ++++++++++++++++++++------ 1 file changed, 105 insertions(+), 31 deletions(-) diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 6acc4893d2..ce906a62b6 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -1121,10 +1121,15 @@ function describeRefusedPageSize( /** * Field-level READ for the field lists the toolbar offers: the Filter panel's - * (`filterFields`, objectui#11925) and the Sort picker's (`sortFields`, - * objectui#11943). The two lists used to disagree: objectui#11925 wrote this - * predicate inside the filter memo, so the sort picker never asked it. It now - * lives once, here, and both lists call it. + * (`filterFields`, objectui#11925), the Sort picker's (`sortFields`, + * objectui#11943), and since objectui#11984 the list the hide-fields popover, + * the Group editor and the Row color select share (`allFields`, which the + * compact toolbar's View settings popover reads too) and the user-filter + * chips (`filterElements`). The first two lists used to disagree: + * objectui#11925 wrote this predicate inside the filter memo, so the sort + * picker never asked it. It now lives once, here, and every list calls it. + * Which lists exist is re-derived by the enumeration pin in + * `ListView.fieldListRead-11984.test.tsx`, not by this comment. * * It is the same call the column gate (`effectiveFields`) makes, * `perms.checkField(objectName, field, 'read')`, behind the same gate: until @@ -1132,8 +1137,8 @@ function describeRefusedPageSize( * true. An unanswered policy filters nothing, exactly as the columns defer. * * A plain function of the permission value and the object name rather than a - * hook. Both memos already depend on those two, so neither memo depends on a - * function's identity (AGENTS.md #10). + * hook. Every memo that calls it already depends on those two, so none depends + * on a function's identity (AGENTS.md #10). */ function canReadField( perms: ReturnType, @@ -1678,9 +1683,8 @@ export const ListView = React.forwardRef(({ return { ...configured, fields: derivedFields }; }, [schema.userFilters, objectDef, tFieldLabel]); - - // ADR-0053: userFilters (dropdown | tabs) is the sole page filter control. - const filterElements = resolvedUserFilters; + // The chips the toolbar renders are `filterElements`, which asks the field + // read; it is declared below `perms` (objectui#11984). // Hidden Fields State (initialized from schema) const [hiddenFields, setHiddenFields] = React.useState>( @@ -3206,11 +3210,14 @@ export const ListView = React.forwardRef(({ // The other field lists are not built from it, and each asks the same read // itself. The `$select` projection in the data-fetch effect filters the // declared columns with the same call; `perms` is read before that effect so - // it can. The Filter panel's list and the Sort picker's list ask through - // `canReadField` (objectui#11925, objectui#11943). One exception: the Sort - // picker keeps a field the current sort already names, so its row can be - // removed. It lists that field disabled, so no other row and no "Add sort" - // can choose it. + // it can. The Filter panel's list, the Sort picker's list, the list the + // hide-fields popover, the Group editor and the Row color select share + // (`allFields`), and the user-filter chips (`filterElements`) ask through + // `canReadField` (objectui#11925, objectui#11943, objectui#11984). Two + // exceptions keep a field the current state already names: the Sort picker + // lists it disabled, so its row can be removed but no other row and no "Add + // sort" can choose it; a user-filter chip whose field a held selection names + // stays, so that selection can be cleared. const effectiveFields = React.useMemo(() => { // Defensive: `columns` is `string[] | ListColumn[]`, but metadata is // user-authored — anything non-array degrades to "no declared columns". @@ -4470,9 +4477,20 @@ export const ListView = React.forwardRef(({ }, [data, effectiveFields, resolvedExportOptions, schema.objectName, authoredFilter, schema.searchableFields, exportPermitted, dataSource, appliedFilters, appliedUserFilterConditions, currentSort, searchTerm, objectDef, resolveObjectLabel]); // All available fields for hide/show (with i18n) + // + // objectui#11984 — this one list is what the hide-fields popover, the Group + // editor and the Row color select offer, in the toolbar and in the compact + // toolbar's View settings popover alike, so the field read is asked here, + // once, through `canReadField`: a column the caller may not read is offered + // by none of them. Until the permission answer loads, nothing is withheld, + // as the column gate defers. A grouping level that already names a withheld + // field still shows: `GroupingEditor` mounts a value its options do not + // carry as its own entry, labelled with the field name, so the level can be + // removed, and no other level or "Add group field" offers it. const allFields = React.useMemo(() => { return (Array.isArray(schema.columns) ? (schema.columns as any[]) : []).flatMap((f: any) => { if (typeof f === 'string') { + if (!canReadField(perms, schema.objectName, f)) return []; return [{ name: f, label: tFieldLabel(f, f) }]; } // `name` here is this popover's OWN key (it drives `hiddenFields`), which @@ -4481,10 +4499,66 @@ export const ListView = React.forwardRef(({ const name = columnIdentity(f); // No resolvable identity → nothing to hide or show; it used to render a // checkbox keyed `undefined` that could never match a column. - if (!name) return []; + if (!name || !canReadField(perms, schema.objectName, name)) return []; return [{ name, label: tFieldLabel(name, f.label || name) }]; }); - }, [schema.columns, tFieldLabel]); + }, [schema.columns, tFieldLabel, perms, schema.objectName]); + + // objectui#11984 — what the two hide-fields lists (the toolbar popover and + // the View settings section) are handed of `hiddenFields`. An entry naming a + // field the caller may not read is WITHHELD from them: not listed, not + // counted in their badges, not cleared by their "Show all", and put back by + // every write they make, so the stored list keeps it. Hiding such a column + // changes nothing for this caller, since the column gate already drops it, + // and a "Show all" that cleared it would rewrite the view for its other + // viewers through an entry this caller was never shown. With nothing + // withheld (full read, or no answer loaded yet) both are exactly + // `hiddenFields` and `updateHiddenFields`. Plain per-render values, not + // memos (AGENTS.md #10). + const withheldHiddenFields = [...hiddenFields].filter((name) => !canReadField(perms, schema.objectName, name)); + const offeredHiddenFields = withheldHiddenFields.length === 0 + ? hiddenFields + : new Set([...hiddenFields].filter((name) => !withheldHiddenFields.includes(name))); + const updateOfferedHiddenFields = (next: Set) => + updateHiddenFields(withheldHiddenFields.length === 0 ? next : new Set([...next, ...withheldHiddenFields])); + + // objectui#11984 — a row-color rule on a field the caller may not read is + // withheld from both Row color selects the same way: they show "None" with + // no Clear, and the trigger is not marked active. The rule itself stays in + // `rowColorConfig`, which only a pick in those selects ever replaces. It + // colors nothing for this caller: their fetch does not ask for a field they + // may not read and the server masks it out of their rows, so the rows carry + // no value to color by. + const offeredRowColorConfig = + rowColorConfig && canReadField(perms, schema.objectName, rowColorConfig.field) ? rowColorConfig : undefined; + + /** + * The user-filter chips the toolbar renders (ADR-0053: `userFilters`, dropdown + * or tabs, is the sole page filter control). + * + * objectui#11984 — the dropdown and toggle chips are a field list too, whether + * the author named the fields or `resolvedUserFilters` derived them from the + * object definition, so each asks `canReadField`. A chip on a field the caller + * may not read is dropped: a value chosen on it is a filter the server refuses + * with 403, and the list blanks. The exception is a chip whose field a held + * selection already names, the applied user filter or a selection the host + * restored (`userFilterSelections`): it stays, so the filter it holds can be + * cleared, as the Sort picker keeps a field its current sort names. The + * preset tabs carry filters, not a field list, and pass through unchanged. + */ + const filterElements = React.useMemo(() => { + const fields = resolvedUserFilters?.fields; + if (!resolvedUserFilters || !fields || fields.length === 0) return resolvedUserFilters; + const held = new Set(); + for (const condition of userFilterConditions) { + if (Array.isArray(condition) && typeof condition[0] === 'string') held.add(condition[0]); + } + for (const [field, values] of Object.entries(userFilterSelections ?? {})) { + if (values && values.length > 0) held.add(field); + } + const offered = fields.filter((f) => held.has(f.field) || canReadField(perms, schema.objectName, f.field)); + return offered.length === fields.length ? resolvedUserFilters : { ...resolvedUserFilters, fields: offered }; + }, [resolvedUserFilters, userFilterConditions, userFilterSelections, perms, schema.objectName]); /** * The list region's ARIA attributes, read through the ONE reader of the @@ -4646,14 +4720,14 @@ export const ListView = React.forwardRef(({ size="sm" className={cn( "hidden sm:inline-flex h-7 px-2 text-muted-foreground hover:text-primary text-xs transition-colors duration-150", - hiddenFields.size > 0 && "text-primary" + offeredHiddenFields.size > 0 && "text-primary" )} > {t('list.hideFields')} - {hiddenFields.size > 0 && ( + {offeredHiddenFields.size > 0 && ( - {hiddenFields.size} + {offeredHiddenFields.size} )} @@ -4662,8 +4736,8 @@ export const ListView = React.forwardRef(({

{t('list.hideFieldsTitle')}

- {hiddenFields.size > 0 && ( - )} @@ -4673,15 +4747,15 @@ export const ListView = React.forwardRef(({