Skip to content

Commit 6ff27b3

Browse files
fix(app-shell): Studio editors edit a translated type's pending draft as stored, not as served (#30912)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoDFP3MiaZLZFMmxVoftra8p)_ Fixes #30859 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 里五处编辑器(数据页的表单布局、列表视图面板、界面页的应用导航和它打开的页面/仪表盘/报表/视图、数据集等打包项编辑器)在有待发布草稿时,编辑副本改取分层读取里作者原样存的草稿,不再取按控制台语言翻译过的草稿读取;走的是 #30858 落地、#30877 移到 `@object-ui/data-objectstack` 的同一处读法。 - 为什么改:中文控制台里,第一次保存正确,刷新后装入的是中文草稿,再保存一次就把中文当成作者原文存进去,英文用户随后看到中文;在 zh-CN 实测栈上五处全部复现。 - 风险与回滚:只改客户端读哪一层,不改服务端和协议;服务端不翻译的类型照旧用读到的草稿。草稿存在但分层读取没给出原文时,编辑器不装副本、说明读不到、不保存。回滚即 revert 本 PR。 - 先看:新文件 `studio-design/authoredDraft.ts`(只是调用 `authoredBuffer` 的一处入口),再看 `useFormViewDraft.ts`、`ObjectListViewInspector.tsx`、`StudioBundleItemEditor.tsx`、`StudioDesignSurface.tsx` 里各自加载草稿的那几行。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/DataPillar.formViewLayout-30057.test.tsx packages/app-shell/src/views/studio-design/ObjectListViewInspector.draftUnreadable-29148.test.tsx packages/app-shell/src/views/studio-design/StudioDesignSurface.draftUnreadable-29148.test.tsx packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx`,应见 35 个测试全绿。 - 需要你做的:无,席位落地。 - **席位意见**:ui 席 2 对照 head `f24ebd58` 的 diff 复核:28 个文件、+629 / −66,无受治理路径。Studio 五处在有待发布草稿时改从分层读取的 `draft` 层(在草稿读取的同一地址)取可编辑副本,经新的 `authoredDraft.ts` 调用 `@object-ui/data-objectstack` 的同一读法,不另写一份;层里没给存储副本就不装缓冲并给出既有的「草稿不可读」提示;无草稿时仍用 `effective ?? code`。`EmbeddedItemEditor` 与 `StudioNavItemInspector` 实测不复现、未改,读数在正文。CI 全绿,Clause-② no(无新导出),不欠契约复审。同意落地。 ## What changes The `?state=draft` read translates a translatable type's body for the console's `Accept-Language`. Five Studio editors seeded their edit buffer from that served body when a draft was pending, so a save stored the translation as the author's text. Each now takes the stored draft from the layered read's `draft` layer, through `authoredBuffer` from `@object-ui/data-objectstack` (the reading #30858 landed and #30877 moved there), never a copy of it. - `studio-design/authoredDraft.ts` (new): `readAuthoredDraft(client, type, name, servedDraft, at)` is the one call the five sites make. For a type `editsAuthoredLayers` names, it reads `client.layered(type, name, { packageId, member })` at the address the draft read was asked at and returns `authoredBuffer(type, layers, servedDraft)` with those layers. Any other type keeps the served draft as its buffer, as before. - `useFormViewDraft.ts` (Data pillar, Form tab): the pending form view draft is the stored one. Unserved: no buffer, the tab's failure strip says the pending draft cannot be read. - `ObjectListViewInspector.tsx` (list view panel): the same; unserved is the panel's existing `draftUnreadable` strip with no Details. The copilot marks' published layer is taken from that same layered answer when the draft was read at the rail's package, so no second `/layers` read is sent. - `StudioBundleItemEditor.tsx` (bundle items: dataset and the other bundle types): the same, with the bundle `member` on the layered read; unserved mirrors the refused-draft path. - `StudioDesignSurface.tsx`, Interfaces pillar: the app's navigation and the leaf it opens (page, dashboard, report, view). Unserved: no nav buffer or no leaf buffer, the strip says why, and the autosave has nothing to send. The dashboard's published pairing reuses the layered answer when it was read at the same package. Sites measured and left alone (readings below): `metadata-admin/EmbeddedItemEditor.tsx` and `StudioNavItemInspector.tsx`. ## Tests Pins, each a zh-CN served draft beside its stored `draft` layer, plus the unserved case: | site | file | zh-CN pin | unserved pin | |:---|:---|:---|:---| | form view | `DataPillar.formViewLayout-30057.test.tsx` | canvas shows `Basic info` / `Money details`, Add group saves them and the label `Deal form`, no CJK in the body | failure strip, no view save | | list view | `ObjectListViewInspector.draftUnreadable-29148.test.tsx` | buffer and PUT carry `Open tasks`, pinned `If-Match: v1`; one `/layers` read | strip, no PUT | | app nav | `StudioDesignSurface.draftUnreadable-29148.test.tsx` | rail shows `Home Page` / `Reports Page`; removing one saves `['Home']`, app label `Acme` | strip, no Edit, no save | | leaf (page) | same file | canvas gets the stored page draft; a patch saves label `Home` and block title `Welcome` | strip, no save | | bundle item (dataset, read-only base pair) | `StudioBundleItemEditor.customize-28569.test.tsx` | designer gets the stored measures, an edit saves source labels | no buffer, nothing sent | Eighteen existing studio-design suites used client doubles whose `/layers` answer had no `draft` key beside a pending draft, so the editors now read it as unserved. Each double now serves the pending draft as stored, as the server does for an admitted author. Three suites count `/layers` reads and misses (`StudioDesignSurface.expectedMisses-11799`, `ObjectListViewInspector.layersUnsaved-11799`, `ObjectListViewInspector.copilotMarks-12725`): their counts now include the stored draft's one layered read, which answers 200 and is never a miss; each change carries an `objectstack#30859` comment. No other assertion changed. <details><summary>Ablation (head f24ebd5)</summary> `node scripts/ablation-replace.mjs` on `authoredDraft.ts`, anchor `return { buffer: authoredBuffer(type, layers, servedDraft), layers };` replaced by `return { buffer: { kind: "buffer", body: servedDraft, fromDraft: true }, layers };` (the served draft as the buffer, the pre-fix reading): anchor 1 to 0, blob 717b739 to df3a56625e48. Over the four pin files: `Tests 10 failed | 25 passed (35)`; the ten red are exactly the ten new pins (five zh-CN, five unserved), every existing test in those files green. Restored: blob equals HEAD, `git diff HEAD` empty. </details> ## Acceptance notes - `EmbeddedItemEditor.tsx` does not reproduce, so it is not changed. Its parents are objects only (fields, indexes, validations), and the object write door un-translates an echoed body and drops injected columns. Measured on a probe object in the showcase pair: the drawer's draft read served `探针` with Chinese labels for `title`, `note` and eight injected columns, the drawer's save of an edited `note` answered 200, and the stored draft held `Probe`, fields `title` / `note` only, with source labels. The published path (no draft, base `effective` with injected columns) stored an overlay with `title` / `note` only, as authored. - `StudioNavItemInspector.tsx` reads the served draft only to classify a nav item, and never seeds a buffer or a save from it, so it is not changed. - One more request: a pending draft of a translated type now costs one `/layers` read beside the draft read. The list view marks and the dashboard pairing reuse that answer rather than asking again. - The reading is imported from `@object-ui/data-objectstack`, its home since #30877 (merged into this branch from main). `authoredObjectBuffer.ts` is not edited; `StudioDesignSurface.tsx` keeps its existing import of the object spellings from it. ## Verification <details><summary>Wanted 1: live measurement on a zh-CN stack (origin/main cf478ea)</summary> `pnpm dev -- --fresh` (app-showcase), signed in as the seeded admin, `Accept-Language: zh-CN`, `com.example.showcase.custom` ensured. Each site's own request sequence on the read-only base and its pair: load (draft read at the pair, 404; `/layers` at the base), PUT 1 of `effective ?? code` with `overrides`, re-load (today's buffer is the served draft), PUT 2 of that buffer, then the stored `draft` layer. | site | item | served draft after PUT 1 | `/layers` `draft` after PUT 1 | stored after PUT 2 | |:---|:---|:---|:---|:---| | list view panel | view `showcase_task.urgent` | label `Urgent` → `紧急` (1 string) | source text | `紧急` (1 string translated) | | form view (Form tab) | view `showcase_task.form` | `sections[0].label` `Task` → `任务信息` (1) | source text | `任务信息` (1) | | app navigation | app `showcase_app` | label `功能展示`, nav `能力地图`, `页面搭建`, `工作区`, `我的工作`, … (58) | source text | 58 strings translated | | leaf page | page `showcase_capability_map` | label `能力地图` (1) | source text | 1 translated | | leaf dashboard | dashboard `showcase_ops_dashboard` | label `交付运营`, description, widget titles (17) | source text | 17 translated | | leaf report | report `showcase_hours_by_status` | label and description (2) | source text | 2 translated | | bundle item | dataset `showcase_task_metrics` | `measures[4].label` `已完成任务`, `measures[5].label` `完成率` (2) | source text | 2 translated | Every site reproduces. At load `effective` equalled `code` and was served untranslated, so the published half was already source text; only the pending draft was the leak. The layered read served the `draft` layer at the pair for each, draft-only items included (200). </details> <details><summary>Local gates and tests (head f24ebd5)</summary> Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` against this diff (36 families, the same before and after the merge of main), each run with stdin closed; reconciled with `--ran`: 36 derived, 36 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/ci/snapshot-next.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:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: 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 ``` UI tests (vitest root `ui/`, under the verify lock): `pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design packages/app-shell/src/views/metadata-admin`: `Test Files 1061 passed (1061)`, `Tests 10214 passed | 1 skipped (10215)`, on head f24ebd5. </details> <!-- signed: domain:ui#2 · os-tesla · session_01L9qibw7DekEe3wx5f3zphf --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d8b2295 commit 6ff27b3

28 files changed

Lines changed: 629 additions & 66 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): Studio editors edit a pending draft of a translated type as its author stored it, never as the console's locale serves it
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `?state=draft` read translates a translatable type's body (view, page, dashboard, report, app, dataset, …) for the console's `Accept-Language`. Five Studio editors seeded their edit buffer from that served body, so on a zh-CN console a save of an untouched label stored the Chinese translation as the author's text: the Data pillar's Form tab (form view), the list view panel, the Interfaces pillar's app navigation and its leaf (page, dashboard, report, view), and the bundle item editor (a dataset on a read-only base's pair). Measured on a running server: the first save stored the source labels, the re-loaded buffer held the translation, and the second save stored it.
11+
12+
Each now reads the pending draft's stored copy from the layered read's `draft` layer, at the address the draft read was asked at, through the one reading in `authoredObjectBuffer.ts` (`authoredBuffer`), as the object editors already did. The served draft still supplies the version pin, a refusal and the per-key provenance. A served draft whose stored copy the layers do not serve installs no buffer: the editor says it cannot read the pending draft and saves nothing over it. The list view marks and the dashboard pairing reuse that layered answer instead of asking `/layers` again. Types the server does not translate keep the served draft as their buffer.

‎ui/packages/app-shell/src/views/studio-design/DataPillar.formViewLayout-30057.test.tsx‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
* - a `create` form view beside the default: New and Edit pick between them;
2525
* - Preview lays the form out by the same form view;
2626
* - a read-only base: the edit is saved into the pair as a declared override
27-
* of the base's view (ADR-0146 D2).
27+
* of the base's view (ADR-0146 D2);
28+
* - objectstack#30859: a pending draft served translated (zh-CN) is edited
29+
* and saved as stored, with the author's labels; one whose stored copy the
30+
* layers do not serve installs no buffer and is shown.
2831
*/
2932

3033
import '@testing-library/jest-dom/vitest';
@@ -45,6 +48,12 @@ const srv = vi.hoisted(() => ({
4548
shellObjects: [] as Array<Record<string, unknown>>,
4649
/** Stored view items by name (the published layer the Studio reads). */
4750
views: new Map<string, Record<string, unknown>>(),
51+
/**
52+
* objectstack#30859 — pending view drafts by name: `served` is what the
53+
* `?state=draft` read answers (translated for the console's locale), `stored`
54+
* the layered read's `draft` layer (as the author wrote it; absent: unserved).
55+
*/
56+
drafts: new Map<string, { served: Record<string, unknown>; stored?: Record<string, unknown> }>(),
4857
}));
4958

5059
const dealObject = () => ({
@@ -109,9 +118,13 @@ const mockClient = {
109118
listDrafts: vi.fn(async () => []),
110119
layered: vi.fn(async (type: string, name: string) => {
111120
const row = type === 'view' ? srv.views.get(name) : dealObject();
112-
return row ? { effective: row, code: row } : null;
121+
const stored = type === 'view' ? srv.drafts.get(name)?.stored : undefined;
122+
return row ? { effective: row, code: row, ...(stored ? { draft: stored } : {}) } : null;
123+
}),
124+
getDraft: vi.fn(async (type: string, name: string) => {
125+
const pending = type === 'view' ? srv.drafts.get(name) : undefined;
126+
return pending ? { item: pending.served, version: 'v7' } : null;
113127
}),
114-
getDraft: vi.fn(async () => null),
115128
get: vi.fn(async () => null),
116129
save: vi.fn(async () => ({ ok: true, version: 'v2', state: 'draft' })),
117130
};
@@ -168,6 +181,7 @@ function customizationOf(): StudioBaseCustomization {
168181

169182
beforeEach(() => {
170183
srv.views.clear();
184+
srv.drafts.clear();
171185
srv.shellObjects = [];
172186
});
173187

@@ -286,3 +300,49 @@ describe('the Form tab lays out the form the app opens (objectstack#30057)', ()
286300
expect(saves().some(([ty]) => ty === 'object' || ty === 'object_extension')).toBe(false);
287301
});
288302
});
303+
304+
/** The default view's pending draft as a zh-CN console's `?state=draft` read serves it. */
305+
const translatedDraft = () => {
306+
const item = defaultViewItem();
307+
const [basic, money] = item.config.sections;
308+
return {
309+
...item,
310+
label: '商机表单',
311+
config: { ...item.config, sections: [{ ...basic, label: '基本信息' }, { ...money, label: '金额信息' }] },
312+
};
313+
};
314+
315+
describe('a pending form view draft is edited as stored, not as served (objectstack#30859)', () => {
316+
it('zh-CN: the canvas and the saved view carry the author’s labels, never the served translation', async () => {
317+
serveObject([defaultViewItem()]);
318+
srv.drafts.set(`${OBJECT}.form`, { served: translatedDraft(), stored: defaultViewItem() });
319+
await openForm();
320+
321+
await waitFor(() => expect(sectionTitles()).toEqual(['Basic info', 'Money details']));
322+
expect(mockClient.layered).toHaveBeenCalledWith('view', `${OBJECT}.form`, { packageId: PKG });
323+
324+
await userEvent.click(screen.getByTestId('form-designer-add-section-footer'));
325+
await waitFor(() => expect(saves().length).toBe(1), { timeout: 4000 });
326+
const [type, name, body, opts] = saves()[0];
327+
expect([type, name, opts.packageId]).toEqual(['view', `${OBJECT}.form`, PKG]);
328+
expect(body.label).toBe('Deal form');
329+
expect((body.config as { sections: Array<{ label: string }> }).sections.map((x) => x.label)).toEqual([
330+
'Basic info',
331+
'Money details',
332+
NEW_GROUP,
333+
]);
334+
expect(JSON.stringify(body)).not.toMatch(/基本信息|金额信息|商机表单/);
335+
});
336+
337+
it('a pending draft the layers do not serve: no buffer is installed, the refusal is shown, nothing is saved', async () => {
338+
serveObject([defaultViewItem()]);
339+
srv.drafts.set(`${OBJECT}.form`, { served: translatedDraft() });
340+
await openForm();
341+
342+
expect(await screen.findByTestId('form-view-failure')).toHaveTextContent(
343+
t('engine.studio.refusal.draftUnreadable', 'en'),
344+
);
345+
expect(sectionTitles()).not.toContain('基本信息');
346+
expect(saves().some(([ty]) => ty === 'view')).toBe(false);
347+
});
348+
});

‎ui/packages/app-shell/src/views/studio-design/InterfacesPillar.componentListServedTitles-12688.test.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ const mockClient = {
9595
listDrafts: vi.fn(async () => []),
9696
layered: vi.fn(async (type: string) => {
9797
if (type === 'app') return { effective: { name: 'showcase_app', label: 'Showcase', navigation: NAV } };
98-
if (type === 'dashboard') return { effective: JSON.parse(JSON.stringify(CODE)) };
98+
// As the server serves an admitted author: the pending draft as stored (objectstack#30859).
99+
if (type === 'dashboard') {
100+
return { effective: JSON.parse(JSON.stringify(CODE)), draft: state.draft ? JSON.parse(JSON.stringify(state.draft)) : null };
101+
}
99102
return { code: null, overlay: null, overlayScope: null, effective: null };
100103
}),
101104
getDraft: vi.fn(async (type: string) =>

‎ui/packages/app-shell/src/views/studio-design/InterfacesPillar.formViewWarnings-29311.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ const mockClient = vi.hoisted(() => ({
6262
overlay: eff,
6363
overlayScope: eff ? 'env' : null,
6464
effective: eff,
65+
// As the server serves an admitted author: the pending draft as stored (objectstack#30859).
66+
// Stored, so without the read's `_diagnostics` decoration.
67+
draft: server.draft ? Object.fromEntries(Object.entries(server.draft).filter(([key]) => !key.startsWith('_'))) : null,
6568
editable: true,
6669
deletable: true,
6770
resettable: false,

‎ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.addView-27836.test.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,11 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Resp
9595
if (seg[0] === 'object' && seg.length === 2) return json(200, { type: 'object', name: seg[1], item: OBJECT_DEF });
9696
if (seg[0] === 'view' && seg.length === 1) return json(200, [...server.active.values()]);
9797
if (seg.length === 3 && seg[2] === 'layers') {
98+
// As the server answers an admitted author (objectstack#30859): a pending draft is served as stored, as `draft`, and an item with only a draft still has layers.
9899
const row = server.active.get(key);
99-
return row
100-
? json(200, { code: null, overlay: row, overlayScope: 'env', effective: row })
100+
const draft = server.drafts.get(key) ?? null;
101+
return row || draft
102+
? json(200, { code: null, overlay: row ?? null, overlayScope: row ? 'env' : null, effective: row ?? null, draft })
101103
: json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
102104
}
103105
if (seg.length === 2 && url.searchParams.get('state') === 'draft') {

‎ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.copilotMarks-12725.test.tsx‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414
* - a setting whose answering entry is `by: 'copilot'` shows the draft chip;
1515
* the elements the copilot added to `kanban.columns` are marked and the
1616
* published ones are not, read by comparing the draft's array with the
17-
* published one — the ONE layered read this adds beside a draft;
17+
* published one — read from the ONE layered read a pending draft takes for
18+
* its stored `draft` layer (objectstack#30859), never a second one;
1819
* - the source line names the added elements and the minutes since `at`;
1920
* - a `studio` entry draws nothing and adds no read;
2021
* - CONTROLS: a published view with no draft draws nothing; a draft whose read
21-
* recorded no `draftChanges` draws nothing and sends no `/layers`; a draft
22-
* created over no published body (`[]`) marks every element without one;
22+
* recorded no `draftChanges` draws nothing and adds no `/layers` read; a
23+
* draft created over no published body (`[]`) marks every element without one;
2324
* - the panel's save sends `?surface=studio`, once.
2425
*/
2526

@@ -82,9 +83,11 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Resp
8283
if (method === 'PUT') return json(200, { success: true, version: 'v2', state: 'draft' });
8384
if (seg[0] === 'object' && seg.length === 2) return json(200, { type: 'object', name: seg[1], item: OBJECT_DEF });
8485
if (seg.length === 3 && seg[2] === 'layers') {
86+
// As the server answers an admitted author (objectstack#30859): a pending draft is served as stored, as `draft`, and an item with only a draft still has layers.
8587
const row = server.active.get(key);
86-
return row
87-
? json(200, { code: null, overlay: row, overlayScope: 'env', effective: row })
88+
const draft = server.drafts.get(key)?.row ?? null;
89+
return row || draft
90+
? json(200, { code: null, overlay: row ?? null, overlayScope: row ? 'env' : null, effective: row ?? null, draft })
8891
: json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
8992
}
9093
if (seg.length === 2 && url.searchParams.get('state') === 'draft') {
@@ -224,7 +227,7 @@ describe('ObjectListViewInspector — the copilot’s draft marks (objectui#1272
224227
);
225228
});
226229

227-
it('a `studio` entry draws no copilot mark and adds no read', async () => {
230+
it('a `studio` entry draws no copilot mark and adds no read beyond the stored draft', async () => {
228231
server.active.set(`view/${VIEW}`, kanbanView(['book']));
229232
server.drafts.set(`view/${VIEW}`, {
230233
row: kanbanView(['book', 'reader']),
@@ -234,7 +237,8 @@ describe('ObjectListViewInspector — the copilot’s draft marks (objectui#1272
234237
expect(chipsOf(panel)).toEqual([]);
235238
expect(markedElements(panel)).toEqual([]);
236239
expect(within(panel).queryByTestId('studio-draft-source')).toBeNull();
237-
expect(layersAsked()).toBe(0);
240+
// objectstack#30859 — the one read is the stored draft layer; no read is added for marks.
241+
expect(layersAsked()).toBe(1);
238242
});
239243

240244
it('CONTROL: a published view with no pending draft draws no mark', async () => {
@@ -245,16 +249,17 @@ describe('ObjectListViewInspector — the copilot’s draft marks (objectui#1272
245249
expect(layersAsked()).toBe(1);
246250
});
247251

248-
it('CONTROL: a draft whose read recorded no `draftChanges` draws no mark and sends no /layers', async () => {
252+
it('CONTROL: a draft whose read recorded no `draftChanges` draws no mark and adds no /layers read for marks', async () => {
249253
server.active.set(`view/${VIEW}`, kanbanView(['book']));
250254
server.drafts.set(`view/${VIEW}`, { row: kanbanView(['book', 'reader']) });
251255
const panel = await openPanel();
252256
expect(chipsOf(panel)).toEqual([]);
253257
expect(markedElements(panel)).toEqual([]);
254-
expect(layersAsked()).toBe(0);
258+
// objectstack#30859 — the one read is the stored draft layer; no read is added for marks.
259+
expect(layersAsked()).toBe(1);
255260
});
256261

257-
it('CONTROL: a draft the copilot created over no published body (`[]`) marks every element, with no /layers', async () => {
262+
it('CONTROL: a draft the copilot created over no published body (`[]`) marks every element, with no /layers read for marks', async () => {
258263
server.drafts.set(`view/${VIEW}`, {
259264
row: kanbanView(['book', 'reader']),
260265
draftChanges: [{ path: [], by: 'copilot', actor: 'usr_1', at: minutesAgo(3) }],
@@ -266,7 +271,8 @@ describe('ObjectListViewInspector — the copilot’s draft marks (objectui#1272
266271
within(within(panel).getByTestId(testId)).getAllByTestId('studio-draft-element-chip').map((e) => e.getAttribute('data-draft-of'));
267272
expect(elementsIn('list-view-kanban-fields')).toEqual(['book', 'reader']);
268273
expect(elementsIn('list-view-columns')).toEqual(['book', 'status']);
269-
expect(layersAsked()).toBe(0);
274+
// objectstack#30859 — the one read is the stored draft layer; no read is added for marks.
275+
expect(layersAsked()).toBe(1);
270276
});
271277

272278
it('the panel’s save sends `?surface=studio`, once', async () => {

‎ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.draftOnlyObject-12508.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,10 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Resp
8181
} else if (seg.length === 3 && seg[2] === 'layers' && server.failLayers.has(`${seg[0]}/${seg[1]}`)) {
8282
[status, body] = [500, { error: { code: 'INTERNAL_ERROR', message: 'metadata store unavailable' } }];
8383
} else if (seg.length === 3 && seg[2] === 'layers') {
84+
// As the server answers an admitted author (objectstack#30859): a pending draft is served as stored, as `draft`, and an item with only a draft still has layers.
8485
const row = server.active.get(`${seg[0]}/${seg[1]}`);
85-
if (row) body = { code: null, overlay: row, overlayScope: 'env', effective: row };
86+
const draft = server.drafts.get(`${seg[0]}/${seg[1]}`) ?? null;
87+
if (row || draft) body = { code: null, overlay: row ?? null, overlayScope: row ? 'env' : null, effective: row ?? null, draft };
8688
else [status, body] = [404, { error: { code: 'NOT_FOUND', message: 'absent' } }];
8789
} else if (seg.length === 2) {
8890
// No draft of that view is pending.

‎ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.draftUnreadable-29148.test.tsx‎

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
* `MetadataClient`, whose transport is an in-memory server: the drafts ledger
1414
* lists the gated view, and its `?state=draft` read answers 403 as the read
1515
* gate does.
16+
*
17+
* objectstack#30859 — a pending view draft served translated (zh-CN) is
18+
* edited and saved as the layered read's stored `draft` layer; one whose
19+
* stored copy the layers do not serve installs no buffer.
1620
*/
1721

1822
import '@testing-library/jest-dom/vitest';
@@ -49,6 +53,13 @@ const server = {
4953
drafts: new Map<string, Record<string, unknown>>(),
5054
/** Draft rows whose read is refused as the audience gate refuses it. */
5155
gated: new Set<string>(),
56+
/**
57+
* objectstack#30859 — what the `?state=draft` read serves in place of the
58+
* stored draft (its translation for the console's locale), and the drafts
59+
* whose stored copy the layered read serves as its `draft` layer.
60+
*/
61+
servedAs: new Map<string, Record<string, unknown>>(),
62+
draftLayer: new Set<string>(),
5263
layerReads: [] as string[],
5364
puts: [] as Array<{ item: string; ifMatch: string | null; ifNoneMatch: string | null }>,
5465
};
@@ -76,11 +87,19 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Resp
7687
if (seg.length === 3 && seg[2] === 'layers') {
7788
server.layerReads.push(item);
7889
const row = server.active.get(item);
79-
return row ? json(200, { code: null, overlay: row, overlayScope: 'env', effective: row }) : json(404, { error: { code: 'NOT_FOUND' } });
90+
const stored = server.draftLayer.has(item) ? server.drafts.get(item) : undefined;
91+
if (!row && !stored) return json(404, { error: { code: 'NOT_FOUND' } });
92+
return json(200, {
93+
code: null,
94+
overlay: row ?? null,
95+
overlayScope: 'env',
96+
effective: row ?? null,
97+
...(stored ? { draft: stored } : {}),
98+
});
8099
}
81100
if (url.searchParams.get('state') === 'draft') {
82101
if (server.gated.has(item)) return json(403, { success: false, error: { code: 'PERMISSION_DENIED', message: GATE_REFUSAL } });
83-
const row = server.drafts.get(item);
102+
const row = server.servedAs.get(item) ?? server.drafts.get(item);
84103
return row ? json(200, { type: seg[0], name: seg[1], item: row, version: 'v1' }) : json(404, { error: { code: 'NO_DRAFT' } });
85104
}
86105
const row = server.active.get(item);
@@ -149,6 +168,8 @@ beforeEach(() => {
149168
server.active.clear();
150169
server.drafts.clear();
151170
server.gated.clear();
171+
server.servedAs.clear();
172+
server.draftLayer.clear();
152173
server.layerReads = [];
153174
server.puts = [];
154175
hook.current = null;
@@ -195,3 +216,44 @@ describe('useObjectListViewDraft — a view draft the server refuses to read is
195216
expect(server.puts).toEqual([{ item: `view/${VIEW}`, ifMatch: null, ifNoneMatch: '*' }]);
196217
});
197218
});
219+
220+
describe('useObjectListViewDraft — a pending view draft is edited as stored, not as served (objectstack#30859)', () => {
221+
const stored = () => viewItem(['title', 'status'], { label: 'Open tasks', description: 'Tasks nobody closed' });
222+
const served = () => viewItem(['title', 'status'], { label: '未完成任务', description: '没人关闭的任务' });
223+
224+
it('zh-CN: the buffer and the saved view carry the author’s text, never the served translation', async () => {
225+
server.drafts.set(`view/${VIEW}`, stored());
226+
server.servedAs.set(`view/${VIEW}`, served());
227+
server.draftLayer.add(`view/${VIEW}`);
228+
229+
mount();
230+
231+
expect(await screen.findByTestId('list-view-columns', undefined, { timeout: 8000 })).toBeInTheDocument();
232+
expect(hook.current!.row).toMatchObject({ label: 'Open tasks', description: 'Tasks nobody closed' });
233+
// The stored draft is read at the draft read's own package.
234+
expect(server.layerReads).toEqual([`view/${VIEW}`]);
235+
236+
await act(async () => hook.current!.edit({ ...hook.current!.row!, config: { ...stored().config, columns: ['title'] } }));
237+
await act(async () => hook.current!.save({ unmoved: () => true }));
238+
await flush();
239+
expect(server.puts).toEqual([{ item: `view/${VIEW}`, ifMatch: 'v1', ifNoneMatch: null }]);
240+
const saved = server.drafts.get(`view/${VIEW}`)!;
241+
expect(saved).toMatchObject({ label: 'Open tasks', description: 'Tasks nobody closed' });
242+
expect(JSON.stringify(saved)).not.toMatch(/未完成任务|没人关闭的任务/);
243+
});
244+
245+
it('a pending draft the layers do not serve: no buffer is installed, the strip says why, and the save sends nothing', async () => {
246+
server.drafts.set(`view/${VIEW}`, stored());
247+
server.servedAs.set(`view/${VIEW}`, served());
248+
249+
mount();
250+
251+
const strip = await screen.findByTestId('list-view-refusal', undefined, { timeout: 8000 });
252+
expect(strip).toHaveTextContent(t('engine.studio.refusal.draftUnreadable', 'en-US'));
253+
expect(screen.queryByTestId('list-view-columns')).toBeNull();
254+
expect(hook.current!.row).toBeNull();
255+
await act(async () => hook.current!.save({ unmoved: () => true }));
256+
await flush();
257+
expect(server.puts).toEqual([]);
258+
});
259+
});

‎ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.firstSavePinned-11773.test.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,12 @@ async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Resp
6969
return json(200, { drafts: [...server.drafts.keys()].map((k) => ({ type: k.split('/')[0], name: k.split('/')[1]!.split('@')[0] })) });
7070
}
7171
if (seg.length === 3 && seg[2] === 'layers') {
72+
// As the server answers an admitted author (objectstack#30859): a pending draft is served as stored, as `draft`, and an item with only a draft still has layers.
7273
const row = server.active.get(item);
73-
return row ? json(200, { code: null, overlay: row, overlayScope: 'env', effective: row }) : json(404, { error: { code: 'NOT_FOUND' } });
74+
const draft = server.drafts.get(`${item}@${q.get('package') ?? ''}`)?.body ?? null;
75+
return row || draft
76+
? json(200, { code: null, overlay: row ?? null, overlayScope: row ? 'env' : null, effective: row ?? null, draft })
77+
: json(404, { error: { code: 'NOT_FOUND' } });
7478
}
7579
const address = `${item}@${q.get('package') ?? ''}`;
7680
if (method === 'PUT') {

0 commit comments

Comments
 (0)