Skip to content

Commit 8c28972

Browse files
fix(app-shell): zh-CN Studio first screens stop mixing English in the package form, flow rail groups and column picker (#29773)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb895hQJRzcYb4LCvShTxE6L)_ Fixes #29706 Clause-②: no Metadata-protocol: none The two lines are the amended claim's (comment on the card, 2026-11-03T02:55Z). ## 维护者速读 - 改了什么:中文 Studio 的三处首屏不再夹英文。新建软件包表单在中文词表加载后重建,标签和说明都是中文;自动化侧栏里尚未发布的对象,其流程分组显示对象标签(如「维修工单」),不再显示大写的机器名;列表视图的「添加字段」先列搭建者自己的字段,再列系统字段(创建时间、创建人、所有人等)。 - 为什么改:这些是中文用户最先看到的界面,中英混杂让人分不清哪些字可以忽略。卡上的第一行(「新建类似」复制流程后的提示句)要服务端先给出代码,已移到服务端车道的卡 #29727,本 PR 不动它。 - 风险与回滚:只改 app-shell 的三处读取与排序,不加不删 i18n 键,不动 spec 与 runtime。草稿对象多读一次草稿列表,失败时照旧只用已发布对象。回滚即撤销本 PR。 - 先看:`StudioDesignSurface.tsx` 的 `readObjectLabels`(合并草稿对象的标签);`PackageFormDialog.tsx` 的表单 memo 依赖;`ViewColumnPanes.tsx` 的 `AddFieldPopover` 排序。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PackageFormDialog.lazyCatalogue-29706.test.tsx packages/app-shell/src/views/studio-design/StudioDesignSurface.draftObjectFlowGroup-29706.test.tsx packages/app-shell/src/views/metadata-admin/previews/FieldsListEditor.addFieldOrder-29706.test.tsx`,预期 3 个文件 6 个用例全过(任一修复撤回时对应用例转红)。 - 需要你做的:无,席位落地。 ## What changes The card's four rows were re-measured on `main` 7b55ea9 in zh-CN, and all four reproduced. Rows 2 to 4 are fixed here. Row 1 moved to its own card (below). - **Row 1, the New similar clone notice: moved to #29727, not changed here.** The flow clone answer is `{flow, packageId, notice}` with no code, and the console prints `notice` as sent. The repo's pattern for a server sentence a person reads is code-keyed: the console maps a code it knows to an i18n key and keeps the wire text for any other code (`translateFailureCode` in `CloudConnectionPanel.tsx`; the publish refusal's `BATCH_ABORTED_CODE` line in `StudioDesignSurface.tsx`). The notice has no code yet, so the first step is server-side (`packages/runtime`), which is out of this card's surface. #29727 covers the flow and permission-set clone notices and the console mapping that follows the code. - **Row 2, the New package form (`PackageFormDialog.tsx`).** Studio's landing mounts the dialog closed, before the lazily fetched zh `studio` catalogue is resident. The form spec (labels and help lines, `getPackageForm`) was memoised on `[locale, mode]`, so the `en` answer of the first render survived the catalogue's arrival, while the title, read on every render, turned Chinese: 新建软件包 over "Display name" and "Human-readable package name". The memo now also keys on `isStudioCatalogueLoaded(locale)`, so the form is rebuilt once the catalogue lands. Once rebuilt, zh no longer falls through to the spec's `.describe()` text; en still does, by design (`add10d8f9`). - **Row 3, the Automations rail (`StudioDesignSurface.tsx`, `readObjectLabels` only).** The rail's object index read the published objects only, so a flow started on an object not published yet was grouped under the object's name, which the header's `uppercase` style showed as REPAIRS_REPAIR_TICKET. The index now merges the package's object drafts (`listDrafts({ packageId, type: 'object' })`, unscoped in the package-less scope) with the label each `_drafts` row carries, resolved in the designer locale. A published object keeps its published label. A failed draft read adds nothing and leaves the published index as it was. - **Row 4, the column picker (`ViewColumnPanes.tsx`, `AddFieldPopover`).** The served object puts the columns `applySystemFields` injects ahead of the author's field map. Measured on `@objectstack/objectql`'s `applySystemFields` for an object declaring `title` and `priority`: `organization_id`, `created_at`, `created_by`, `updated_at`, `updated_by`, `owner_id`, `owning_business_unit_id`, `queue`, `title`, `priority`. The list-view inspector already drops the hidden ones, but the audit four and `owner_id` stayed on top. The picker now lists the builder's fields first and the `system` ones after, each in served order. The same popover serves the report inspector's columns. - No i18n key is added or removed, and nothing under `packages/spec` or `packages/runtime` changes. ## Tests Three new files, one per kept row, each over the real component, each with a CONTROL case. - **`PackageFormDialog.lazyCatalogue-29706.test.tsx`.** Mounts the real `PackageFormDialog` closed under an `I18nProvider` in zh with the studio catalogue NOT resident (asserted first; the `dom` project isolates each file), lands the catalogue, then opens it, as Studio's landing does. The labels and help read 显示名称 / 软件包的可读名称。 and neither "Display name" nor the spec's `.describe()` text appears (read from the installed `ManifestSchema`). CONTROL: an en console mounted the same way keeps the spec's own English help. - **`StudioDesignSurface.draftObjectFlowGroup-29706.test.tsx`.** The whole surface at `/studio/PKG/automations` (harness of `automationsRailOrder-12612`): one published object, one draft-only object whose `_drafts` header carries 维修工单, one flow on each. The draft object's group reads 维修工单 and not its machine name. CONTROL: the published object's group reads its published label. - **`FieldsListEditor.addFieldOrder-29706.test.tsx`.** The real `FieldsListEditor` and its Add field popover over a catalog in the measured served order (audit four and `owner_id` first). The picker lists `title`, `priority`, then the system columns in served order. CONTROL: a catalog with no system column keeps its served order. - **Ablations, one per row** (`scripts/ablation-replace.mjs`, fix committed first, each mutation counted on disk and restored to the HEAD blob with `git diff HEAD` empty): memo deps back to `[locale, mode]`; the draft read replaced by an empty answer; the picker order back to `[...fields]`. Each turns exactly its row's case red and leaves the CONTROL green. ## Acceptance notes - **Row 1 went to #29727** (filed by the seat for the server lane). This PR leaves the clone notice as it is. - **Measured live, before and after, in zh-CN** (app-crm `--fresh` backend plus the Vite console on source; before = the three `origin/main` source files swapped in, restored to HEAD after): the rail header read REPAIRS_REPAIR_TICKET before and 维修工单 after; the New package form's first labels read "Display name", "Description", 软件包 ID, "Object namespace" before and 显示名称, 描述, 软件包 ID, 对象命名空间 after; the column picker read 创建时间, 创建人, 更新时间, 更新人, 负责人, 标题, 优先级 before and 标题, 优先级, then the five system columns after. Screenshots are in the project's `issue-29706/` folder (`before-row*.png`, `after-row*.png`). The harness was temporary and nothing of it is committed. - **Seen on the same screens, not changed here (outside this card's surface):** the filter builder's "+ Add filter…" in the Interfaces list-view panel renders in English in zh-CN; the package form's Type select shows the raw value `app`; the dialog's screen-reader "Close" label is English. Noted for triage. - After merging `main` (the pinyin create-door card landed in `PackageFormDialog.tsx` and `StudioDesignSurface.tsx`), the touched suites, the app-shell type-check and the three new files were re-run on the merged head. ## Verification Final head `f9fd4b39` (merge of `origin/main` 3eeb81d); code head a346e17. <details><summary>Tests</summary> - The 3 new files: 3 files, 6 tests passed (merged head). - Every app-shell test naming `PackageFormDialog`, `ViewColumnPanes`, `FieldsListEditor`, `AddFieldPopover`, `BuilderLanding` or the Automations rail (125 files, code head a346e17's tree, `--maxWorkers=2`, three batches): 125 files, 1080 tests passed. On the merged head, the 36 that name the touched components plus the two rail suites: 38 files, 304 tests passed. - `pnpm exec turbo run type-check --filter=@object-ui/app-shell`: 65 of 65 tasks successful (merged head). - `pnpm exec eslint` over the six touched ui files: 0 errors (existing warnings only). - `pnpm --dir ui exec vitest run scripts/`: 170 files passed, 2 skipped; 4977 tests passed. - `pnpm --dir ui check:new-line-citations`: 0 new citations. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 35 derived families (unchanged by the merge). The 34 non-vitest ones ran in one loop on a346e17 and all exited 0, including `check-sdui-manifest.mjs` and its `--self-test`, `check-changeset-no-major`, `check-adr-0087-registration`, `check-empty-changeset`, `check:published-files`, `check:type-check-debt` (locked) and `check:cross-package-test-inputs`; the 35th, `pnpm --dir ui exec vitest run scripts/`, is the run above. The body-reading gates were re-run on this body before opening. - `scripts/pm/clause2-line.mjs` reads the changeset as `Clause-②: no` and `Metadata-protocol: none`, matching the amended claim. No i18n key and no export changes. - Dogfood battery: n/a, because there is no `!` in the title and no (narrowing) arm. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jRdVLGVEbfCX4asHX1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8d208a4 commit 8c28972

7 files changed

Lines changed: 357 additions & 5 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+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
Studio in zh-CN no longer mixes English into three first screens.
9+
10+
- **New package form.** Studio's landing mounts the form before the lazily fetched zh catalogue arrives, and the form kept its first, English labels ("Display name", "Human-readable package name") under the translated title. The form is now rebuilt once the catalogue lands.
11+
- **Automations rail.** A flow on an object that is not published yet was grouped under the object's machine name, shown uppercased. The rail's object index now includes the package's draft objects, so the group reads the object's label.
12+
- **Column picker.** "Add field" listed the platform's injected columns (created/updated at and by, owner) ahead of the builder's own fields. The builder's fields now come first, then the system ones, each in the order the object serves them.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29706 — the New package form in a zh console read "Display
5+
* name" over "Human-readable package name" under the translated title
6+
* 新建软件包.
7+
*
8+
* Studio's landing mounts `PackageFormDialog` closed, before the lazily
9+
* fetched zh `studio` catalogue is resident. The form (its labels and help
10+
* lines) was memoised on the locale alone, so the `en` answer of that first
11+
* render survived the catalogue's arrival, while the title, read on every
12+
* render, did not. The form is now rebuilt once the catalogue lands.
13+
*
14+
* ⛔ No module-scope `loadStudioCatalogue('zh')` here, unlike the sibling
15+
* suites: the defect only exists while the catalogue is NOT yet resident, so
16+
* each case asserts that precondition first. The `dom` project runs each file
17+
* isolated, so no other file can have loaded it.
18+
*/
19+
20+
import '@testing-library/jest-dom/vitest';
21+
import * as React from 'react';
22+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
23+
import { act, cleanup, render, screen } from '@testing-library/react';
24+
import { I18nProvider, createI18n } from '@object-ui/i18n';
25+
import { isStudioCatalogueLoaded, loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
26+
import { ManifestSchema } from '@objectstack/spec/kernel';
27+
import { PackageFormDialog } from './PackageFormDialog';
28+
29+
beforeEach(() => {
30+
vi.stubGlobal(
31+
'fetch',
32+
vi.fn(async () => ({ ok: true, status: 200, text: async () => '{}' }) as Response),
33+
);
34+
});
35+
afterEach(() => {
36+
cleanup();
37+
vi.unstubAllGlobals();
38+
});
39+
40+
/** The landing's shape: mounted closed, opened later by the New package card. */
41+
function mountClosedThenOpen(language: 'zh' | 'en') {
42+
const i18n = createI18n({ defaultLanguage: language, detectBrowserLanguage: false, resources: { [language]: {} } });
43+
const dialog = (open: boolean) => (
44+
<I18nProvider instance={i18n}>
45+
<PackageFormDialog mode="create" open={open} onOpenChange={vi.fn()} />
46+
</I18nProvider>
47+
);
48+
const view = render(dialog(false));
49+
return { open: () => view.rerender(dialog(true)) };
50+
}
51+
52+
describe('the New package form after the lazy zh catalogue lands (objectstack#29706)', () => {
53+
it('a dialog mounted before the zh catalogue is resident reads its labels and help in zh once opened', async () => {
54+
expect(isStudioCatalogueLoaded('zh'), 'precondition: the zh catalogue is not resident yet').toBe(false);
55+
const landing = mountClosedThenOpen('zh');
56+
await act(async () => {
57+
await loadStudioCatalogue('zh');
58+
});
59+
landing.open();
60+
await screen.findByTestId('package-form');
61+
62+
expect(screen.getByText('新建软件包')).toBeInTheDocument();
63+
expect(screen.getByText('显示名称')).toBeInTheDocument();
64+
expect(screen.getByText('软件包的可读名称。')).toBeInTheDocument();
65+
// The card's exact complaint, read from the installed spec so it cannot
66+
// go vacuous when the spec rewords a `.describe()`.
67+
expect(screen.queryByText('Display name')).toBeNull();
68+
expect(screen.queryByText(ManifestSchema.shape.name.description!)).toBeNull();
69+
expect(screen.queryByText(ManifestSchema.shape.description.description!)).toBeNull();
70+
});
71+
72+
it('CONTROL — an en console mounted the same way keeps the spec’s own English help', async () => {
73+
const landing = mountClosedThenOpen('en');
74+
landing.open();
75+
await screen.findByTestId('package-form');
76+
77+
expect(screen.getByText('Display name')).toBeInTheDocument();
78+
expect(screen.getByText(ManifestSchema.shape.name.description!)).toBeInTheDocument();
79+
});
80+
});

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
Label,
5757
} from '@object-ui/components';
5858
import { useMetadataLocale, t, tFormat, tOptional } from './i18n.js';
59+
import { isStudioCatalogueLoaded } from '@object-ui/i18n/namespaces/studio';
5960
import { SchemaForm, type SchemaFormIssue } from './SchemaForm.js';
6061
import type { FormFieldSpec, FormViewSpec } from './form-spec.js';
6162
import { getPackageSchema, getPackageForm } from './package-schema.js';
@@ -383,7 +384,16 @@ export function PackageFormDialog({
383384
}) {
384385
const locale = useMetadataLocale();
385386
const schema = React.useMemo(() => getPackageSchema(), []);
386-
const form = React.useMemo(() => getPackageForm(locale, { create: mode === 'create' }), [locale, mode]);
387+
// The form's labels and help lines are read from the lazily fetched studio
388+
// catalogue. Landing mounts this dialog closed before that catalogue is
389+
// resident, so the form is rebuilt once it lands; keyed on the locale alone
390+
// it kept the `en` answer of its first render under a translated title.
391+
const catalogueResident = isStudioCatalogueLoaded(locale);
392+
const form = React.useMemo(
393+
() => getPackageForm(locale, { create: mode === 'create' }),
394+
// eslint-disable-next-line react-hooks/exhaustive-deps -- the residency flag is the dependency: the form reads the catalogue through `t`
395+
[locale, mode, catalogueResident],
396+
);
387397
// objectui#11792 — the `<org>` of a derived `com.<org>.<name>` id. Null
388398
// outside an AuthProvider and whenever the session has no active
389399
// organization; `deriveCreatePackageId` then leaves the segment out.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29706 — the column picker ("Add field") listed the platform's
5+
* injected columns before the builder's own fields.
6+
*
7+
* The served object puts the columns `applySystemFields` injects ahead of the
8+
* author's field map: the audit four and `owner_id` (`system: true`, shown),
9+
* plus the hidden bookkeeping ones the list-view inspector already drops. The
10+
* picker kept that order, so a builder scrolled past 创建时间 / 创建人 / 所有人
11+
* to reach the fields they made. It now lists the builder's fields first and
12+
* the system ones after, each in served order.
13+
*
14+
* The catalog below is that served order, measured on `@objectstack/objectql`'s
15+
* `applySystemFields` for an object declaring `title` and `priority`, with the
16+
* hidden system columns already dropped as `ObjectListViewInspector` drops them.
17+
*/
18+
19+
import '@testing-library/jest-dom/vitest';
20+
import * as React from 'react';
21+
import { afterEach, describe, expect, it, vi } from 'vitest';
22+
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
23+
import type { ObjectFieldInfo } from './useObjectFields';
24+
25+
// `useObjectFields` short-circuits the fetch when handed a catalog, but still
26+
// constructs the shared metadata client — stub it so no test reaches the
27+
// network (as FieldsListEditor.fieldLabels-12693.test.tsx does).
28+
const state = vi.hoisted(() => ({
29+
metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) },
30+
}));
31+
vi.mock('../useMetadata', () => ({
32+
useMetadataClient: () => state.metadataClient,
33+
}));
34+
35+
import { FieldsListEditor } from './FieldsListEditor';
36+
37+
afterEach(cleanup);
38+
39+
const SERVED: ObjectFieldInfo[] = [
40+
{ name: 'created_at', label: 'Created At', type: 'datetime', hidden: false, system: true },
41+
{ name: 'created_by', label: 'Created By', type: 'lookup', hidden: false, system: true },
42+
{ name: 'updated_at', label: 'Updated At', type: 'datetime', hidden: false, system: true },
43+
{ name: 'updated_by', label: 'Updated By', type: 'lookup', hidden: false, system: true },
44+
{ name: 'owner_id', label: 'Owner', type: 'lookup', hidden: false, system: true },
45+
{ name: 'title', label: 'Title', type: 'text', hidden: false },
46+
{ name: 'priority', label: 'Priority', type: 'select', hidden: false },
47+
];
48+
49+
/** The picker's rows, top to bottom, by field name. */
50+
function pickerOrder(catalog: ObjectFieldInfo[]): string[] {
51+
render(
52+
<FieldsListEditor
53+
variantKey="config"
54+
schema={{ type: 'grid', columns: [] }}
55+
columns={[]}
56+
allStrings
57+
objectName="repairs_repair_ticket"
58+
objectFieldsOverride={catalog}
59+
selectedIndex={null}
60+
onPatch={vi.fn()}
61+
/>,
62+
);
63+
fireEvent.click(screen.getByRole('button', { name: /Add field/ }));
64+
const list = screen.getByPlaceholderText(/Search fields/).closest('[role="dialog"]') as HTMLElement;
65+
return within(list)
66+
.getAllByRole('button')
67+
.map((b) => b.querySelector('code')?.textContent ?? '')
68+
.filter(Boolean);
69+
}
70+
71+
describe('the column picker lists the builder’s own fields first (objectstack#29706)', () => {
72+
it('served system-first: the builder’s fields lead, the system columns follow, each in served order', () => {
73+
expect(pickerOrder(SERVED)).toEqual([
74+
'title',
75+
'priority',
76+
'created_at',
77+
'created_by',
78+
'updated_at',
79+
'updated_by',
80+
'owner_id',
81+
]);
82+
});
83+
84+
it('CONTROL — a catalog with no system column keeps its served order', () => {
85+
const own = SERVED.filter((f) => f.system !== true);
86+
expect(pickerOrder([...own].reverse())).toEqual(['priority', 'title']);
87+
});
88+
});

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,13 +303,21 @@ export function AddFieldPopover({
303303
const [open, setOpen] = React.useState(false);
304304
const [query, setQuery] = React.useState('');
305305

306+
// The builder's own fields first, then the ones the platform injects
307+
// (`system`), each in served order. The served object puts the injected
308+
// audit and owner columns ahead of the author's fields, and they are not
309+
// what a builder adds a column for first.
310+
const ordered = React.useMemo(
311+
() => [...fields.filter((f) => f.system !== true), ...fields.filter((f) => f.system === true)],
312+
[fields],
313+
);
306314
const filtered = React.useMemo(() => {
307315
const q = query.trim().toLowerCase();
308-
if (!q) return fields;
309-
return fields.filter(
316+
if (!q) return ordered;
317+
return ordered.filter(
310318
(f) => f.name.toLowerCase().includes(q) || f.label.toLowerCase().includes(q),
311319
);
312-
}, [fields, query]);
320+
}, [ordered, query]);
313321

314322
return (
315323
<Popover open={open} onOpenChange={setOpen}>
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#29706 — the Automations rail labelled a draft object's flow
5+
* group with the object's machine name, which the header's `uppercase` style
6+
* turned into REPAIRS_REPAIR_TICKET on a zh screen.
7+
*
8+
* The rail's object index (`readObjectLabels`) read the PUBLISHED objects
9+
* only. An object the builder has not published yet is a draft, so the flow
10+
* started on it was grouped under a name with no label. The index now merges
11+
* the package's object drafts, each with the label its `_drafts` row carries,
12+
* as the Data rail lists them.
13+
*
14+
* The whole surface at `/studio/PKG/automations`, with the harness of
15+
* StudioDesignSurface.automationsRailOrder-12612.test.tsx: one published
16+
* object (Account) and one draft-only object (维修工单), one flow on each.
17+
* The control is a published object, whose group already read its label.
18+
*/
19+
20+
import '@testing-library/jest-dom/vitest';
21+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22+
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
23+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
24+
25+
const PACKAGE_ID = 'app.r41l';
26+
27+
vi.mock('sonner', () => ({
28+
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() },
29+
Toaster: () => null,
30+
}));
31+
32+
const onObject = (name: string, objectName: string) => ({
33+
name,
34+
label: name,
35+
type: 'autolaunched',
36+
status: 'active',
37+
nodes: [{ id: 'start', type: 'start', config: { triggerType: 'record-after-create', objectName } }],
38+
edges: [],
39+
});
40+
41+
const FLOWS = [onObject('account_flow', 'crm_account'), onObject('ticket_flow', 'repairs_repair_ticket')];
42+
43+
const mockClient = {
44+
list: vi.fn(async (type: string) => {
45+
if (type === 'app') return [{ name: 'sales', label: 'Sales', navigation: [] }];
46+
if (type === 'object') return [{ name: 'crm_account', label: 'Account' }];
47+
if (type === 'flow') return FLOWS;
48+
return [];
49+
}),
50+
// The object the builder has not published: a `_drafts` header with its label.
51+
listDrafts: vi.fn(async (query?: { type?: string }) =>
52+
query?.type === 'object' ? [{ name: 'repairs_repair_ticket', label: '维修工单' }] : [],
53+
),
54+
layered: vi.fn(async (type: string, name: string) => ({
55+
effective: (type === 'flow' && FLOWS.find((f) => f.name === name)) || { name },
56+
})),
57+
getDraft: vi.fn(async () => null),
58+
get: vi.fn(async () => undefined),
59+
save: vi.fn(async () => ({})),
60+
};
61+
62+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
63+
const actual = await importOriginal<Record<string, unknown>>();
64+
return {
65+
...actual,
66+
useMetadataClient: () => mockClient,
67+
useMetadataTypes: () => ({ loading: false, error: null, entries: [] }),
68+
};
69+
});
70+
71+
vi.mock('@object-ui/react', async (importOriginal) => {
72+
const actual = await importOriginal<Record<string, unknown>>();
73+
return { ...actual, useAdapter: () => ({}) };
74+
});
75+
76+
vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null }));
77+
vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null }));
78+
vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null }));
79+
vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null }));
80+
81+
import { StudioDesignSurface } from './StudioDesignSurface';
82+
83+
const answer = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
84+
85+
beforeEach(() => {
86+
vi.stubGlobal('matchMedia', (query: string) => ({
87+
matches: false,
88+
media: query,
89+
onchange: null,
90+
addEventListener: () => {},
91+
removeEventListener: () => {},
92+
addListener: () => {},
93+
removeListener: () => {},
94+
dispatchEvent: () => false,
95+
}));
96+
vi.stubGlobal(
97+
'fetch',
98+
vi.fn(async (input: unknown) =>
99+
String(input) === '/api/v1/packages'
100+
? answer({ success: true, data: { packages: [{ manifest: { id: PACKAGE_ID, name: 'Sales' }, writable: true }] } })
101+
: answer([]),
102+
),
103+
);
104+
});
105+
106+
afterEach(() => {
107+
cleanup();
108+
vi.unstubAllGlobals();
109+
});
110+
111+
function renderRail(): void {
112+
render(
113+
<MemoryRouter initialEntries={[`/studio/${PACKAGE_ID}/automations`]}>
114+
<Routes>
115+
<Route path="/studio/:packageId/:tab" element={<StudioDesignSurface />} />
116+
</Routes>
117+
</MemoryRouter>,
118+
);
119+
}
120+
121+
/** One group of the rail: its header and its flow rows. */
122+
const groupHeader = (key: string): Promise<HTMLElement> =>
123+
screen.findByTestId(`auto-rail-group-${key}`, undefined, { timeout: 8000 });
124+
125+
describe("a draft object's flow group reads the object's label (objectstack#29706)", () => {
126+
it('a flow on an object not published yet is grouped under its draft label, not its machine name', async () => {
127+
renderRail();
128+
const group = await groupHeader('repairs_repair_ticket');
129+
await waitFor(() => expect(group).toHaveTextContent('维修工单'));
130+
expect(within(group).queryByText(/repairs_repair_ticket/i)).toBeNull();
131+
});
132+
133+
it('CONTROL — a published object’s group reads its published label', async () => {
134+
renderRail();
135+
const group = await groupHeader('crm_account');
136+
await waitFor(() => expect(group).toHaveTextContent('Account'));
137+
expect(within(group).queryByText(/crm_account/i)).toBeNull();
138+
});
139+
});

0 commit comments

Comments
 (0)