Skip to content

Commit e24d4eb

Browse files
fix(setup): permission set field rows leave out the platform's bookkeeping columns (#27439)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8611re6sGxojaV3xm86DkjY)_ Fixes #27404 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Setup 权限集展开对象后的字段行,不再列出平台记账列(`__search`、`organization_id`、`owning_business_unit_id`),判据沿用 Studio 数据栏的 `isStudioHiddenSystemField`(system 且 hidden)。 - 为什么改:原来字段行列出合并定义里的全部字段并按名排序,“Search Index (`__search`)” 排在第一,管理员看到的是平台内部列而不是对象自己的字段。 - 风险与回滚:只改显示与批量按钮的作用范围,保存写法不变;已存有条目的记账列照旧显示;回滚即还原 `PermissionMatrixEditor.tsx` 一个文件。 - 先看:`PermissionMatrixEditor.tsx` 里 `FieldsSubTable` 的 `offered` 过滤;同文件 `ensureFields` 带上 system/hidden;`PermissionMatrixEditor.systemFields-27404.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.systemFields-27404.test.tsx`,预期 5 passed。 - 需要你做的:无,席位落地。 ## What changes - `FieldSummary` keeps the served definition's `system` and `hidden` marks; `ensureFields` reads them as booleans (`=== true`, the spec declares both `z.boolean()`). - `FieldsSubTable` draws the **offered** rows: every field except one `isStudioHiddenSystemField` holds for, imported from `views/studio-design/studioHiddenSystemField.ts` (untouched). No second predicate, no name list. - A bookkeeping column the set already carries an entry for still draws as written: an entry in the draft, or in the last loaded/saved set (`baselineFieldKeys`, set where the dirty baseline is re-anchored). So a Clear on such a row keeps the row on screen until that save lands. - The count, the filter threshold, the empty state and the three bulk shortcuts read the offered rows, so Read-only all / Write all / Clear never write an entry for a column the admin cannot see. - The row-condition editor's `fieldNames` stays the full field list, so a `when` naming a left-out column is not linted as unknown. `loadObjectFields` (the RLS CEL editor) is untouched: it feeds lint and autocomplete only and offers no picker. ## Why The ruling on the card: the platform's bookkeeping columns are the fields where `system` AND `hidden` hold together (`isStudioHiddenSystemField`, objectui#11780); they are dropped from offered field lists, and a value already stored against one still draws as written (objectui#13152 CONTROL). This editor was the one field list that did not apply it. ## Tests New `PermissionMatrixEditor.systemFields-27404.test.tsx` (package door, served definition with `__search` and `organization_id` marked system + hidden, `owner_id` system only, `internal_notes` hidden only): - `__search` and `organization_id` are not offered; the count reads `4 / 4`. - CONTROL: the author-hidden field and the system `owner_id` still draw, rows in name order. - CONTROL: a stored `__search` entry draws as written (Read off, a rule, not inherited). - CONTROL: Clear on that stored row keeps it until the autosave lands; the saved body has no `__search` entry, after which the column is no longer offered. - Read-only all writes entries for the four offered fields only. Ablation (fix committed first, `scripts/ablation-replace.mjs` on the predicate term of the `offered` filter, replaced by `true ||`): anchor 1 → 0, all 5 tests red, restore verified blob == HEAD and `git diff HEAD` empty. The three CONTROL cases go red too, because each pins the exact row list, which the ablation widens. ## Acceptance notes - Once a save drops the last stored entry for a bookkeeping column, the row leaves the list (on the package door, at autosave). That is the ruling read literally: nothing is stored any more, so nothing is drawn as written. - The predicate is imported from `studio-design/`, as `FlowReferenceField` in this same `metadata-admin/` tree already does. - os-dev exception: the PR template's headings are folded into the os-dev section order. ## Verification <details><summary>Local gates on the final head</summary> Head `59c93806` (origin/main c5d4634 merged in), derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`; `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm --filter @object-ui/app-shell run type-check :: exit 0 pnpm --dir ui exec eslint (both touched files) :: exit 0 (0 errors) pnpm --dir ui run check:new-line-citations :: exit 0 (0 new citations) pnpm --dir ui exec vitest run (52 files naming PermissionMatrixEdit) :: exit 0 (Test Files 52 passed, Tests 362 passed) ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (no spec change) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on both touched files: 0 errors) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (the 维护者速读 block is the os-dev Chinese summary) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWHPvxeSBbvjBYKZ8x)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7af2145 commit e24d4eb

3 files changed

Lines changed: 219 additions & 6 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(setup): a permission set's field rows no longer offer the platform's bookkeeping columns
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** Expanding an object in a permission set listed every field of the merged definition, so the platform's search companion `__search` sorted first and `organization_id` / `owning_business_unit_id` sat among the object's own fields.
12+
- **Now.** A field the platform marks both `system` and `hidden` is not offered, the same rule the Studio Data rail, form designer and pickers already apply. The bulk shortcuts act only on the offered rows.
13+
- **Unchanged.** A field the set already carries an entry for still draws as written, also while the draft clears it. An author's own hidden field and the system `owner_id` still draw. How a save writes is unchanged, and the row-condition editor still knows every field name.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27404 — the field sub-table offers the object's own fields, not
5+
* the columns the platform injects for its bookkeeping. Such a column carries
6+
* `system: true` AND `hidden: true` on the served definition (`__search`,
7+
* `organization_id`), the Data rail's predicate (`isStudioHiddenSystemField`,
8+
* objectui#11780). A stored entry against one still draws as written (the
9+
* objectui#13152 CONTROL); either mark alone is not enough to leave a field out.
10+
*/
11+
12+
import '@testing-library/jest-dom/vitest';
13+
import { afterEach, describe, expect, it, vi } from 'vitest';
14+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
15+
import { MemoryRouter } from 'react-router-dom';
16+
17+
interface FieldEntry {
18+
readable?: boolean;
19+
editable?: boolean;
20+
}
21+
interface SavedSet {
22+
fields?: Record<string, FieldEntry>;
23+
}
24+
25+
// `withPreviewDrafts` answers the client itself, not a promise (objectstack#26840).
26+
type FakeClient = Record<string, (...args: never[]) => unknown>;
27+
28+
let clientImpl: FakeClient;
29+
let saved: SavedSet[] = [];
30+
31+
vi.mock('./useMetadata', () => ({
32+
useMetadataClient: () => clientImpl,
33+
useMetadataTypes: () => ({
34+
loading: false,
35+
error: null,
36+
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
37+
}),
38+
}));
39+
40+
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
41+
42+
import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
43+
44+
afterEach(() => {
45+
cleanup();
46+
saved = [];
47+
});
48+
49+
// The served definition: two bookkeeping columns (system + hidden), the
50+
// reassignable owner (system only), an author's own hidden field, and two
51+
// plain fields.
52+
const SERVED_FIELDS = [
53+
{ name: '__search', label: 'Search Index', system: true, hidden: true },
54+
{ name: 'amount', label: 'Amount' },
55+
{ name: 'internal_notes', label: 'Internal notes', hidden: true },
56+
{ name: 'name', label: 'Name' },
57+
{ name: 'organization_id', label: 'Organization', system: true, hidden: true },
58+
{ name: 'owner_id', label: 'Owner', system: true },
59+
];
60+
61+
const EDIT_ON = { allowRead: true, allowCreate: true, allowEdit: true };
62+
63+
function makeClient(storedFields: Record<string, FieldEntry>): FakeClient {
64+
return {
65+
layered: async () => ({
66+
effective: {
67+
name: 'project_editor',
68+
label: 'Project editor',
69+
objects: { a_project: EDIT_ON },
70+
fields: storedFields,
71+
},
72+
code: null,
73+
overlay: null,
74+
overlayScope: null,
75+
}),
76+
getDraft: async () => null,
77+
withPreviewDrafts() {
78+
return this;
79+
},
80+
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_project' } }] : []),
81+
get: async (type: string) => (type === 'object' ? { fields: SERVED_FIELDS } : null),
82+
save: async (_type: string, _name: string, payload: Record<string, unknown>) => {
83+
saved.push(JSON.parse(JSON.stringify(payload)) as SavedSet);
84+
return payload;
85+
},
86+
};
87+
}
88+
89+
/** The package door autosaves 1.5s after the last edit (objectui#11787). */
90+
const AUTOSAVE = { timeout: 4000 };
91+
92+
async function renderExpanded(storedFields: Record<string, FieldEntry> = {}) {
93+
clientImpl = makeClient(storedFields);
94+
render(
95+
<MemoryRouter>
96+
<PermissionMatrixEditPage type="permission" name="project_editor" packageId="app.a" />
97+
</MemoryRouter>,
98+
);
99+
fireEvent.click(await screen.findByRole('button', { name: /a_project/ }));
100+
await screen.findByLabelText('a_project.name readable');
101+
}
102+
103+
/** The field names the sub-table draws, in the order it draws them. */
104+
const drawnRows = () =>
105+
screen
106+
.getAllByTestId(/^perm-field-name-a_project-/)
107+
.map((el) => el.getAttribute('data-testid')!.slice('perm-field-name-a_project-'.length));
108+
109+
describe('the field rows leave out the platform bookkeeping columns (objectstack#27404)', () => {
110+
it('does not offer a field that is system AND hidden', async () => {
111+
await renderExpanded();
112+
expect(screen.queryByTestId('perm-field-name-a_project-__search')).toBeNull();
113+
expect(screen.queryByTestId('perm-field-name-a_project-organization_id')).toBeNull();
114+
expect(screen.queryByLabelText('a_project.__search readable')).toBeNull();
115+
expect(screen.getByTestId('perm-field-count-a_project')).toHaveTextContent('4 / 4');
116+
});
117+
118+
it('CONTROL: one mark alone is not enough — an author-hidden field and the system owner still draw, in name order', async () => {
119+
await renderExpanded();
120+
expect(drawnRows()).toEqual(['amount', 'internal_notes', 'name', 'owner_id']);
121+
});
122+
123+
it('CONTROL: a stored entry against a bookkeeping column still draws, as written', async () => {
124+
await renderExpanded({ 'a_project.__search': { readable: false, editable: false } });
125+
expect(drawnRows()).toEqual(['__search', 'amount', 'internal_notes', 'name', 'owner_id']);
126+
const read = screen.getByLabelText('a_project.__search readable');
127+
expect(read).not.toBeChecked();
128+
expect(read).toHaveAttribute('data-field-rule', 'rule');
129+
expect(screen.queryByTestId('perm-field-name-a_project-organization_id')).toBeNull();
130+
});
131+
132+
it('CONTROL: a stored row the draft clears keeps its row until the save lands, then nothing is stored', async () => {
133+
await renderExpanded({ 'a_project.__search': { readable: false, editable: false } });
134+
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
135+
// Unsaved: the loaded set still carries the entry, so the row shows what the save drops.
136+
expect(screen.getByTestId('perm-field-name-a_project-__search')).toBeInTheDocument();
137+
expect(screen.getByLabelText('a_project.__search readable')).toHaveAttribute('data-field-rule', 'inherited');
138+
await waitFor(() => expect(saved.length).toBeGreaterThan(0), AUTOSAVE);
139+
expect(saved[saved.length - 1].fields?.['a_project.__search']).toBeUndefined();
140+
// Saved: no entry is stored any more, so the column is not offered.
141+
await waitFor(() => expect(screen.queryByTestId('perm-field-name-a_project-__search')).toBeNull());
142+
});
143+
144+
it('a bulk shortcut writes only the offered rows, never a bookkeeping column', async () => {
145+
await renderExpanded();
146+
fireEvent.click(screen.getByRole('button', { name: 'Read-only all' }));
147+
await waitFor(() => expect(saved.length).toBeGreaterThan(0), AUTOSAVE);
148+
const keys = Object.keys(saved[saved.length - 1].fields ?? {}).sort();
149+
expect(keys).toEqual(['a_project.amount', 'a_project.internal_notes', 'a_project.name', 'a_project.owner_id']);
150+
});
151+
});

‎ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx‎

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ import { useCanAuthorMetadata, useCanReadAuthoringTrail } from '../../hooks/useC
123123
import { errorCodeIs } from '@object-ui/types';
124124
import type { MetadataError } from '@object-ui/data-objectstack';
125125
import { PermissionSetCloneDialog } from './PermissionSetCloneDialog.js';
126+
import { isStudioHiddenSystemField } from '../studio-design/studioHiddenSystemField.js';
126127
import type { PermissionSetCloned } from './permission-set-clone.js';
127128
import {
128129
mergePermissionSlice,
@@ -331,6 +332,13 @@ function owdLabel(t: (k: string) => string, value: string): string {
331332
interface FieldSummary {
332333
name: string;
333334
label?: string;
335+
/**
336+
* objectstack#27404 — the platform's two marks on the served definition, kept
337+
* so the field rows can leave out its bookkeeping columns
338+
* (`isStudioHiddenSystemField`, objectui#11780).
339+
*/
340+
system?: boolean;
341+
hidden?: boolean;
334342
}
335343

336344
/**
@@ -751,13 +759,18 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
751759
// Snapshot of the last loaded/saved draft — the anchor `isDirty` compares
752760
// against. `null` until the first load lands (nothing to be dirty against).
753761
const baselineRef = React.useRef<string | null>(null);
762+
// objectstack#27404 — the `${object}.${field}` keys the last loaded/saved set
763+
// carries: a platform bookkeeping column with a stored entry keeps its row
764+
// while the draft clears it, so the admin still sees what the save changes.
765+
const [baselineFieldKeys, setBaselineFieldKeys] = React.useState<ReadonlySet<string>>(() => new Set());
754766
/** Set the draft AND re-anchor the dirty baseline to it (load + post-save). */
755767
const resetDraftBaseline = React.useCallback((next: PermissionSetDraft) => {
756768
try {
757769
baselineRef.current = JSON.stringify(next);
758770
} catch {
759771
baselineRef.current = null;
760772
}
773+
setBaselineFieldKeys(new Set(Object.keys(next.fields ?? {})));
761774
// objectui#9484 — a fresh identity, ALWAYS. `isDirty` below is a `useMemo`
762775
// keyed on `draft`, while the anchor it reads lives in a ref: hand
763776
// `setDraft` the object that is already the draft and React bails out on
@@ -1028,11 +1041,18 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
10281041
const raw = obj?.fields;
10291042
const list: FieldSummary[] = (
10301043
Array.isArray(raw)
1031-
? raw.map((f: any) => ({ name: String(f?.name ?? ''), label: f?.label }))
1044+
? raw.map((f: any) => ({
1045+
name: String(f?.name ?? ''),
1046+
label: f?.label,
1047+
system: f?.system === true,
1048+
hidden: f?.hidden === true,
1049+
}))
10321050
: raw && typeof raw === 'object'
10331051
? Object.entries(raw).map(([name, f]: [string, any]) => ({
10341052
name: String(f?.name ?? name),
10351053
label: f?.label,
1054+
system: f?.system === true,
1055+
hidden: f?.hidden === true,
10361056
}))
10371057
: []
10381058
)
@@ -2192,6 +2212,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
21922212
draft={draft}
21932213
expanded={expanded}
21942214
fieldsByObject={fieldsByObject}
2215+
baselineFieldKeys={baselineFieldKeys}
21952216
writable={writable}
21962217
objectActions={OBJECT_ACTIONS}
21972218
t={t}
@@ -2382,6 +2403,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
23822403
draft={draft}
23832404
expanded={expanded}
23842405
fieldsByObject={fieldsByObject}
2406+
baselineFieldKeys={baselineFieldKeys}
23852407
writable={writable}
23862408
objectActions={OBJECT_ACTIONS}
23872409
t={t}
@@ -2440,6 +2462,8 @@ interface PermissionTableProps {
24402462
draft: PermissionSetDraft;
24412463
expanded: Set<string>;
24422464
fieldsByObject: Record<string, FieldSummary[]>;
2465+
/** objectstack#27404 — field-entry keys of the last loaded/saved set (`FieldsSubTable`). */
2466+
baselineFieldKeys: ReadonlySet<string>;
24432467
writable: boolean;
24442468
objectActions: ReturnType<typeof getObjectActions>;
24452469
t: (key: string) => string;
@@ -2464,6 +2488,7 @@ function PermissionTable({
24642488
draft,
24652489
expanded,
24662490
fieldsByObject,
2491+
baselineFieldKeys,
24672492
writable,
24682493
objectActions,
24692494
t,
@@ -2654,6 +2679,7 @@ function PermissionTable({
26542679
objectPerm={perm}
26552680
fields={fieldsByObject[o.name]}
26562681
fieldsState={draft.fields ?? {}}
2682+
baselineFieldKeys={baselineFieldKeys}
26572683
writable={writable}
26582684
t={t}
26592685
onFieldPerm={onFieldPerm}
@@ -2762,6 +2788,7 @@ function StudioObjectMatrix({
27622788
draft,
27632789
expanded,
27642790
fieldsByObject,
2791+
baselineFieldKeys,
27652792
writable,
27662793
objectActions,
27672794
t,
@@ -2814,6 +2841,7 @@ function StudioObjectMatrix({
28142841
open={expanded.has(o.name)}
28152842
fieldsState={draft.fields ?? {}}
28162843
fields={fieldsByObject[o.name]}
2844+
baselineFieldKeys={baselineFieldKeys}
28172845
writable={writable}
28182846
objectActions={objectActions}
28192847
divider={divider}
@@ -2840,6 +2868,7 @@ function StudioObjectRow({
28402868
open,
28412869
fieldsState,
28422870
fields,
2871+
baselineFieldKeys,
28432872
writable,
28442873
objectActions,
28452874
divider,
@@ -2860,6 +2889,7 @@ function StudioObjectRow({
28602889
open: boolean;
28612890
fieldsState: Record<string, FieldPerm>;
28622891
fields: FieldSummary[] | undefined;
2892+
baselineFieldKeys: ReadonlySet<string>;
28632893
writable: boolean;
28642894
objectActions: ReturnType<typeof getObjectActions>;
28652895
divider: (i: number) => string;
@@ -3013,6 +3043,7 @@ function StudioObjectRow({
30133043
objectPerm={perm}
30143044
fields={fields}
30153045
fieldsState={fieldsState}
3046+
baselineFieldKeys={baselineFieldKeys}
30163047
writable={writable}
30173048
t={t}
30183049
onFieldPerm={onFieldPerm}
@@ -3138,6 +3169,7 @@ function FieldsSubTable({
31383169
objectPerm,
31393170
fields,
31403171
fieldsState,
3172+
baselineFieldKeys,
31413173
writable,
31423174
t,
31433175
onFieldPerm,
@@ -3150,6 +3182,8 @@ function FieldsSubTable({
31503182
objectPerm: ObjectPerm;
31513183
fields: FieldSummary[] | undefined;
31523184
fieldsState: Record<string, FieldPerm>;
3185+
/** Field-entry keys of the last loaded/saved set: such a row stays while the draft clears it. */
3186+
baselineFieldKeys: ReadonlySet<string>;
31533187
writable: boolean;
31543188
t: (key: string) => string;
31553189
onFieldPerm: (objectName: string, fieldName: string, action: keyof FieldPerm, value: boolean) => void;
@@ -3179,13 +3213,26 @@ function FieldsSubTable({
31793213
</div>
31803214
);
31813215
}
3182-
if (fields.length === 0) {
3216+
// objectstack#27404 — the rows OFFER the object's own fields. A column the
3217+
// platform injects and hides (the `__search` companion,
3218+
// `owning_business_unit_id`, `organization_id`) is left out, judged by the
3219+
// Data rail's own predicate (`isStudioHiddenSystemField`, objectui#11780).
3220+
// One the set already carries an entry for, in the draft or in the last
3221+
// loaded/saved set, still draws as written (the objectui#13152 CONTROL), so
3222+
// the bulk shortcuts never reach a bookkeeping column the admin cannot see.
3223+
const offered = fields.filter(
3224+
(f) =>
3225+
!isStudioHiddenSystemField({ system: f.system, hidden: f.hidden }) ||
3226+
fieldsState[`${objectName}.${f.name}`] !== undefined ||
3227+
baselineFieldKeys.has(`${objectName}.${f.name}`),
3228+
);
3229+
if (offered.length === 0) {
31833230
return <div className="text-xs text-muted-foreground">{t('perm.field.empty')}</div>;
31843231
}
31853232
const q = fieldFilter.trim().toLowerCase();
31863233
const visible = q
3187-
? fields.filter((f) => f.name.toLowerCase().includes(q) || (f.label ?? '').toLowerCase().includes(q))
3188-
: fields;
3234+
? offered.filter((f) => f.name.toLowerCase().includes(q) || (f.label ?? '').toLowerCase().includes(q))
3235+
: offered;
31893236
const visibleNames = visible.map((f) => f.name);
31903237
return (
31913238
<div className="space-y-2">
@@ -3196,7 +3243,7 @@ function FieldsSubTable({
31963243
matrix scrolled sideways (its floor is wider than the Studio
31973244
content column at 1440 px). */}
31983245
<div className="flex flex-wrap items-center gap-2">
3199-
{fields.length > 6 && (
3246+
{offered.length > 6 && (
32003247
<Input
32013248
value={fieldFilter}
32023249
onChange={(e) => setFieldFilter(e.target.value)}
@@ -3209,7 +3256,7 @@ function FieldsSubTable({
32093256
data-testid={`perm-field-count-${objectName}`}
32103257
className="shrink-0 whitespace-nowrap text-[11px] tabular-nums text-muted-foreground"
32113258
>
3212-
{visible.length} / {fields.length}
3259+
{visible.length} / {offered.length}
32133260
</span>
32143261
{writable && (
32153262
<div className="flex items-center gap-1">
@@ -3341,6 +3388,8 @@ function FieldsSubTable({
33413388
disabled={!writable}
33423389
placeholder="status == 'draft'"
33433390
objectName={objectName}
3391+
// Every field, the left-out ones too: a condition may
3392+
// still name one, and lint must not call it unknown.
33443393
fieldNames={fields.map((x) => x.name)}
33453394
clause="check"
33463395
onLintChange={(issues) => onFieldWhenLint(objectName, f.name, issues)}

0 commit comments

Comments
 (0)