Skip to content

Commit 76e90df

Browse files
fix(app-shell): list a package's draft objects in the Access matrix, badged as drafts (#27064)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8VtLrLYVCgWS9LRTX72qamP)_ Fixes #26840 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 包内的访问矩阵现在也列出该包的草稿对象,并在行上标出「(草稿)」;草稿对象的字段也能展开。 - 为什么改:原来只列已发布对象,新包的对象都是草稿时矩阵显示「0 个对象」,首次发布前无法配置权限。 - 风险与回滚:只改包作用域下的三处读取(对象列表、字段读取两处),环境作用域不变;回滚即 revert 本 PR。 - 先看:`PermissionMatrixEditor.tsx` 的对象列表读取与 `readObjectDefinition`;新测试 `PermissionMatrixEditor.draftObjects-26840.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.draftObjects-26840`,预期 5 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes - Under a `packageId`, the matrix lists its objects through `MetadataClient.withPreviewDrafts(true)` (the `?preview=draft` door the object picker's `useObjectCatalog` uses), so a package whose objects are all drafts lists them instead of "0 objects". - A row served from its pending draft (`_draft: true`, the picker's signal) carries the picker's draft mark: a muted `data-draft-marker` span reading `engine.inspector.draftSuffix` ("(draft)" / "(草稿)"), in both the page table and the Studio layout. No new marker, no new i18n key. - The two per-object definition reads (field rows in `ensureFields`, and `loadObjectFields` for the RLS and field-condition CEL editors) go through the same door under a `packageId`, via one helper `readObjectDefinition`. Without it a draft-only object expands to "no fields". - Environment scope (Setup catalog, no `packageId`) keeps the published reads unchanged; only the package door authors drafts. Pinned by a test that asserts no preview read and no draft row there. - Test doubles that mount the matrix under a package gain `withPreviewDrafts() { return this; }` (the Studio tests' existing precedent); five typed doubles widen `FakeClient` to `=> unknown` because that method returns the client, not a promise. ## Why Triage ruling 6450953786: list draft objects too (with `preview=draft`), badged as drafts, so a new package can be secured before its first publish. ## Tests - New: `PermissionMatrixEditor.draftObjects-26840.test.tsx`, 5 tests: a draft-only object is listed and badged (page and Studio layouts); a published object has no badge; toggling the draft object's Read writes `objects.a_invoice.allowRead` into the set draft (`mode: 'draft'`, `packageId: 'app.a'`); its fields come from its draft; the environment door keeps the published list. - Scoped run (every app-shell test that mounts the matrix: 41 importers of `PermissionMatrixEditPage` plus 35 `StudioDesignSurface` tests naming the Access pillar, 73 files after dedupe): Test Files 73 passed, Tests 690 passed. - `pnpm --filter @object-ui/app-shell run type-check`: exit 0. - Ablation (`scripts/ablation-replace.mjs`, anchor hit 1 to 0, blob f8f9c81 to 36feb5f1 and back, `git diff HEAD` empty after restore): replacing the package-door list read with plain `client.list` turns 4 of the 5 new tests red; the environment-door test stays green, as it should. ## Acceptance notes - PR #26999 (#26858) edits this same file and was still open when this PR was opened; `origin/main` was merged into this branch twice, the last time at `3230bdbf`. - A published object with a pending draft is also served with `_draft: true` and so carries the draft mark, the same as in the object picker. - The full app-shell suite (1,816 files) was not run locally; it belongs to CI. The scoped set above is every file that mounts the matrix. ## Verification Derived gates (`dispatch-gates --commands`) and their exits are in the os-dev report on #26840. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI) - [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. (`@object-ui/app-shell` is private: `skip-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. (except the six-line maintainer brief the dispatch order asks for in Chinese) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbYLTHkXTxBf89Sud8r --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbYLTHkXTxBf89Sud8r)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c8097a9 commit 76e90df

30 files changed

Lines changed: 277 additions & 9 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ function makeClient() {
8787
...(provenance !== undefined ? { provenance } : {}),
8888
}),
8989
getDraft: async () => null,
90+
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
91+
withPreviewDrafts() { return this; },
9092
list: async (type: string) =>
9193
type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : [],
9294
get: async (type: string) =>

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,15 @@ function deferred() {
5050
}
5151

5252
/** The slice of the metadata client the editor calls here. */
53-
type FakeClient = Record<string, (...args: never[]) => Promise<unknown>>;
53+
// `withPreviewDrafts` answers the client itself, not a promise (objectstack#26840).
54+
type FakeClient = Record<string, (...args: never[]) => unknown>;
5455

5556
function makeClient(server: Server, opts: { holdFirstSave?: Promise<void> } = {}): FakeClient {
5657
return {
5758
layered: async () => LAYERED,
5859
getDraft: async () => null,
60+
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
61+
withPreviewDrafts() { return this; },
5962
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
6063
get: async () => null,
6164
save: async (

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ function makeClient(set: Record<string, unknown>) {
3333
...(codeLayer ? { provenance: 'package' } : {}),
3434
}),
3535
getDraft: async () => null,
36+
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
37+
withPreviewDrafts() { return this; },
3638
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : []),
3739
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
3840
save: async (_t: string, _n: string, payload: Record<string, unknown>) => payload,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ function makeClient(server: FakeServer) {
7777
return {
7878
layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }),
7979
getDraft: async () => null,
80+
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
81+
withPreviewDrafts() { return this; },
8082
list: async (type: string) =>
8183
type === 'object' ? server.objectNames.map((name) => ({ item: { name } })) : [],
8284
get: async (type: string) => (type === 'object' ? { fields: [] } : null),

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ function makeClient() {
8989
overlayScope: null,
9090
})),
9191
getDraft: async () => null,
92+
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
93+
withPreviewDrafts() { return this; },
9294
list: async (type: string) =>
9395
type === 'object' ? [{ item: { name: 'a_account', label: 'Account' } }] : [],
9496
get: async (type: string) =>

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ const SET = {
3333
const client = {
3434
layered: async () => ({ effective: SET, code: null, overlay: null, overlayScope: null }),
3535
getDraft: async () => null,
36+
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
37+
withPreviewDrafts() { return this; },
3638
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
3739
get: async () => null,
3840
save: async (_type: string, _name: string, payload: unknown) => payload,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ function makeClient(server: Server) {
6161
overlayScope: null,
6262
}),
6363
getDraft: async () => null,
64+
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
65+
withPreviewDrafts() { return this; },
6466
list: async (type: string) => {
6567
if (type === 'object') return [{ item: { name: 'a_account' } }];
6668
return [];
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26840 — the package door's Access matrix lists the package's
5+
* DRAFT objects too, so a new package can be secured before its first
6+
* publish.
7+
*
8+
* The fake client behaves like the server's two reads of one package:
9+
*
10+
* • the published door (`list` / `get`) knows only the published object;
11+
* • the draft-overlaid door (`withPreviewDrafts(true)`, `?preview=draft`)
12+
* also serves each pending draft, with its `_draft` mark.
13+
*
14+
* The draft-only row carries the object picker's draft mark, its fields come
15+
* from its draft, and its grants write into the set's draft. The environment
16+
* door keeps the published list and its rows carry no mark.
17+
*/
18+
19+
import '@testing-library/jest-dom/vitest';
20+
import { afterEach, describe, expect, it, vi } from 'vitest';
21+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
22+
import { MemoryRouter } from 'react-router-dom';
23+
24+
interface Server {
25+
set: Record<string, unknown>;
26+
saved: Array<Record<string, unknown>>;
27+
savedOpts: Array<Record<string, unknown> | undefined>;
28+
listCalls: Array<{ preview: boolean; opts?: Record<string, unknown> }>;
29+
}
30+
31+
const PUBLISHED = { name: 'a_account', label: 'Account', fields: { name: { label: 'Name' } } };
32+
const DRAFT_ONLY = {
33+
name: 'a_invoice',
34+
label: 'Invoice',
35+
fields: { amount: { label: 'Amount' } },
36+
_draft: true,
37+
_packageId: 'app.a',
38+
};
39+
40+
function makeClient(server: Server, preview = false): any {
41+
return {
42+
withPreviewDrafts: (on: boolean) => makeClient(server, on),
43+
layered: async () => ({ effective: server.set, code: null, overlay: null, overlayScope: null }),
44+
getDraft: async () => null,
45+
list: async (type: string, opts?: Record<string, unknown>) => {
46+
if (type !== 'object') return [];
47+
server.listCalls.push({ preview, opts });
48+
return preview ? [PUBLISHED, DRAFT_ONLY] : [PUBLISHED];
49+
},
50+
get: async (type: string, name: string) => {
51+
if (type !== 'object') return null;
52+
if (name === PUBLISHED.name) return PUBLISHED;
53+
// A draft-only object has no published definition.
54+
return preview && name === DRAFT_ONLY.name ? DRAFT_ONLY : null;
55+
},
56+
save: async (_type: string, _name: string, payload: Record<string, unknown>, opts?: Record<string, unknown>) => {
57+
server.saved.push(payload);
58+
server.savedOpts.push(opts);
59+
server.set = payload;
60+
return payload;
61+
},
62+
};
63+
}
64+
65+
let clientImpl: any;
66+
67+
vi.mock('./useMetadata', () => ({
68+
useMetadataClient: () => clientImpl,
69+
useMetadataTypes: () => ({
70+
loading: false,
71+
error: null,
72+
entries: [{ type: 'permission', label: 'Permission', allowEnvironmentOverlay: true }],
73+
}),
74+
}));
75+
76+
vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () => null }));
77+
78+
import { PermissionMatrixEditPage } from './PermissionMatrixEditor';
79+
80+
afterEach(cleanup);
81+
82+
function freshServer(): Server {
83+
return {
84+
set: { name: 'sales_perms', label: 'Sales', objects: {}, fields: {} },
85+
saved: [],
86+
savedOpts: [],
87+
listCalls: [],
88+
};
89+
}
90+
91+
function renderMatrix(opts: { packageId?: string; studio?: boolean } = {}) {
92+
return render(
93+
<MemoryRouter>
94+
<PermissionMatrixEditPage
95+
type="permission"
96+
name="sales_perms"
97+
packageId={opts.packageId}
98+
studio={opts.studio ? { tab: 'objects', onTabChange: () => {} } : undefined}
99+
/>
100+
</MemoryRouter>,
101+
);
102+
}
103+
104+
const rowOf = (text: string) => screen.getByText(text).closest('tr')!;
105+
const draftMarks = (row: HTMLElement) => row.querySelectorAll('[data-draft-marker]');
106+
107+
describe('PermissionMatrixEditPage — draft objects at the package door (objectstack#26840)', () => {
108+
it('lists a draft-only object badged as a draft, and a published one without the badge', async () => {
109+
clientImpl = makeClient(freshServer());
110+
renderMatrix({ packageId: 'app.a' });
111+
112+
await screen.findByText('Invoice');
113+
const invoiceMarks = draftMarks(rowOf('Invoice'));
114+
expect(invoiceMarks).toHaveLength(1);
115+
expect(invoiceMarks[0]).toHaveTextContent('(draft)');
116+
expect(draftMarks(rowOf('Account'))).toHaveLength(0);
117+
});
118+
119+
it('badges the draft-only row in the Studio layout too', async () => {
120+
clientImpl = makeClient(freshServer());
121+
renderMatrix({ packageId: 'app.a', studio: true });
122+
123+
await screen.findByText('Invoice');
124+
expect(draftMarks(rowOf('Invoice'))).toHaveLength(1);
125+
expect(draftMarks(rowOf('Account'))).toHaveLength(0);
126+
});
127+
128+
it("writes the draft-only object's grants into the set's draft", async () => {
129+
const server = freshServer();
130+
clientImpl = makeClient(server);
131+
renderMatrix({ packageId: 'app.a' });
132+
133+
await screen.findByText('Invoice');
134+
fireEvent.click(within(rowOf('Invoice')).getByLabelText('a_invoice Read'));
135+
136+
await waitFor(() => expect(server.saved).toHaveLength(1), { timeout: 4000 });
137+
expect((server.saved[0] as any).objects.a_invoice).toMatchObject({ allowRead: true });
138+
expect(server.savedOpts[0]).toMatchObject({ mode: 'draft', packageId: 'app.a' });
139+
});
140+
141+
it("reads a draft-only object's fields from its draft", async () => {
142+
clientImpl = makeClient(freshServer());
143+
renderMatrix({ packageId: 'app.a' });
144+
145+
fireEvent.click(await screen.findByRole('button', { name: /a_invoice/ }));
146+
await screen.findByLabelText('a_invoice.amount readable');
147+
});
148+
});
149+
150+
describe('PermissionMatrixEditPage — environment door (objectstack#26840)', () => {
151+
it('keeps the published list: no preview read, no draft rows, no badge', async () => {
152+
const server = freshServer();
153+
clientImpl = makeClient(server);
154+
renderMatrix();
155+
156+
await screen.findByText('Account');
157+
expect(screen.queryByText('Invoice')).not.toBeInTheDocument();
158+
expect(document.querySelectorAll('[data-draft-marker]')).toHaveLength(0);
159+
expect(server.listCalls.length).toBeGreaterThan(0);
160+
expect(server.listCalls.every((c) => !c.preview)).toBe(true);
161+
});
162+
});

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ function makeClient() {
2828
overlayScope: null,
2929
}),
3030
getDraft: async () => null,
31+
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
32+
withPreviewDrafts() { return this; },
3133
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
3234
get: async (type: string) => (type === 'object' ? { fields: [] } : null),
3335
save: async (_t: string, _n: string, payload: Record<string, unknown>) => payload,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ function makeClient() {
4141
overlayScope: null,
4242
}),
4343
getDraft: async () => null,
44+
// objectstack#26840 — the package door reads its objects through the draft-overlaid door.
45+
withPreviewDrafts() { return this; },
4446
list: async (type: string) => (type === 'object' ? [{ item: { name: 'a_account' } }] : []),
4547
get: async (type: string) =>
4648
type === 'object' ? { fields: FIELDS.map((name) => ({ name })) } : null,

0 commit comments

Comments
 (0)