diff --git a/.changeset/25890-pending-changes-generated-group.md b/.changeset/25890-pending-changes-generated-group.md new file mode 100644 index 00000000000..fd50b54b453 --- /dev/null +++ b/.changeset/25890-pending-changes-generated-group.md @@ -0,0 +1,17 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(app-shell): pending changes folds what the platform wrote into one collapsed line + +Clause-②: no +Metadata-protocol: none + +Publishing a new object from Studio listed the platform's injected system columns (`created_at`, `created_by`, `owner_id`, `updated_at`, `updated_by`) as fields the author added, and the object's derived `nameField` under "Also changed", beside the one field the author typed. + +- A field the publish adds or removes whose every definition carries `system: true` (the spec's mark for an auto-injected system/audit column), and a `nameField` equal to the registry's own `provisionPrimary` derivation, now fold into one collapsed "Generated by the platform (N)" line; opening it shows those rows. A system field whose definition differs between the published and draft bodies is an edit and stays an author row, as does a `nameField` the derivation would not pick. +- A new item's top-level keys are headed "Includes:" rather than "Also changed:": every key of an item that does not exist yet is part of it. +- The columns the platform injects and hides (`__search`, `organization_id`, `owning_business_unit_id`) are still not listed at all. + +New strings `preview.changes.detailNewKeys` and `preview.changes.generatedGroup` ship in all ten locale packs. diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index f65f470a705..684a23dffc1 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063" + "registrySourcesSha256": "0e127be18a1104d857822dc5c71a6380e4f00b0db729cd8b8a230a11c120fe78" } } diff --git a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx index c8a0f12ced7..6c92a1c507d 100644 --- a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -14,7 +14,9 @@ * Each entry expands into the fields (objects) and top-level keys it adds, * changes and removes, lazily fetched on first expand. An entry whose drill-in * finds the draft equal to its published version drops the UPDATE mark - * (objectui#11807): publishing still promotes it, but it changes nothing. This + * (objectui#11807): publishing still promotes it, but it changes nothing. What + * the platform wrote rather than the author — the system columns it provisions, + * the keys it derives — folds into one collapsed line (objectstack#25890). This * is the review surface that turns Publish from a leap of faith into an * informed click. * @@ -83,7 +85,7 @@ import { MetadataProtectionFields, stripReadDecorations } from '@objectstack/spe // objectui#12929 — the ONE discriminator of an authored default's shape, the // same function the batch door's backfill judgement calls. Moves nothing onto // the eager graph for the reason recorded above (`vendor-objectstack`). -import { discriminateDefaultValueShape } from '@objectstack/spec/data'; +import { discriminateDefaultValueShape, provisionPrimary } from '@objectstack/spec/data'; import { diffFields, readFields } from '../views/metadata-admin/previews/object-fields-io.js'; // objectui#13125 — the Data pillar's rule for a column the platform injected // and hides (objectui#11780), reused rather than restated. A leaf module with @@ -285,6 +287,21 @@ export interface EntryChangeDetail { changed: string[]; removed: string[]; }; + /** + * What publishing installs that the PLATFORM wrote, not the author: the + * system columns it provisions for the object and the keys it derives + * (objectstack#25890). Listed once, in a collapsed group of its own, never in + * `fields` or `keys` above. Field rows sort before key rows; each sorts by + * name. A generated field's label, when it declares one, is in + * `fields.labels`. + */ + generated: Array<{ name: string; kind: 'added' | 'changed' | 'removed'; scope: 'field' | 'key' }>; + /** + * True when there is no published body, so publishing ADDS the item + * (objectstack#25890). Every key it lists is then something the new item + * includes, not something else that changed, and the drill-in heads them so. + */ + isNew: boolean; } /** Stable equality for metadata values (small JSON — order-sensitive is fine). */ @@ -292,13 +309,46 @@ function valueEqual(a: unknown, b: unknown): boolean { return JSON.stringify(a ?? null) === JSON.stringify(b ?? null); } -/** True when the detail lists nothing: no field row and no key row. */ +/** True when the detail lists nothing: no field row, no key row and no generated row. */ function isEmptyChangeDetail(detail: EntryChangeDetail): boolean { - const { fields, keys } = detail; + const { fields, keys, generated } = detail; const fieldRows = fields ? fields.added.length + fields.changed.length + fields.removed.length : 0; - return fieldRows + keys.added.length + keys.changed.length + keys.removed.length === 0; + return fieldRows + keys.added.length + keys.changed.length + keys.removed.length + generated.length === 0; +} + +/** + * Did the platform write this field definition (objectstack#25890)? The spec's own + * mark: `system: true`, which `FieldSchema` declares as the auto-injected + * system/audit field and tells tools that surface system fields apart from the + * author's to branch on. Read as a boolean and nothing else, as the spec + * declares it. + */ +function isPlatformSystemField(def: Record | undefined): boolean { + return def?.system === true; } +/** + * Is `body.nameField` the pointer the registry DERIVES, rather than one the + * author chose (objectstack#25890)? The `/meta` reads replay the registry's + * materialization seam, which stamps `nameField` through `provisionPrimary` in + * designate-only mode. The answer is that same function over the body without + * the pointer — the registry's own write-side test for the same question — so + * no derivation rule is restated here. A pointer naming any other field is the + * author's choice and stays an author row; an absent pointer is no stamp. + */ +function isDerivedNameField(body: Record): boolean { + const present = body.nameField; + if (typeof present !== 'string' || present === '') return false; + const withoutPointer = { ...body }; + delete withoutPointer.nameField; + return provisionPrimary(withoutPointer, { synthesize: false }).nameField === present; +} + +/** Top-level keys whose every present value is platform-derived (objectstack#25890). */ +const DERIVED_KEY_TESTS: Readonly) => boolean>> = { + nameField: isDerivedNameField, +}; + /** * The ADR-0010 protection envelope (`_lock`, `_packageId`, `_provenance`, …), * read off the spec's own declaration so no key list lives here. The review @@ -351,9 +401,17 @@ function fieldLabelOf(def: Record | undefined): string | undefi * the Data pillar's own test (`isStudioHiddenSystemField`, objectui#11780). * The `/meta` reads serve an object with the platform's columns already * stamped on, so a NEW object's drill-in listed the search companion - * `__search` as a field the author added. Only that rule decides: a column - * the platform injects but does not hide (the audit columns, `owner_id`) is - * still listed. + * `__search` as a field the author added. Only that rule drops a row + * outright. + * + * A column the platform injects but does NOT hide — the audit columns, + * `owner_id` — is a row, but not the author's (objectstack#25890): a field this + * publish adds or removes whose every definition carries `system: true` moves + * to `generated`, the collapsed group of what the platform wrote. So does a + * top-level key whose value is the platform's derivation (`nameField`, see + * {@link isDerivedNameField}). A system field CHANGED between two bodies stays + * an author row: the platform injects the same definition on both reads, so a + * difference there is an edit someone made. * * Why not `computeDiffRows` from `LayeredDiff.tsx`, which splits top-level keys * the same way: that module imports the metadata-admin string table @@ -368,6 +426,7 @@ export function computeChangeDetail( const pub = withoutProtectionEnvelope(published ?? {}); const cur = withoutProtectionEnvelope(draft ?? {}); let fields: EntryChangeDetail['fields'] = null; + const generatedFields: EntryChangeDetail['generated'] = []; if (pub.fields != null || cur.fields != null) { const d = diffFields(pub.fields, cur.fields); const pubDefs = fieldDefsByName(pub.fields); @@ -376,7 +435,10 @@ export function computeChangeDetail( const defs = [pubDefs.get(name), curDefs.get(name)].filter((def) => def !== undefined); return !(defs.length > 0 && defs.every((def) => isStudioHiddenSystemField(def))); }; - const added = Object.values(d.byName) + // objectstack#25890 — added or removed, and the platform's on every side. + const platformWrote = (name: string): boolean => + [pubDefs.get(name), curDefs.get(name)].every((def) => def === undefined || isPlatformSystemField(def)); + const addedAll = Object.values(d.byName) .filter((e) => e.status === 'added' && listed(e.name)) .map((e) => e.name) .sort(); @@ -384,30 +446,44 @@ export function computeChangeDetail( .filter((e) => e.status === 'changed' && listed(e.name)) .map((e) => ({ name: e.name, keys: e.changedKeys })) .sort((a, b) => a.name.localeCompare(b.name)); - const removed = d.removed + const removedAll = d.removed .map((e) => e.name) .filter(listed) .sort(); + const added = addedAll.filter((name) => !platformWrote(name)); + const removed = removedAll.filter((name) => !platformWrote(name)); + for (const name of addedAll) if (platformWrote(name)) generatedFields.push({ name, kind: 'added', scope: 'field' }); + for (const name of removedAll) { + if (platformWrote(name)) generatedFields.push({ name, kind: 'removed', scope: 'field' }); + } + generatedFields.sort((a, b) => a.name.localeCompare(b.name)); const labels = new Map(); - for (const name of [...added, ...changed.map((f) => f.name), ...removed]) { + for (const name of [...addedAll, ...changed.map((f) => f.name), ...removedAll]) { // The draft's definition where there is one: it is what publishing installs. const label = fieldLabelOf(curDefs.get(name) ?? pubDefs.get(name)); if (label !== undefined) labels.set(name, label); } fields = { added, changed, removed, labels }; } + const isNew = published === null; const names = new Set([...Object.keys(pub), ...Object.keys(cur)]); names.delete('fields'); const keys: EntryChangeDetail['keys'] = { added: [], changed: [], removed: [] }; + const generatedKeys: EntryChangeDetail['generated'] = []; for (const k of [...names].sort()) { const before = (pub as Record)[k]; const after = (cur as Record)[k]; if (valueEqual(before, after)) continue; - if (before == null) keys.added.push(k); - else if (after == null) keys.removed.push(k); - else keys.changed.push(k); + const kind = before == null ? 'added' : after == null ? 'removed' : 'changed'; + const derived = Object.prototype.hasOwnProperty.call(DERIVED_KEY_TESTS, k) ? DERIVED_KEY_TESTS[k] : undefined; + // Derived only when every body that carries the key carries the derivation. + if (derived && [before == null ? null : pub, after == null ? null : cur].every((b) => b === null || derived(b))) { + generatedKeys.push({ name: k, kind, scope: 'key' }); + continue; + } + keys[kind].push(k); } - return { fields, keys }; + return { fields, keys, generated: [...generatedFields, ...generatedKeys], isNew }; } /** @@ -678,7 +754,7 @@ function EntryDetail({ ); } - const { fields, keys } = detail; + const { fields, keys, generated, isNew } = detail; const hasKeyRows = keys.added.length > 0 || keys.changed.length > 0 || keys.removed.length > 0; if (isEmptyChangeDetail(detail)) { return ( @@ -737,7 +813,10 @@ function EntryDetail({ ))} {hasKeyRows && (

- {t('preview.changes.detailChangedKeys', { defaultValue: 'Also changed:' })} + {isNew + ? // objectstack#25890 — every key of a NEW item is new: what it includes, not what else changed. + t('preview.changes.detailNewKeys', { defaultValue: 'Includes:' }) + : t('preview.changes.detailChangedKeys', { defaultValue: 'Also changed:' })}

)} {keys.added.map((name) => ( @@ -755,6 +834,55 @@ function EntryDetail({ − {name}

))} + {generated.length > 0 && } + + ); +} + +/** + * objectstack#25890 — what the platform wrote into this publish, folded into + * one line: the system columns it provisions and the keys it derives. Collapsed + * on every open, the confirm posture's too (that posture expands the item rows, + * not this group); the toggle shows the rows, read-only, in the same shape as + * the author's. + */ +function GeneratedGroup({ + rows, + labels, +}: { + rows: EntryChangeDetail['generated']; + labels: ReadonlyMap | undefined; +}) { + const { t } = useObjectTranslation(); + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( +
+ {rows.map((row) => + row.scope === 'field' ? ( + + ) : ( +

+ {FIELD_ROW_MARK[row.kind]} {row.name} +

+ ), + )} +
+ )}
); } diff --git a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.fieldRows-13125.test.tsx b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.fieldRows-13125.test.tsx index 504e106c587..611f4d43053 100644 --- a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.fieldRows-13125.test.tsx +++ b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.fieldRows-13125.test.tsx @@ -30,9 +30,13 @@ * * - `internal_note` — an author field with `hidden: true` and no `system`: * listed. - * - `owner_id` — `system: true` without `hidden`: listed. - * - `created_at` — an audit column, `system` without `hidden`: listed. The - * rule does not hide it, and this card does not widen the rule. + * - `owner_id` — `system: true` without `hidden`: not dropped. + * - `created_at` — an audit column, `system` without `hidden`: not dropped. + * The rule does not hide it, and this card does not widen the rule. Both are + * rows of the collapsed group of what the platform wrote, not the author's + * field rows (objectstack#25890, pinned in the generatedGroup test beside + * this one); here they are the controls that the drop rule leaves them in + * the detail at all. */ import '@testing-library/jest-dom/vitest'; @@ -95,9 +99,9 @@ const VISIT_DRAFT = { code: { type: 'text', label: 'code' }, // Control: author-hidden, not system — listed. internal_note: { type: 'text', label: 'Internal Note', hidden: true }, - // Control: system, not hidden — listed. + // Control: system, not hidden — not dropped (a generated row, objectstack#25890). owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner', required: false, readonly: false, system: true }, - // Control: audit column — listed; the rule does not hide it. + // Control: audit column — not dropped; the rule does not hide it (a generated row). created_at: CREATED_AT_DEF, owning_business_unit_id: OWNING_BUSINESS_UNIT_DEF, __search: SEARCH_COMPANION_DEF, @@ -105,7 +109,9 @@ const VISIT_DRAFT = { }; const HIDDEN_SYSTEM = ['__search', 'owning_business_unit_id'] as const; -const VISIT_LISTED = ['bare', 'code', 'created_at', 'internal_note', 'owner_id', 'visit_date']; +const VISIT_LISTED = ['bare', 'code', 'internal_note', 'visit_date']; +/** The system columns the drop rule keeps: rows of the generated group (objectstack#25890). */ +const VISIT_GENERATED = ['created_at', 'owner_id']; afterEach(() => { cleanup(); @@ -116,6 +122,7 @@ describe('computeChangeDetail — a column the platform injected and hides is no it("a NEW object's added fields leave out the injected-and-hidden columns, and keep every control", () => { const d = computeChangeDetail(null, VISIT_DRAFT); expect(d.fields?.added).toEqual(VISIT_LISTED); + expect(d.generated.filter((g) => g.scope === 'field').map((g) => g.name)).toEqual(VISIT_GENERATED); }); it('an UPDATE lists no changed or removed row for one either, and lists the author rows beside it', () => { @@ -254,7 +261,11 @@ describe('the drill-in of a NEW object (objectui#13125)', () => { for (const name of HIDDEN_SYSTEM) expect(text).not.toContain(name); expect(text).not.toContain(SEARCH_COMPANION_DEF.label); expect(text).not.toContain(OWNING_BUSINESS_UNIT_DEF.label); - for (const label of ['Internal Note', 'Owner', 'Created At']) fieldRow(detail, `+ ${label}`); + fieldRow(detail, '+ Internal Note'); + // The two system controls are in the detail, behind the generated group's toggle. + fireEvent.click(within(detail).getByTestId('draft-generated-toggle')); + for (const label of ['Owner', 'Created At']) fieldRow(detail, `+ ${label}`); + for (const name of HIDDEN_SYSTEM) expect(detail.textContent ?? '').not.toContain(name); }); it('CONTROL: a field with no label reads as its name alone, once, in monospace', async () => { diff --git a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.generatedGroup-25890.test.tsx b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.generatedGroup-25890.test.tsx new file mode 100644 index 00000000000..f35500a0691 --- /dev/null +++ b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.generatedGroup-25890.test.tsx @@ -0,0 +1,221 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#25890 — the pending-changes drill-in lists what the author + * changed; what the platform wrote folds into one collapsed line. + * + * Measured in a console walkthrough: Studio → new object 「报修工单」 (OWD + * private) → Publish expanded the one object to six field rows (the five + * injected system columns beside the author's `name`) and four "other + * changes" (`label`, `name`, `nameField`, `sharingModel`). The `/meta` reads + * serve an object with the platform's columns stamped on and its `nameField` + * derived, and a NEW object has no published body to cancel them against. + * + * The fixture is that object as the read serves it. Its system columns are the + * spec's own definitions (`injectedSystemColumnDefs`, the table the injection + * spreads), relabelled the way the read localises them, so the test proves the + * fold reads the `system` mark and not a label or a byte-identity. + * + * Controls, each a way to get the fold wrong while the headline stays green: + * + * - a system field CHANGED between two bodies is the author's edit: listed; + * - a `nameField` naming a field the derivation would not pick is the author's + * choice: listed; + * - a system field an UPDATE adds is the platform's: folded; + * - an author field that merely looks like an audit column (no `system`): listed. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { injectedSystemColumnDefs } from '@objectstack/spec/data'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (_k: string, o?: Record & { defaultValue?: string }) => + (o?.defaultValue ?? _k).replace(/\{\{(\w+)\}\}/g, (_m, key: string) => String(o?.[key] ?? '')), + language: 'en', + }), +})); + +import { DraftChangesPanel, computeChangeDetail } from '../DraftChangesPanel'; + +/** The read's zh-CN labels for the injected columns (`SYSTEM_FIELD_LABELS`). */ +const ZH_LABELS: Record = { + created_at: '创建时间', + created_by: '创建人', + owner_id: '所有者', + updated_at: '更新时间', + updated_by: '更新人', +}; + +const AUTHORED = { name: 'com_test_browser_repair_ticket', label: '报修工单', sharingModel: 'private' }; + +/** The injected columns as the read serves them: the spec's definitions, labels localised. */ +function servedSystemColumns(): Record> { + const injected = injectedSystemColumnDefs({ ...AUTHORED, fields: { name: { type: 'text' } } }); + const out: Record> = {}; + for (const [name, def] of Object.entries(injected)) { + out[name] = { ...def, ...(ZH_LABELS[name] ? { label: ZH_LABELS[name] } : {}) }; + } + return out; +} + +/** The card's repro: one authored field, the platform's columns, the derived pointer. */ +const REPAIR_TICKET_DRAFT = { + ...AUTHORED, + nameField: 'name', + fields: { + name: { type: 'text', label: '名称' }, + ...servedSystemColumns(), + // The search companion: injected AND hidden, dropped outright (objectui#13125). + __search: { type: 'text', label: 'Search Index', hidden: true, readonly: true, system: true }, + }, +}; + +const SYSTEM_COLUMNS = ['created_at', 'created_by', 'owner_id', 'updated_at', 'updated_by']; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('computeChangeDetail — what the platform wrote is not an author row (objectstack#25890)', () => { + it('the fixture carries the five visible system columns the card measured', () => { + // Guards the fixture: if the injection table moves, this test says so here. + const visible = Object.entries(REPAIR_TICKET_DRAFT.fields) + .filter(([, def]) => (def as { system?: unknown }).system === true && !(def as { hidden?: unknown }).hidden) + .map(([name]) => name) + .sort(); + expect(visible).toEqual(SYSTEM_COLUMNS); + }); + + it("a NEW object's author rows are its authored field and keys; the system columns and nameField are generated", () => { + const d = computeChangeDetail(null, REPAIR_TICKET_DRAFT); + expect(d.isNew).toBe(true); + expect(d.fields?.added).toEqual(['name']); + expect(d.fields?.changed).toEqual([]); + expect(d.fields?.removed).toEqual([]); + expect(d.keys).toEqual({ added: ['label', 'name', 'sharingModel'], changed: [], removed: [] }); + expect(d.generated).toEqual([ + ...SYSTEM_COLUMNS.map((name) => ({ name, kind: 'added', scope: 'field' })), + { name: 'nameField', kind: 'added', scope: 'key' }, + ]); + // A generated field keeps its label, for the row the group shows when opened. + expect(d.fields?.labels.get('owner_id')).toBe('所有者'); + }); + + it('CONTROL: a field shaped like an audit column without the system mark is the author’s', () => { + const draft = { name: 'x', fields: { created_on: { type: 'datetime', label: 'Created On', readonly: true } } }; + const d = computeChangeDetail(null, draft); + expect(d.fields?.added).toEqual(['created_on']); + expect(d.generated).toEqual([]); + }); + + it('an UPDATE folds a system column it adds, and lists a system column the author changed', () => { + const cols = servedSystemColumns(); + const published = { + name: 'ticket', + fields: { subject: { type: 'text' }, created_at: cols.created_at }, + }; + const draft = { + name: 'ticket', + fields: { + subject: { type: 'text' }, + // Relabelled by the author: the same column on both reads, so the difference is an edit. + created_at: { ...cols.created_at, label: 'Opened At' }, + // Newly provisioned (e.g. the OWD now asks for an owner): the platform's. + owner_id: cols.owner_id, + }, + }; + const d = computeChangeDetail(published, draft); + expect(d.isNew).toBe(false); + expect(d.fields?.added).toEqual([]); + expect(d.fields?.changed).toEqual([{ name: 'created_at', keys: ['label'] }]); + expect(d.generated).toEqual([{ name: 'owner_id', kind: 'added', scope: 'field' }]); + }); + + it('CONTROL: a nameField the derivation would not pick is the author’s choice and is listed', () => { + const fields = { name: { type: 'text' }, subject: { type: 'text' } }; + const published = { name: 'ticket', nameField: 'name', fields }; + const draft = { name: 'ticket', nameField: 'subject', fields }; + const d = computeChangeDetail(published, draft); + expect(d.keys.changed).toEqual(['nameField']); + expect(d.generated).toEqual([]); + }); + + it('a nameField moving between two derived answers is the platform’s', () => { + const published = { name: 'ticket', nameField: 'name', fields: { name: { type: 'text' } } }; + const draft = { name: 'ticket', nameField: 'subject', fields: { subject: { type: 'text' } } }; + const d = computeChangeDetail(published, draft); + expect(d.keys.changed).toEqual([]); + expect(d.generated).toEqual([{ name: 'nameField', kind: 'changed', scope: 'key' }]); + }); +}); + +/* ─────────────── the panel ─────────────── */ + +function stubServer() { + const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/_drafts')) { + return ok({ drafts: [{ type: 'object', name: AUTHORED.name, packageId: 'com.x', label: AUTHORED.label }] }); + } + if (/\/meta\/object(\?|$)/.test(url)) return ok([]); + if (url.includes(`/meta/object/${AUTHORED.name}`) && url.includes('state=draft')) { + return ok({ item: REPAIR_TICKET_DRAFT }); + } + return { ok: false, status: 404, json: async () => ({}) }; + }) as unknown as typeof fetch, + ); +} + +async function expandRepairTicket(): Promise { + stubServer(); + render( {}} packageId="com.x" />); + const toggle = (await screen.findByText(AUTHORED.label)).closest('[data-testid="draft-entry-toggle"]') as HTMLElement; + fireEvent.click(toggle); + return screen.findByTestId('draft-entry-detail'); +} + +describe("the card's repro in the sheet (objectstack#25890)", () => { + it('shows the one authored field and one collapsed line for what the platform wrote', async () => { + const detail = await expandRepairTicket(); + const rows = within(detail).getAllByTestId('draft-field-row'); + expect(rows.map((r) => r.textContent)).toEqual(['+ 名称 name']); + const toggle = within(detail).getByTestId('draft-generated-toggle'); + expect(toggle).toHaveTextContent('Generated by the platform (6)'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + const text = detail.textContent ?? ''; + for (const name of [...SYSTEM_COLUMNS, '__search', 'nameField']) expect(text).not.toContain(name); + }); + + it('heads a NEW item’s keys as what it includes, not as other changes', async () => { + const detail = await expandRepairTicket(); + expect(within(detail).getByText('Includes:')).toBeInTheDocument(); + expect(within(detail).queryByText('Also changed:')).toBeNull(); + expect(within(detail).getByText('+ sharingModel')).toBeInTheDocument(); + }); + + it('opens the group to the five system columns, label first, and the derived nameField', async () => { + const detail = await expandRepairTicket(); + const toggle = within(detail).getByTestId('draft-generated-toggle'); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + const group = within(detail).getByTestId('draft-generated-group'); + const fieldRows = within(group).getAllByTestId('draft-field-row').map((r) => r.textContent); + expect(fieldRows).toEqual(SYSTEM_COLUMNS.map((name) => `+ ${ZH_LABELS[name]} ${name}`)); + expect(within(group).getByText('+ nameField')).toBeInTheDocument(); + expect(group.textContent ?? '').not.toContain('__search'); + }); +}); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 891cfe2b3cf..476ee91451e 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -4698,6 +4698,8 @@ const ar = { detailLoadFailed: 'تعذر تحميل تفاصيل التغيير:', detailNone: 'لم يتم اكتشاف اختلافات — المسودة مطابقة للنسخة المنشورة.', detailChangedKeys: 'تغييرات أخرى:', + detailNewKeys: 'يتضمن:', + generatedGroup: 'أنشأتها المنصة ({{n}})', confirmNote: 'النشر يُصدر كل المسودات المعلقة ({{count}}) لهذه الحزمة دفعة واحدة.', confirmNote_zero: 'النشر يُصدر كل المسودات المعلقة ({{count}}) لهذه الحزمة دفعة واحدة.', confirmNote_one: 'النشر يُصدر كل المسودات المعلقة ({{count}}) لهذه الحزمة دفعة واحدة.', diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 45df300eb6b..f3714b5a787 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4341,6 +4341,8 @@ const de = { detailLoadFailed: 'Änderungsdetails konnten nicht geladen werden:', detailNone: 'Keine Unterschiede erkannt — der Entwurf entspricht der veröffentlichten Version.', detailChangedKeys: 'Ebenfalls geändert:', + detailNewKeys: 'Enthält:', + generatedGroup: 'Von der Plattform erzeugt ({{n}})', confirmNote: 'Beim Veröffentlichen werden alle {{count}} ausstehenden Entwürfe dieses Pakets atomar freigegeben.', confirmNote_one: 'Beim Veröffentlichen wird der {{count}} ausstehende Entwurf dieses Pakets atomar freigegeben.', confirmNote_other: 'Beim Veröffentlichen werden alle {{count}} ausstehenden Entwürfe dieses Pakets atomar freigegeben.', diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 912c9ea46ae..1fb9ef1160f 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3755,6 +3755,8 @@ const en = { detailLoadFailed: 'Could not load change detail:', detailNone: 'No differences detected — the draft matches the published version.', detailChangedKeys: 'Also changed:', + detailNewKeys: 'Includes:', + generatedGroup: 'Generated by the platform ({{n}})', confirmNote: 'Publishing releases all {{count}} pending drafts of this package atomically.', confirmNote_one: 'Publishing releases the {{count}} pending draft of this package atomically.', confirmNote_other: 'Publishing releases all {{count}} pending drafts of this package atomically.', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 49755a9440b..168ca7bb05c 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4431,6 +4431,8 @@ const es = { detailLoadFailed: 'No se pudieron cargar los detalles del cambio:', detailNone: 'No se detectaron diferencias — el borrador coincide con la versión publicada.', detailChangedKeys: 'También cambió:', + detailNewKeys: 'Incluye:', + generatedGroup: 'Generado por la plataforma ({{n}})', confirmNote: 'Publicar libera atómicamente los {{count}} borradores pendientes de este paquete.', confirmNote_one: 'Publicar libera atómicamente {{count}} borrador pendiente de este paquete.', confirmNote_many: 'Publicar libera atómicamente los {{count}} de borradores pendientes de este paquete.', diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 977e180ee73..36a583e9e77 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4427,6 +4427,8 @@ const fr = { detailLoadFailed: 'Impossible de charger les détails de la modification :', detailNone: 'Aucune différence détectée — le brouillon correspond à la version publiée.', detailChangedKeys: 'Également modifié :', + detailNewKeys: 'Inclut :', + generatedGroup: 'Généré par la plateforme ({{n}})', confirmNote: 'La publication libère atomiquement les {{count}} brouillons en attente de ce paquet.', confirmNote_one: 'La publication libère atomiquement {{count}} brouillon en attente de ce paquet.', confirmNote_many: 'La publication libère atomiquement les {{count}} de brouillons en attente de ce paquet.', diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 567ea51bf82..267f2b165cc 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4343,6 +4343,8 @@ const ja = { detailLoadFailed: '変更の詳細を読み込めませんでした:', detailNone: '差分は検出されませんでした — ドラフトは公開版と一致しています。', detailChangedKeys: 'その他の変更:', + detailNewKeys: '含まれるもの:', + generatedGroup: 'プラットフォームが生成({{n}})', confirmNote: '公開すると、このパッケージの保留中ドラフト {{count}} 件がまとめて(アトミックに)公開されます。', confirmNote_one: '公開すると、このパッケージの保留中ドラフト {{count}} 件がまとめて(アトミックに)公開されます。', confirmNote_other: '公開すると、このパッケージの保留中ドラフト {{count}} 件がまとめて(アトミックに)公開されます。', diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 72a5459b2e7..234b8d26b37 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4340,6 +4340,8 @@ const ko = { detailLoadFailed: '변경 상세 정보를 불러오지 못했습니다:', detailNone: '차이가 감지되지 않았습니다 — 초안이 게시된 버전과 일치합니다.', detailChangedKeys: '기타 변경:', + detailNewKeys: '포함:', + generatedGroup: '플랫폼에서 생성됨 ({{n}})', confirmNote: '게시하면 이 패키지의 대기 중인 초안 {{count}}개가 한 번에(원자적으로) 게시됩니다.', confirmNote_one: '게시하면 이 패키지의 대기 중인 초안 {{count}}개가 한 번에(원자적으로) 게시됩니다.', confirmNote_other: '게시하면 이 패키지의 대기 중인 초안 {{count}}개가 한 번에(원자적으로) 게시됩니다.', diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 02b58db8da4..e538e3b0cf9 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4426,6 +4426,8 @@ const pt = { detailLoadFailed: 'Não foi possível carregar os detalhes da alteração:', detailNone: 'Nenhuma diferença detectada — o rascunho corresponde à versão publicada.', detailChangedKeys: 'Também alterado:', + detailNewKeys: 'Inclui:', + generatedGroup: 'Gerado pela plataforma ({{n}})', confirmNote: 'Publicar libera atomicamente todos os {{count}} rascunhos pendentes deste pacote.', confirmNote_one: 'Publicar libera atomicamente {{count}} rascunho pendente deste pacote.', confirmNote_many: 'Publicar libera atomicamente todos os {{count}} de rascunhos pendentes deste pacote.', diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 747023fad25..fcb1150bec7 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -4530,6 +4530,8 @@ const ru = { detailLoadFailed: 'Не удалось загрузить детали изменения:', detailNone: 'Различий не обнаружено — черновик совпадает с опубликованной версией.', detailChangedKeys: 'Также изменено:', + detailNewKeys: 'Включает:', + generatedGroup: 'Создано платформой ({{n}})', confirmNote: 'Публикация атомарно выпускает все {{count}} ожидающих черновиков этого пакета.', confirmNote_one: 'Публикация атомарно выпускает {{count}} ожидающий черновик этого пакета.', confirmNote_few: 'Публикация атомарно выпускает все {{count}} ожидающих черновика этого пакета.', diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index aee0eb1ff9d..8d78c82a634 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -3262,6 +3262,8 @@ const zh = { detailLoadFailed: '无法加载变更详情:', detailNone: '未检测到差异 —— 草稿与已发布版本一致。', detailChangedKeys: '其他变更:', + detailNewKeys: '包含:', + generatedGroup: '平台生成({{n}})', confirmNote: '发布将一次性(原子地)发布此包全部 {{count}} 个待发布草稿。', confirmNote_one: '发布将一次性(原子地)发布此包全部 {{count}} 个待发布草稿。', confirmNote_other: '发布将一次性(原子地)发布此包全部 {{count}} 个待发布草稿。',