Skip to content

Commit 69dbb3c

Browse files
feat(app-shell): the publish sheet speaks the console's language, and a load error the server answered shows its code and sentence (#29780)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbCxgU8qhkxkTtKmg6Wwh1hY)_ Fixes #29704 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:发布前的「待发布变更」面板改用使用者的语言说明改动。更新项按检查器里的字段名列出改动(如「显示名称」「列数」),不再列出 `overrides` 声明。新增项不再列出属性清单和「平台生成」分组,对象扩展分组显示中文名「对象扩展」。列表页、记录页、应用页和相关列表在加载失败时,如果服务器有回复,就显示它给出的原因和错误码(如 404 `OBJECT_NOT_FOUND`)。只有请求完全没有得到回复时,才提示「请检查网络连接后重试」。 - 为什么改:发布面板是使用者决定是否上线改动的地方,原来显示的是 `+ overrides`、`+ isDefault` 这类内部键名,看不懂。#29673 中对象注册失败时,服务器已经明确回复了原因,页面却让人去查网络,排查方向完全错误。 - 风险与回滚:只改界面文案和显示逻辑,不改发布内容、接口和元数据协议。十个语言包各新增两个键,没有删除键。回滚只需还原本 PR。 - 先看:`ui/packages/app-shell/src/preview/DraftChangesPanel.tsx` 的 `EntryDetail`,以及 `ui/packages/react/src/utils/error-message.ts` 新增的 `answeredLoadFailure`(四个页面共用的唯一判断规则)。 - 两分钟验证:中文界面下,在 Studio 中定制一个基础包的仪表板并改名,打开「变更」面板展开它,应看到「~ 显示名称」而没有 overrides。再打开一个对象列表,让数据接口返回 404 `OBJECT_NOT_FOUND`,应看到服务器的原句和错误码,而不是网络提示。下方 Verification 附有前后截图。 - 需要你做的:无,席位落地。 ## What changes **Publish sheet (`DraftChangesPanel`)** - An updated item's top-level keys are named by the inspector's existing label catalog, in the console's language: the spec form rows, the zh overlay `localizeMetadataForm`, then `translateSchemaFieldLabel`. The machine-name chip is still judged on the spec's source label (objectui#8231). To keep the studio namespace off the console's eager graph, `isZhLocale` and the schema-field label tables moved verbatim into a new import-free leaf, `views/metadata-admin/schema-field-label.ts`, and `i18n.ts` re-exports them. - The ADR-0146 D2 override declaration (`Object.keys(OverrideDeclarationFields)`, i.e. `overrides`) is dropped where the protection envelope is dropped, so a customized item no longer lists `+ overrides`. - A new item is added whole. It lists the fields and flow nodes it brings under "Includes:", or one sentence, "Publishing adds this item." (`preview.changes.detailNewItem`). It lists no key rows and no 平台生成 group. Updates keep both. - `object_extension` has a count family in all ten packs (`preview.changes.typeCount.object_extension`), so its group reads 「对象扩展 N」 / "N object extensions". - No new diff capability. **Load errors** - New `answeredLoadFailure(err)` in `@object-ui/react`, beside `classifyLoadError`, exported with its type. It returns the server's `{ code, message }` when a server answered, and `null` otherwise. A server answered when the error has a numeric `httpStatus`, `status` or `statusCode`, or `HTTP` plus three digits in its message. The sentence comes from `extractWriteErrorMessage`, or from a wrapped `{ error: { code, message } }` body parked under `details`. RelatedList's local `serverSentence` rule is lifted into it. - ListView: kind `network` with an answer shows the server's sentence and a `list-error-code` chip. The network sentence stays for no answer. Refused, unauthorized, rejected and api-disabled kinds keep their own fixed copy. - RecordDetailView `failed` and PageView's record scope `failed` follow the same rule (`record-load-failed-code`, `page-record-load-failed-code`). RelatedList now reads the shared helper and shows the code too. - Out of scope: the route-level read failure in `console/AppContent.tsx` (`RouteObjectReadFailed`, with `MetadataProvider` and `appObjectScope`) is filed as #29728. ## Tests - `DraftChangesPanel.namedChanges-27835`: - a customized dashboard update in zh-CN lists `~ 列数` and `~ 显示名称`, with no `overrides` and no English label; - an English control; - `computeChangeDetail` drops the declaration; - a new view in zh-CN shows `draft-entry-new` and no key rows, with no `viewKind`, `isDefault` or "Includes:". - This replaces the "heads a NEW view…" pin. - `DraftChangesPanel.groupHeading-13221`: an `object_extension` group reads 「对象扩展 1」 (zh) and "2 object extensions" (en), against the real packs. - `error-message.test`, `answeredLoadFailure`: - a 404 `OBJECT_NOT_FOUND` in the adapter's raw-read shape; - a body under `details`; - a wrapped `{error:{code,message}}` body that was not lifted; - an `HTTP 500` JSON tail; - controls: no answer, junk input, and the kind of an answered 404 is still `network`. - `ListView.loadErrorKind`: - a 404 `OBJECT_NOT_FOUND` shows its sentence, its code and Retry, with no network sentence; - an answered 500 JSON tail is quoted, with no code; - controls: `Failed to fetch` keeps the network sentence; a 403 shows no server text. - `RecordDetailView.recordReadFailure-11902`, over the real `ObjectStackAdapter`: - the 5xx answer is now the dispatcher's wrapped envelope; - in en and zh, the page shows its sentence and `INTERNAL_ERROR`, never 请检查网络连接; - a transport failure keeps the network sentence in en and zh (new zh pin). - `PageView.appPageRecord-12931`: an answered 500 shows its sentence and code; a control for no answer. **Moved pins** These pins asserted the old new-item key list or the 平台生成 group on a new item. Seat 5 approved adding the last two files to the surface. - `DraftChangesPanel.generatedGroup-25890`: the repro's new object now shows its one field and nothing of the platform's. The group's collapsed line and its opened rows are pinned on an update of the same object. - `DraftChangesPanel.protectionEnvelope-12594`: the two "still lists an authored key" controls run on updates (`~ Label`), because a new item lists no keys. - `DraftChangesPanel.fieldRows-13125`: a new object draws no generated group. The hidden-column guard (`__search`, `owning_business_unit_id`) and the Owner / Created At controls moved to the Ticket update, whose draft now carries the stamped columns. - `DraftChangesPanel.bundleMember-12271`: a new `zh-CN` member reads `draft-entry-new` with no key rows. Its member-selector URL and expansion assertions are unchanged. **Ablations** (each red, each restored, `git diff HEAD` empty afterwards) | Ablation | Result | |---|---| | drop the locale from `KeyRow` | 1 red | | keep `overrides` | 3 red | | key rows on a new item | 1 red | | rename the `object_extension` count row | 2 red | | `answeredLoadFailure` never answers | 9 red | | it always answers | 6 red | | the ListView branch | 2 red | | the RecordDetailView sentence | 2 red | | the PageView sentence | 1 red | ## Acceptance notes - Only `object_extension` gained a count family. Other registered types without one (for example `hook`, `position`) keep their raw name in a group heading, as the summary line does. - A new item whose draft is empty still reads the existing "No differences detected" sentence (pinned by `noChangeAndKeySplit-11807`, unchanged). - On the list page only kind `network` quotes the server. Every other kind keeps its fixed sentence, as before. ## Verification Head `eae9d624` (main merged twice; the second merge touched none of these files). - Dispatch gates: all 35 derived commands were run, and each exited 0. This includes `node scripts/check-sdui-manifest.mjs`, `check-changeset-no-major`, `check-adr-0087-registration`, `check-empty-changeset`, `pnpm --dir ui exec vitest run scripts/`, `check:type-check-coverage` and `check:type-check-debt`. `dispatch-gates --ran` reconciles: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. - Type-check: `type-check` is green for `@object-ui/react`, `@object-ui/plugin-list`, `@object-ui/plugin-detail` and `@object-ui/app-shell`, after merging main. - Tests (locked runs): - preview suite: 35 files, 251 tests; - the four load-error files: 84 tests; - `plugin-detail` plus react `utils`: 3330 passed; - all of `packages/i18n`: 1443 passed, including `count-families-11445`, `untranslated-identity-4376`, the key-parity tests and `studio-namespace`; - post-merge re-run of the preview, i18n and load-error files: 1781 passed. - Lint: eslint on the touched files reports 0 errors. - `plugin-form/src/affordanceGrantMap-12082.test.tsx` was not run, because this diff does not touch its files. Live screenshots, zh-CN, `examples/app-showcase` dev server plus the Vite console. The "before" leg served `origin/main`'s copies of the changed source files. - **Sheet setup:** `ensure-customization`, then three drafts in `com.example.showcase.custom`: - the base dashboard `showcase_ops_dashboard`, customized with a new label and column count, carrying `overrides`; - a new dashboard; - an `object_extension` on `showcase_task`. - **What the sheet shows:** - Update. Before: `其他变更: + overrides ~ Columns ~ Label`. After: `其他变更: ~ 列数 ~ 显示名称`. - New item. Before: `包含: + Columns + Date Range + Description + Global Filters + 任务状态 task_status + Label + Name + Widgets`. After: `发布后将新增此项。` - Group heading. Before: `OBJECT_EXTENSION 1`. After: `对象扩展 1`. - **List load error:** a Playwright route answers the task list's expand read with 404 `{"success":false,"error":{"code":"OBJECT_NOT_FOUND","message":"Object 'showcase_task' is not registered"}}`. - Before: `无法加载记录 / 加载数据时出错。请检查网络连接后重试。` - After: `无法加载记录 / Object 'showcase_task' is not registered / OBJECT_NOT_FOUND / 重试`. - With a 500 `INTERNAL_ERROR` answer, the panel shows that sentence and code the same way. - **The PNGs:** ten files, `before-` and `after-`, each of `sheet-groups`, `sheet-update`, `sheet-new`, `list-load-error-404` and `list-load-error-500`. They are in the project folder `issue-29704/screens/`, and the driver script is in `issue-29704/measure/`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jVQc3uTU5ZhjP31Sh8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1a8ba3f commit 69dbb3c

32 files changed

Lines changed: 692 additions & 165 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/react': patch
4+
'@object-ui/plugin-list': patch
5+
'@object-ui/plugin-detail': patch
6+
'@object-ui/i18n': patch
7+
---
8+
9+
feat(app-shell): the publish sheet speaks in the console's language, and a load error the server answered shows its code and sentence
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: none
13+
14+
The Pending changes sheet now names an updated item's changed keys by the label the inspector gives them, in the console's language (a customized dashboard reads 「~ 显示名称」, not "~ Label"). It no longer lists the ADR-0146 override declaration (`overrides`) as a change. A new item is added whole, so it lists no key list and no 平台生成 group: it shows only the fields and flow nodes it brings, or "Publishing adds this item." An `object_extension` group reads as its translated name ("2 object extensions", 「对象扩展 2」). This widens the ten shipped packs by `preview.changes.detailNewItem` and the `preview.changes.typeCount.object_extension` count family.
15+
16+
When a read fails and the server answered, the list page, the record page, the app page and the related list now show the server's sentence and code, for example 404 `OBJECT_NOT_FOUND` with "Object '…' is not registered". "Check your connection and try again" is only shown when no answer came back. All four share one rule, `answeredLoadFailure`, newly exported from `@object-ui/react`.

‎ui/packages/app-shell/src/preview/DraftChangesPanel.tsx‎

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ import { readDraftChanges, type DraftChange, type MetadataClient } from '@object
8484
import { fetchPendingDrafts } from './usePendingDrafts.js';
8585
import { canonicalMetaUrlType } from '@objectstack/spec/shared';
8686
import { resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui';
87-
import { MetadataProtectionFields, stripReadDecorations } from '@objectstack/spec/kernel';
87+
import { MetadataProtectionFields, OverrideDeclarationFields, stripReadDecorations } from '@objectstack/spec/kernel';
8888
// objectui#12929 — the ONE discriminator of an authored default's shape, the
8989
// same function the batch door's backfill judgement calls. Moves nothing onto
9090
// the eager graph for the reason recorded above (`vendor-objectstack`).
@@ -555,10 +555,23 @@ const DERIVED_KEY_TESTS: Readonly<Record<string, (body: Record<string, unknown>)
555555
*/
556556
const PROTECTION_ENVELOPE_KEYS: readonly string[] = Object.freeze(Object.keys(MetadataProtectionFields));
557557

558-
/** `body` without its top-level protection envelope; a copy, never a mutation. */
558+
/**
559+
* objectstack#29704 — the ADR-0146 D2 override declaration (`overrides`), read
560+
* off the spec's own declaration like the envelope above. Customizing a base
561+
* package's item writes it into the `.custom` pair's copy, so on a customized
562+
* item it is a change of every publish and of none of the person's edits: the
563+
* sheet listed it as "+ overrides" beside what they actually changed.
564+
*/
565+
const OVERRIDE_DECLARATION_KEYS: readonly string[] = Object.freeze(Object.keys(OverrideDeclarationFields));
566+
567+
/**
568+
* `body` without its top-level protection envelope and override declaration;
569+
* a copy, never a mutation.
570+
*/
559571
function withoutProtectionEnvelope(body: Record<string, unknown>): Record<string, unknown> {
560572
const rest = { ...body };
561573
for (const key of PROTECTION_ENVELOPE_KEYS) delete rest[key];
574+
for (const key of OVERRIDE_DECLARATION_KEYS) delete rest[key];
562575
return rest;
563576
}
564577

@@ -829,6 +842,9 @@ const TYPE_COUNT: Readonly<Record<string, (t: Translate, count: number) => strin
829842
agent: (t, count) => t('preview.changes.typeCount.agent', { count, defaultValue: '{{count}} agents' }),
830843
email_template: (t, count) =>
831844
t('preview.changes.typeCount.email_template', { count, defaultValue: '{{count}} email templates' }),
845+
// objectstack#29704 — what customizing a base package's object writes (ADR-0146 D10).
846+
object_extension: (t, count) =>
847+
t('preview.changes.typeCount.object_extension', { count, defaultValue: '{{count}} object extensions' }),
832848
};
833849

834850
function summaryTypeSegment(t: Translate, type: string, count: number): string {
@@ -911,14 +927,20 @@ function KeyRow({
911927
kind,
912928
type,
913929
name,
930+
locale,
914931
children,
915932
}: {
916933
kind: keyof typeof FIELD_ROW_MARK;
917934
type: string;
918935
name: string;
936+
locale: string;
919937
children?: ReactNode;
920938
}) {
921-
const label = formKeyLabel(type, name);
939+
// objectstack#29704 — the row reads in the console's language, as the
940+
// inspector labels it; whether the key shows beside it is judged on the
941+
// spec's own label, as `SchemaForm`'s machine-name chip is (objectui#8231).
942+
const label = formKeyLabel(type, name, locale);
943+
const sourceLabel = formKeyLabel(type, name);
922944
return (
923945
<div data-testid="draft-key-row">
924946
<p className={`${label === undefined ? 'font-mono ' : ''}text-xs ${FIELD_ROW_TONE[kind]}`}>
@@ -928,7 +950,7 @@ function KeyRow({
928950
) : (
929951
<>
930952
{label}
931-
{labelSpellsKey(label, name) ? null : (
953+
{labelSpellsKey(sourceLabel ?? label, name) ? null : (
932954
<>
933955
{' '}
934956
<span className="font-mono text-[11px] text-muted-foreground">{name}</span>
@@ -946,8 +968,8 @@ function KeyRow({
946968
* The properties a changed field or list entry lists after its name, each by
947969
* its label on `type`'s form where the form has a row for it, else its key.
948970
*/
949-
function propertyList(type: string, keys: readonly string[]): string {
950-
return keys.map((key) => formKeyLabel(type, key) ?? key).join(', ');
971+
function propertyList(type: string, keys: readonly string[], locale: string): string {
972+
return keys.map((key) => formKeyLabel(type, key, locale) ?? key).join(', ');
951973
}
952974

953975
/**
@@ -956,9 +978,19 @@ function propertyList(type: string, keys: readonly string[]): string {
956978
* entry lists what differs by the labels the list's own form row declares for
957979
* its sub-rows (`fieldGroups` → "Label", "Collapse").
958980
*/
959-
function ListEntryRows({ type, listKey, diff }: { type: string; listKey: string; diff: ListEntryDiff }) {
981+
function ListEntryRows({
982+
type,
983+
listKey,
984+
diff,
985+
locale,
986+
}: {
987+
type: string;
988+
listKey: string;
989+
diff: ListEntryDiff;
990+
locale: string;
991+
}) {
960992
const entryKeys = (keys: readonly string[]) =>
961-
keys.map((key) => formKeyLabel(type, `${listKey}.${key}`) ?? key).join(', ');
993+
keys.map((key) => formKeyLabel(type, `${listKey}.${key}`, locale) ?? key).join(', ');
962994
return (
963995
<div className="ml-3 flex flex-col gap-0.5">
964996
{diff.added.map((e) => (
@@ -1014,7 +1046,7 @@ function EntryDetail({
10141046
onCompared?: (unchanged: boolean) => void;
10151047
backfill?: EntryBackfillControl;
10161048
}) {
1017-
const { t } = useObjectTranslation();
1049+
const { t, language } = useObjectTranslation();
10181050
const [detail, setDetail] = useState<EntryChangeDetail | null>(null);
10191051
const [agents, setAgents] = useState<DraftAgentSource[]>([]);
10201052
const [error, setError] = useState<string | null>(null);
@@ -1114,7 +1146,12 @@ function EntryDetail({
11141146
}
11151147

11161148
const { fields, nodes, keys, entries, generated, isNew } = detail;
1117-
const hasKeyRows = keys.added.length > 0 || keys.changed.length > 0 || keys.removed.length > 0;
1149+
// objectstack#29704 — a NEW item is added whole. Its key list (every property
1150+
// it was created with: `isDefault`, `viewKind`, …) and the columns the
1151+
// platform provisions for it name nothing the person changed, so the drill-in
1152+
// lists neither: only the fields and nodes it brings, or one sentence.
1153+
const hasKeyRows = !isNew && (keys.added.length > 0 || keys.changed.length > 0 || keys.removed.length > 0);
1154+
const hasNewItemRows = isNew && ((fields?.added.length ?? 0) > 0 || (nodes?.added.length ?? 0) > 0);
11181155
// objectstack#27176 — which AI tools drafted part of this item, one line each.
11191156
const agentLines = agents.map((source) => (
11201157
<p
@@ -1145,6 +1182,13 @@ function EntryDetail({
11451182
return (
11461183
<div className="flex flex-col gap-0.5 px-2 py-1" data-testid="draft-entry-detail">
11471184
{agentLines}
1185+
{isNew && (
1186+
<p className="text-xs text-muted-foreground" data-testid={hasNewItemRows ? undefined : 'draft-entry-new'}>
1187+
{hasNewItemRows
1188+
? t('preview.changes.detailNewKeys', { defaultValue: 'Includes:' })
1189+
: t('preview.changes.detailNewItem', { defaultValue: 'Publishing adds this item.' })}
1190+
</p>
1191+
)}
11481192
{fields?.added.map((name) => {
11491193
const candidate = backfill ? candidates.find((c) => c.field === name) : undefined;
11501194
const choice: DraftBackfillEntry = { object: entry.name, field: name };
@@ -1182,7 +1226,9 @@ function EntryDetail({
11821226
})}
11831227
{fields?.changed.map((f) => (
11841228
<FieldRow key={`~${f.name}`} kind="changed" name={f.name} label={fields.labels.get(f.name)}>
1185-
{f.keys.length > 0 && <span className="text-muted-foreground"> · {propertyList('field', f.keys)}</span>}
1229+
{f.keys.length > 0 && (
1230+
<span className="text-muted-foreground"> · {propertyList('field', f.keys, language)}</span>
1231+
)}
11861232
</FieldRow>
11871233
))}
11881234
{fields?.removed.map((name) => (
@@ -1208,23 +1254,21 @@ function EntryDetail({
12081254
))}
12091255
{hasKeyRows && (
12101256
<p className="text-xs text-muted-foreground">
1211-
{isNew
1212-
? // objectstack#25890 — every key of a NEW item is new: what it includes, not what else changed.
1213-
t('preview.changes.detailNewKeys', { defaultValue: 'Includes:' })
1214-
: t('preview.changes.detailChangedKeys', { defaultValue: 'Also changed:' })}
1257+
{t('preview.changes.detailChangedKeys', { defaultValue: 'Also changed:' })}
12151258
</p>
12161259
)}
1217-
{(['added', 'changed', 'removed'] as const).flatMap((kind) =>
1218-
keys[kind].map((name) => {
1219-
const list = entries.get(name);
1220-
return (
1221-
<KeyRow key={`k${FIELD_ROW_MARK[kind]}${name}`} kind={kind} type={entry.type} name={name}>
1222-
{list ? <ListEntryRows type={entry.type} listKey={name} diff={list} /> : null}
1223-
</KeyRow>
1224-
);
1225-
}),
1226-
)}
1227-
{generated.length > 0 && <GeneratedGroup rows={generated} labels={fields?.labels} />}
1260+
{hasKeyRows &&
1261+
(['added', 'changed', 'removed'] as const).flatMap((kind) =>
1262+
keys[kind].map((name) => {
1263+
const list = entries.get(name);
1264+
return (
1265+
<KeyRow key={`k${FIELD_ROW_MARK[kind]}${name}`} kind={kind} type={entry.type} name={name} locale={language}>
1266+
{list ? <ListEntryRows type={entry.type} listKey={name} diff={list} locale={language} /> : null}
1267+
</KeyRow>
1268+
);
1269+
}),
1270+
)}
1271+
{!isNew && generated.length > 0 && <GeneratedGroup rows={generated} labels={fields?.labels} />}
12281272
</div>
12291273
);
12301274
}

‎ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.bundleMember-12271.test.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,10 @@ describe('two members of one email_template are two rows (objectui#12271)', () =
147147

148148
await waitFor(() => expect(screen.getByTestId('draft-entry-detail')).toBeInTheDocument());
149149
const detail = screen.getByTestId('draft-entry-detail');
150-
// zh-CN is new: its draft adds `body` and `subject` against no published body.
151-
expect(detail).toHaveTextContent('+ body');
152-
expect(detail).toHaveTextContent('+ Subject');
150+
// zh-CN is new: no published body, so it is added whole and lists no key
151+
// list (objectstack#29704) — what it says comes from ITS draft read below.
152+
expect(within(detail).getByTestId('draft-entry-new')).toBeInTheDocument();
153+
expect(within(detail).queryAllByTestId('draft-key-row')).toHaveLength(0);
153154
expect(urls).toContain('/api/v1/meta/email_template/welcome?state=draft&package=com.x&member=zh-CN');
154155
// The canonical body was never read for this row.
155156
expect(urls).not.toContain('/api/v1/meta/email_template/welcome?state=draft&package=com.x');

‎ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.fieldRows-13125.test.tsx‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,20 @@ const TICKET_PUBLISHED = {
203203
__search: SEARCH_COMPANION_DEF,
204204
},
205205
};
206+
// The draft also carries the columns the platform stamps on, which the
207+
// published body predates: two it keeps (rows of the generated group) and one
208+
// it injects and hides (dropped) — the hidden-column guard on an UPDATE, where
209+
// the group is drawn (objectstack#29704: a NEW item draws none).
206210
const TICKET_DRAFT = {
207211
name: 'crm_ticket',
208212
label: 'Ticket',
209-
fields: { subject: { type: 'text', label: 'Subject' }, __search: SEARCH_COMPANION_DEF },
213+
fields: {
214+
subject: { type: 'text', label: 'Subject' },
215+
owner_id: VISIT_DRAFT.fields.owner_id,
216+
created_at: CREATED_AT_DEF,
217+
owning_business_unit_id: OWNING_BUSINESS_UNIT_DEF,
218+
__search: SEARCH_COMPANION_DEF,
219+
},
210220
};
211221

212222
function stubServer() {
@@ -262,10 +272,10 @@ describe('the drill-in of a NEW object (objectui#13125)', () => {
262272
expect(text).not.toContain(SEARCH_COMPANION_DEF.label);
263273
expect(text).not.toContain(OWNING_BUSINESS_UNIT_DEF.label);
264274
fieldRow(detail, '+ Internal Note');
265-
// The two system controls are in the detail, behind the generated group's toggle.
266-
fireEvent.click(within(detail).getByTestId('draft-generated-toggle'));
267-
for (const label of ['Owner', 'Created At']) fieldRow(detail, `+ ${label}`);
268-
for (const name of HIDDEN_SYSTEM) expect(detail.textContent ?? '').not.toContain(name);
275+
// objectstack#29704 — a NEW object draws no generated group: the two system
276+
// controls are the platform's, and are not listed at all.
277+
expect(within(detail).queryByTestId('draft-generated-toggle')).toBeNull();
278+
for (const label of ['Owner', 'Created At']) expect(text).not.toContain(label);
269279
});
270280

271281
it('CONTROL: a field with no label reads as its name alone, once, in monospace', async () => {
@@ -292,4 +302,16 @@ describe('the drill-in of an UPDATE (objectui#13125)', () => {
292302
expect(within(removed).getByText('legacy_code')).toHaveClass('font-mono');
293303
expect(detail.textContent ?? '').not.toContain('__search');
294304
});
305+
306+
it('lists no injected-and-hidden column in the generated group, and lists the system controls there', async () => {
307+
const detail = await expandRow('Ticket');
308+
const text = detail.textContent ?? '';
309+
for (const name of HIDDEN_SYSTEM) expect(text).not.toContain(name);
310+
expect(text).not.toContain(OWNING_BUSINESS_UNIT_DEF.label);
311+
// The two system controls are in the detail, behind the generated group's toggle.
312+
fireEvent.click(within(detail).getByTestId('draft-generated-toggle'));
313+
for (const label of ['Owner', 'Created At']) fieldRow(detail, `+ ${label}`);
314+
for (const name of HIDDEN_SYSTEM) expect(detail.textContent ?? '').not.toContain(name);
315+
expect(detail.textContent ?? '').not.toContain(SEARCH_COMPANION_DEF.label);
316+
});
295317
});

‎ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.generatedGroup-25890.test.tsx‎

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,11 @@ describe('computeChangeDetail — what the platform wrote is not an author row (
162162

163163
/* ─────────────── the panel ─────────────── */
164164

165-
function stubServer() {
165+
/**
166+
* objectstack#29704 — `published` makes the entry an UPDATE of that body; the
167+
* default is the card's NEW object.
168+
*/
169+
function stubServer(published: Record<string, unknown> | null = null) {
166170
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
167171
vi.stubGlobal(
168172
'fetch',
@@ -171,45 +175,59 @@ function stubServer() {
171175
if (url.includes('/_drafts')) {
172176
return ok({ drafts: [{ type: 'object', name: AUTHORED.name, packageId: 'com.x', label: AUTHORED.label }] });
173177
}
174-
if (/\/meta\/object(\?|$)/.test(url)) return ok([]);
178+
if (/\/meta\/object(\?|$)/.test(url)) return ok(published ? [{ name: AUTHORED.name }] : []);
175179
if (url.includes(`/meta/object/${AUTHORED.name}`) && url.includes('state=draft')) {
176180
return ok({ item: REPAIR_TICKET_DRAFT });
177181
}
182+
if (published && url.includes(`/meta/object/${AUTHORED.name}`)) return ok(published);
178183
return { ok: false, status: 404, json: async () => ({}) };
179184
}) as unknown as typeof fetch,
180185
);
181186
}
182187

183-
async function expandRepairTicket(): Promise<HTMLElement> {
184-
stubServer();
188+
async function expandRepairTicket(published: Record<string, unknown> | null = null): Promise<HTMLElement> {
189+
stubServer(published);
185190
render(<DraftChangesPanel open onOpenChange={() => {}} packageId="com.x" />);
186191
const toggle = (await screen.findByText(AUTHORED.label)).closest('[data-testid="draft-entry-toggle"]') as HTMLElement;
187192
fireEvent.click(toggle);
188193
return screen.findByTestId('draft-entry-detail');
189194
}
190195

196+
/**
197+
* The same object published before the platform stamped it: one authored
198+
* field, no system columns, no `nameField`. Publishing the served draft over it
199+
* is an UPDATE whose only differences are the platform's.
200+
*/
201+
const PUBLISHED_BEFORE_STAMP = { ...AUTHORED, fields: { name: { type: 'text', label: '名称' } } };
202+
191203
describe("the card's repro in the sheet (objectstack#25890)", () => {
192-
it('shows the one authored field and one collapsed line for what the platform wrote', async () => {
204+
// objectstack#29704 — a NEW item is added whole: its authored field under
205+
// "Includes:", and neither its key list nor the 平台生成 group.
206+
it('shows the one authored field of a NEW object and nothing the platform wrote', async () => {
193207
const detail = await expandRepairTicket();
194208
const rows = within(detail).getAllByTestId('draft-field-row');
195209
expect(rows.map((r) => r.textContent)).toEqual(['+ 名称 name']);
196-
const toggle = within(detail).getByTestId('draft-generated-toggle');
210+
expect(within(detail).getByText('Includes:')).toBeInTheDocument();
211+
expect(within(detail).queryByText('Also changed:')).toBeNull();
212+
expect(within(detail).queryByTestId('draft-generated-toggle')).toBeNull();
213+
expect(within(detail).queryAllByTestId('draft-key-row')).toHaveLength(0);
214+
const text = detail.textContent ?? '';
215+
for (const name of [...SYSTEM_COLUMNS, '__search', 'nameField', 'Sharing Model']) expect(text).not.toContain(name);
216+
});
217+
218+
it('folds what the platform wrote on an UPDATE into one collapsed line', async () => {
219+
const detail = await expandRepairTicket(PUBLISHED_BEFORE_STAMP);
220+
const toggle = await within(detail).findByTestId('draft-generated-toggle');
197221
expect(toggle).toHaveTextContent('Generated by the platform (6)');
198222
expect(toggle).toHaveAttribute('aria-expanded', 'false');
223+
expect(within(detail).queryAllByTestId('draft-field-row')).toHaveLength(0);
199224
const text = detail.textContent ?? '';
200225
for (const name of [...SYSTEM_COLUMNS, '__search', 'nameField']) expect(text).not.toContain(name);
201226
});
202227

203-
it('heads a NEW item’s keys as what it includes, not as other changes', async () => {
204-
const detail = await expandRepairTicket();
205-
expect(within(detail).getByText('Includes:')).toBeInTheDocument();
206-
expect(within(detail).queryByText('Also changed:')).toBeNull();
207-
expect(within(detail).getByText('+ Sharing Model')).toBeInTheDocument();
208-
});
209-
210228
it('opens the group to the five system columns, label first, and the derived nameField', async () => {
211-
const detail = await expandRepairTicket();
212-
const toggle = within(detail).getByTestId('draft-generated-toggle');
229+
const detail = await expandRepairTicket(PUBLISHED_BEFORE_STAMP);
230+
const toggle = await within(detail).findByTestId('draft-generated-toggle');
213231
fireEvent.click(toggle);
214232
expect(toggle).toHaveAttribute('aria-expanded', 'true');
215233
const group = within(detail).getByTestId('draft-generated-group');

0 commit comments

Comments
 (0)