From 98f2916703537e366f1028d5833d27b8b5cfacd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:36:14 +0000 Subject: [PATCH 1/3] fix(studio): list an order-only field change and pin platform fields on the form canvas The publish review sheet compared an object's fields by name only, so a draft that only reorders them read "No differences detected" although the publish door hashes field order and saves it. The drill-in now compares the order of the fields both bodies declare, leaving out the columns the reads inject and the writes strip, and lists a "Field order changed" row. The form designer let an authored field sit above an ungrouped field whose definition is the platform's own (owner_id). The draft write strips that column and every read puts it back ahead of the declared fields, so the arrangement reverted on reload. The canvas now settles such fields first in the ungrouped bucket on every drop, and the drop announcement names the settled place. Claude-Session: https://claude.ai/code/session_01Utp6CZkEeDsL4LuR4hwqTN Co-authored-by: Claude --- .../27516-studio-field-order-reorder.md | 12 ++ .../src/preview/DraftChangesPanel.tsx | 72 +++++++++- .../studio-design/ObjectFormDesigner.tsx | 128 ++++++++++++------ ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 1 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + 13 files changed, 177 insertions(+), 45 deletions(-) create mode 100644 .changeset/27516-studio-field-order-reorder.md diff --git a/.changeset/27516-studio-field-order-reorder.md b/.changeset/27516-studio-field-order-reorder.md new file mode 100644 index 00000000000..24ca6947d70 --- /dev/null +++ b/.changeset/27516-studio-field-order-reorder.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(studio): the form designer and the publish review no longer misreport an order-only field change + +Clause-②: no +Metadata-protocol: none + +- **Publish review.** Expanding an object whose draft only reorders its fields used to read "No differences detected", although publishing does save the new order. The drill-in now lists a **Field order changed** row, and the entry keeps its Update badge. Only the fields both versions declare are compared, and the columns the platform injects on every read (an untouched `owner_id`, the audit columns, the search companion) are left out, because their place is the platform's, not the author's. Only an `object`'s fields are compared this way, matching the publish door. +- **Form designer.** An ungrouped field whose definition is exactly the platform's own, `owner_id` among them, now stays first in the ungrouped section. A field dropped above it lands below it, and the drop announcement names that place, so the canvas shows the order a reload shows. Moving such a field into a section, or editing it, makes it the author's field, and it then keeps the place it is given. diff --git a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx index 0583bc280d8..a2f96ea9e4e 100644 --- a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -85,7 +85,12 @@ 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, provisionPrimary } from '@objectstack/spec/data'; +import { + discriminateDefaultValueShape, + injectedSystemColumnDefs, + isInjectedColumnDefinition, + 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 @@ -313,6 +318,13 @@ export interface EntryChangeDetail { * A Map, never a plain object: `__proto__` is a legal field name. */ labels: ReadonlyMap; + /** + * objectstack#27516 — publishing changes the order of the fields both + * bodies declare. An `object`'s field order is content (the spec declares + * it the display order), so a pure reorder publishes a new version; see + * {@link fieldOrderChanged}. + */ + reordered: boolean; } | null; /** * Node-level diff of a flow's `nodes` (objectstack#27356): each node is @@ -368,7 +380,9 @@ export interface FlowNodeRow { /** True when the detail lists nothing: no field row, no node row, no key row and no generated row. */ function isEmptyChangeDetail(detail: EntryChangeDetail): boolean { const { fields, nodes, keys, generated } = detail; - const fieldRows = fields ? fields.added.length + fields.changed.length + fields.removed.length : 0; + const fieldRows = fields + ? fields.added.length + fields.changed.length + fields.removed.length + (fields.reordered ? 1 : 0) + : 0; const nodeRows = nodes ? nodes.added.length + nodes.changed.length + nodes.removed.length : 0; return fieldRows + nodeRows + keys.added.length + keys.changed.length + keys.removed.length + generated.length === 0; } @@ -497,6 +511,50 @@ function withoutProtectionEnvelope(body: Record): Record, cur: Record): boolean { + const storedOrder = (body: Record): string[] => { + const injected = injectedSystemColumnDefs(body); + return readFields(body.fields) + .entries.filter(({ name, def }) => { + if (isStudioHiddenSystemField(def)) return false; + const platform = Object.prototype.hasOwnProperty.call(injected, name) ? injected[name] : undefined; + return !(platform && isInjectedColumnDefinition(def, platform)); + }) + .map((e) => e.name); + }; + const before = storedOrder(pub); + const after = storedOrder(cur); + const inBefore = new Set(before); + const inAfter = new Set(after); + const kept = after.filter((name) => inBefore.has(name)); + return before.filter((name) => inAfter.has(name)).some((name, i) => name !== kept[i]); +} + /** Each field's definition by name, in either `fields` shape the diff reads. */ function fieldDefsByName(fieldsInput: unknown): Map> { return new Map(readFields(fieldsInput).entries.map((e) => [e.name, e.def] as const)); @@ -592,7 +650,9 @@ export function computeChangeDetail( const label = fieldLabelOf(curDefs.get(name) ?? pubDefs.get(name)); if (label !== undefined) labels.set(name, label); } - fields = { added, changed, removed, labels }; + // objectstack#27516 — the order the publish door hashes, for an `object` only. + const reordered = type === 'object' && fieldOrderChanged(pub, cur); + fields = { added, changed, removed, labels, reordered }; } const isNew = published === null; const names = new Set([...Object.keys(pub), ...Object.keys(cur)]); @@ -987,6 +1047,12 @@ function EntryDetail({ {fields?.removed.map((name) => ( ))} + {fields?.reordered && ( + // objectstack#27516 — a pure reorder publishes a new version, so it is a row. +

+ {FIELD_ROW_MARK.changed} {t('preview.changes.fieldOrderChanged', { defaultValue: 'Field order changed' })} +

+ )} {/* objectstack#27356 — a flow's nodes, each by its label, in the field rows' shape. */} {nodes?.added.map((n) => ( diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx index 601c9d1c708..26e2800f035 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx @@ -53,6 +53,7 @@ import { import { useSafeFieldLabel } from '@object-ui/i18n'; import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js'; import { isStudioHiddenSystemField } from './studioHiddenSystemField.js'; +import { injectedSystemColumnDefs, isInjectedColumnDefinition } from '@objectstack/spec/data'; import { formDndAccessibility, type FormDndLookups, type FormDndSlot } from './formDndAnnouncements.js'; import { AddFieldPopover } from './AddFieldPopover.js'; import type { FieldTypeId } from '../metadata-admin/previews/field-types.js'; @@ -193,15 +194,17 @@ function placeIn(layout: Record, id: string): LayoutPlace | nu } /** - * Where a drop of `activeId` on `overId` lands, read off the same container - * map `onDragEnd` reads and with the same arithmetic, so the place the live - * region announces is the place the drop commits (objectui#11802). `null` - * where `onDragEnd` returns without moving anything. The handler is the rule; - * this restates it for the announcements, and the pins in - * `ObjectFormDesigner.dndAnnouncements-11802.test.tsx` compare the two on - * every drop they make. + * The container map a drop of `activeId` on `overId` leaves, before the + * platform's pins are applied ({@link settlePinned}). `null` where the drop + * moves nothing because either id is in no container; `layout` itself where + * the field stays in place, which `onDragEnd` still commits (an earlier + * `onDragOver` may have carried it across groups). */ -function dropPlaceIn(layout: Record, activeId: string, overId: string): LayoutPlace | null { +function dropLayout( + layout: Record, + activeId: string, + overId: string, +): Record | null { const inContainer = (id: string): string | undefined => id.startsWith('g:') && id in layout ? id : Object.keys(layout).find((k) => layout[k].includes(id)); const from = inContainer(activeId); @@ -211,12 +214,61 @@ function dropPlaceIn(layout: Record, activeId: string, overId: const list = layout[from]; const oldIndex = list.indexOf(activeId); const newIndex = overId.startsWith('g:') ? list.length - 1 : list.indexOf(overId); - // `onDragEnd` keeps the field where it is when either index is missing. - return { container: from, index: newIndex < 0 ? oldIndex : newIndex, total: list.length }; + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return layout; + return { ...layout, [from]: arrayMove(list, oldIndex, newIndex) }; } const toItems = layout[to]; const overIndex = overId.startsWith('g:') ? toItems.length : toItems.indexOf(overId); - return { container: to, index: overIndex < 0 ? toItems.length : overIndex, total: toItems.length + 1 }; + const insertAt = overIndex < 0 ? toItems.length : overIndex; + return { + ...layout, + [from]: layout[from].filter((i) => i !== activeId), + [to]: [...toItems.slice(0, insertAt), activeId, ...toItems.slice(insertAt)], + }; +} + +/** + * objectstack#27516 — the ungrouped bucket with the platform's pinned fields + * first. A field whose definition, once the commit drops its `group`, is + * byte-for-byte the column the platform injects (`isInjectedColumnDefinition` + * over `injectedSystemColumnDefs`, `@objectstack/spec/data`) is not the + * author's: the draft write strips it (`stripInjectedSystemColumns`) and every + * `/meta` read puts it back AHEAD of the declared fields + * (`applyInjectedSystemColumns`, `@objectstack/metadata-core`). So a card left + * above it — `owner_id` is the one the canvas shows — sat there until a reload + * moved it back under Owner. The canvas places those fields where the read + * will: first in the bucket, in the order `pinRank` gives them. A pinned field + * dragged into a declared group carries a `group` and is the author's from + * then on, so only the ungrouped bucket is settled. Returns `layout` itself + * when nothing moves. + */ +function settlePinned( + layout: Record, + pinRank: ReadonlyMap, +): Record { + const bucket = layout[cid(UNGROUPED)]; + if (!bucket || pinRank.size === 0) return layout; + const pinned = bucket.filter((id) => pinRank.has(id)).sort((a, b) => pinRank.get(a)! - pinRank.get(b)!); + const settled = [...pinned, ...bucket.filter((id) => !pinRank.has(id))]; + return settled.every((id, i) => id === bucket[i]) ? layout : { ...layout, [cid(UNGROUPED)]: settled }; +} + +/** + * Where a drop of `activeId` on `overId` lands: {@link dropLayout}, settled the + * way `onDragEnd` settles it, so the place the live region announces is the + * place the drop commits (objectui#11802), a field the platform moves back + * included. `null` where `onDragEnd` returns without moving anything. The pins + * in `ObjectFormDesigner.dndAnnouncements-11802.test.tsx` compare the two on + * every drop they make. + */ +function dropPlaceIn( + layout: Record, + activeId: string, + overId: string, + pinRank: ReadonlyMap, +): LayoutPlace | null { + const next = dropLayout(layout, activeId, overId); + return next && placeIn(settlePinned(next, pinRank), activeId); } export interface ObjectFormDesignerProps { @@ -665,6 +717,21 @@ export function ObjectFormDesigner({ return m; }, [groups, locale, objectName, sectionLabel]); + // objectstack#27516 — the shown fields the platform pins first in the + // ungrouped bucket (see `settlePinned`), each by its place in the injection. + const pinRank = React.useMemo(() => { + const injected = injectedSystemColumnDefs(draft); + const order = Object.keys(injected); + const rank = new Map(); + for (const e of view.entries) { + if (isKeptOffLayout(e, systemFieldNames) || !Object.prototype.hasOwnProperty.call(injected, e.name)) continue; + const ungrouped = { ...e.def }; + delete ungrouped.group; + if (isInjectedColumnDefinition(ungrouped, injected[e.name])) rank.set(fid(e.name), order.indexOf(e.name)); + } + return rank; + }, [draft, view.entries, systemFieldNames]); + // Derive container → ordered field ids from the draft (editable fields only; // system/audit fields are preserved on write but never shown in the layout). const derived = React.useMemo(() => { @@ -676,8 +743,8 @@ export function ObjectFormDesigner({ const target = g && map[cid(g)] ? cid(g) : cid(UNGROUPED); map[target].push(fid(e.name)); } - return map; - }, [view.entries, containerOrder, systemFieldNames]); + return settlePinned(map, pinRank); + }, [view.entries, containerOrder, systemFieldNames, pinRank]); const [items, setItems] = React.useState>(derived); const [activeId, setActiveId] = React.useState(null); @@ -713,11 +780,11 @@ export function ObjectFormDesigner({ return entry ? fieldLabelOf(entry) : unFid(id); }, slotOf: (id) => slot(placeIn(items, id)), - dropSlot: (id, overId) => slot(dropPlaceIn(items, id, overId)), + dropSlot: (id, overId) => slot(dropPlaceIn(items, id, overId, pinRank)), committedSlotOf: (id) => slot(placeIn(derived, id)), }; return formDndAccessibility(locale, lookups); - }, [locale, items, derived, labelOf, entryByName, fieldLabelOf]); + }, [locale, items, derived, labelOf, entryByName, fieldLabelOf, pinRank]); // Where a keyboard drag's arrow keys have taken the field (objectui#11898), // `null` from each keyboard pick-up until its first step. Held twice: the @@ -797,33 +864,10 @@ export function ObjectFormDesigner({ const activeIdStr = String(e.active.id); const overId = e.over ? String(e.over.id) : null; if (!overId) return; - const prev = itemsRef.current; - const inContainer = (id: string): string | undefined => - id.startsWith('g:') && id in prev ? id : Object.keys(prev).find((k) => prev[k].includes(id)); - const from = inContainer(activeIdStr); - const to = inContainer(overId); - if (!from || !to) return; - let next: Record; - if (from === to) { - const list = prev[from]; - const oldIndex = list.indexOf(activeIdStr); - const newIndex = overId.startsWith('g:') ? list.length - 1 : list.indexOf(overId); - if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) { - commit(prev); - return; - } - next = { ...prev, [from]: arrayMove(list, oldIndex, newIndex) }; - } else { - const fromItems = prev[from].filter((i) => i !== activeIdStr); - const toItems = prev[to]; - const overIndex = overId.startsWith('g:') ? toItems.length : toItems.indexOf(overId); - const insertAt = overIndex < 0 ? toItems.length : overIndex; - next = { - ...prev, - [from]: fromItems, - [to]: [...toItems.slice(0, insertAt), activeIdStr, ...toItems.slice(insertAt)], - }; - } + const dropped = dropLayout(itemsRef.current, activeIdStr, overId); + if (!dropped) return; + // objectstack#27516 — never above a field the next read puts back first. + const next = settlePinned(dropped, pinRank); setItems(next); commit(next); }; diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index bae90ee21b7..4ae06dc3b74 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -4720,6 +4720,7 @@ const ar = { detailLoading: 'جارٍ تحميل التفاصيل…', detailLoadFailed: 'تعذر تحميل تفاصيل التغيير:', detailNone: 'لم يتم اكتشاف اختلافات — المسودة مطابقة للنسخة المنشورة.', + fieldOrderChanged: 'تغيّر ترتيب الحقول', // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. draftedByCopilot: 'يتضمن تغييرات صاغها مساعد الذكاء الاصطناعي', draftedByMcpClient: 'يتضمن تغييرات صاغها عميل MCP «{{client}}»', diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 5867940c73f..d7fba1febd8 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4343,6 +4343,7 @@ const de = { detailLoading: 'Details werden geladen…', detailLoadFailed: 'Änderungsdetails konnten nicht geladen werden:', detailNone: 'Keine Unterschiede erkannt — der Entwurf entspricht der veröffentlichten Version.', + fieldOrderChanged: 'Feldreihenfolge geändert', // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. draftedByCopilot: 'Enthält Änderungen, die der KI-Copilot entworfen hat', draftedByMcpClient: 'Enthält Änderungen, die der MCP-Client „{{client}}“ entworfen hat', diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 8b48e5efbce..22a772eaa43 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3799,6 +3799,7 @@ const en = { detailLoading: 'Loading detail…', detailLoadFailed: 'Could not load change detail:', detailNone: 'No differences detected — the draft matches the published version.', + fieldOrderChanged: 'Field order changed', // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. draftedByCopilot: 'Includes changes drafted by the AI copilot', draftedByMcpClient: 'Includes changes drafted by the MCP client “{{client}}”', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 84ba86a6caf..4e36d8a2a24 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4438,6 +4438,7 @@ const es = { detailLoading: 'Cargando detalles…', detailLoadFailed: 'No se pudieron cargar los detalles del cambio:', detailNone: 'No se detectaron diferencias — el borrador coincide con la versión publicada.', + fieldOrderChanged: 'Orden de los campos cambiado', // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. draftedByCopilot: 'Incluye cambios redactados por el copiloto de IA', draftedByMcpClient: 'Incluye cambios redactados por el cliente MCP «{{client}}»', diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index dd54c82eae1..297eb49e021 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4434,6 +4434,7 @@ const fr = { detailLoading: 'Chargement des détails…', detailLoadFailed: 'Impossible de charger les détails de la modification :', detailNone: 'Aucune différence détectée — le brouillon correspond à la version publiée.', + fieldOrderChanged: 'Ordre des champs modifié', // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. draftedByCopilot: 'Inclut des modifications rédigées par le copilote IA', draftedByMcpClient: 'Inclut des modifications rédigées par le client MCP « {{client}} »', diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index f3ea4d22844..3287ded7dcb 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4345,6 +4345,7 @@ const ja = { detailLoading: '詳細を読み込み中…', detailLoadFailed: '変更の詳細を読み込めませんでした:', detailNone: '差分は検出されませんでした — ドラフトは公開版と一致しています。', + fieldOrderChanged: 'フィールドの順序が変更されました', // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. draftedByCopilot: 'AI コパイロットが下書きした変更を含みます', draftedByMcpClient: 'MCP クライアント「{{client}}」が下書きした変更を含みます', diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 1ee8162c21f..e6d2719ef92 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4342,6 +4342,7 @@ const ko = { detailLoading: '상세 정보 로드 중…', detailLoadFailed: '변경 상세 정보를 불러오지 못했습니다:', detailNone: '차이가 감지되지 않았습니다 — 초안이 게시된 버전과 일치합니다.', + fieldOrderChanged: '필드 순서가 변경됨', // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. draftedByCopilot: 'AI 코파일럿이 초안으로 작성한 변경 사항 포함', draftedByMcpClient: 'MCP 클라이언트 “{{client}}”이(가) 초안으로 작성한 변경 사항 포함', diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index fe721a0f114..a00f77c3839 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4433,6 +4433,7 @@ const pt = { detailLoading: 'Carregando detalhes…', detailLoadFailed: 'Não foi possível carregar os detalhes da alteração:', detailNone: 'Nenhuma diferença detectada — o rascunho corresponde à versão publicada.', + fieldOrderChanged: 'Ordem dos campos alterada', // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. draftedByCopilot: 'Inclui alterações rascunhadas pelo copiloto de IA', draftedByMcpClient: 'Inclui alterações rascunhadas pelo cliente MCP “{{client}}”', diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 5071cfc11fe..ef89e54b190 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -4542,6 +4542,7 @@ const ru = { detailLoading: 'Загрузка деталей…', detailLoadFailed: 'Не удалось загрузить детали изменения:', detailNone: 'Различий не обнаружено — черновик совпадает с опубликованной версией.', + fieldOrderChanged: 'Порядок полей изменён', // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. draftedByCopilot: 'Включает изменения, подготовленные ИИ-копилотом', draftedByMcpClient: 'Включает изменения, подготовленные MCP-клиентом «{{client}}»', diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 283878980ba..2177c7fba96 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -3285,6 +3285,7 @@ const zh = { detailLoading: '正在加载详情…', detailLoadFailed: '无法加载变更详情:', detailNone: '未检测到差异 —— 草稿与已发布版本一致。', + fieldOrderChanged: '字段顺序已变更', // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. draftedByCopilot: '包含由 AI 副驾起草的更改', draftedByMcpClient: '包含由 MCP 客户端「{{client}}」起草的更改', From 1a77ba9934d241891420274d7b22314ae1a76950 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:37:55 +0000 Subject: [PATCH 2/3] test(studio): pin the order-only review row and the canvas's pinned platform fields Claude-Session: https://claude.ai/code/session_01Utp6CZkEeDsL4LuR4hwqTN Co-authored-by: Claude --- ...raftChangesPanel.fieldOrder-27516.test.tsx | 175 ++++++++++++++++++ ...ctFormDesigner.pinnedFields-27516.test.tsx | 163 ++++++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.fieldOrder-27516.test.tsx create mode 100644 ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.pinnedFields-27516.test.tsx diff --git a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.fieldOrder-27516.test.tsx b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.fieldOrder-27516.test.tsx new file mode 100644 index 00000000000..ad5c2777249 --- /dev/null +++ b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.fieldOrder-27516.test.tsx @@ -0,0 +1,175 @@ +/** + * 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#27516 — the review sheet reads an order-only field change as a + * change. + * + * The publish door hashes an `object`'s `fields` in their declared order + * (`canonicalize(body, 'object')`, `@objectstack/metadata-core`), so a draft + * that only reorders its fields publishes a new version. The drill-in compared + * fields by name only and read "No differences detected" for it. It now lists + * a "Field order changed" row. + * + * The order compared is the order the door stores: the columns the `/meta` + * reads inject ahead of the declared fields, and the write strips again, have + * no place of the author's, so they are left out on both sides. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { AUDIT_FIELD_DEFS, OWNER_FIELD_DEF } from '@objectstack/spec/data'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, + }), +})); + +import { DraftChangesPanel, computeChangeDetail } from '../DraftChangesPanel'; + +const NO_CHANGE = 'No differences detected — the draft matches the published version.'; +const ORDER_ROW = '~ Field order changed'; + +const condition = { type: 'select', label: 'Condition' }; +const notes = { type: 'textarea', label: 'Notes' }; +const title = { type: 'text', label: 'Title' }; +const search = { type: 'text', system: true, hidden: true }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('computeChangeDetail compares the order of an object’s fields (objectstack#27516)', () => { + it('reports a pure reorder of two authored fields, and nothing else', () => { + const d = computeChangeDetail( + { name: 'asset', fields: { title, condition, notes } }, + { name: 'asset', fields: { title, notes, condition } }, + 'object', + ); + expect(d.fields).toMatchObject({ added: [], changed: [], removed: [], reordered: true }); + expect(d.keys).toEqual({ added: [], changed: [], removed: [] }); + }); + + it('reads the array shape of `fields` in its order too', () => { + const d = computeChangeDetail( + { name: 'asset', fields: [{ name: 'title', ...title }, { name: 'condition', ...condition }] }, + { name: 'asset', fields: [{ name: 'condition', ...condition }, { name: 'title', ...title }] }, + 'object', + ); + expect(d.fields?.reordered).toBe(true); + }); + + it('CONTROL: the same order is no reorder', () => { + const d = computeChangeDetail( + { name: 'asset', fields: { title, condition } }, + { name: 'asset', fields: { title, condition } }, + 'object', + ); + expect(d.fields?.reordered).toBe(false); + }); + + it('leaves out the columns the reads inject: their place is the platform’s', () => { + // The registry-backed published read and the overlay-backed draft read put + // the platform's columns in different places; the declared order is equal. + const d = computeChangeDetail( + { + name: 'asset', + fields: { created_at: AUDIT_FIELD_DEFS.created_at, owner_id: OWNER_FIELD_DEF, title, __search: search, condition }, + }, + { + name: 'asset', + fields: { owner_id: OWNER_FIELD_DEF, title, condition, created_at: AUDIT_FIELD_DEFS.created_at, __search: search }, + }, + 'object', + ); + expect(d.fields?.reordered).toBe(false); + }); + + it('counts an `owner_id` the author declared: its place is theirs', () => { + const owner = { ...OWNER_FIELD_DEF, label: 'Account manager' }; + const d = computeChangeDetail( + { name: 'asset', fields: { owner_id: owner, title } }, + { name: 'asset', fields: { title, owner_id: owner } }, + 'object', + ); + expect(d.fields?.reordered).toBe(true); + }); + + it('compares only the fields both versions declare: an added or removed field is its own row', () => { + const d = computeChangeDetail( + { name: 'asset', fields: { title, notes, condition } }, + { name: 'asset', fields: { serial: { type: 'text' }, title, condition } }, + 'object', + ); + expect(d.fields).toMatchObject({ added: ['serial'], removed: ['notes'], reordered: false }); + }); + + it('is the publish door’s rule for an `object` only', () => { + const d = computeChangeDetail( + { name: 'asset', fields: { title, condition } }, + { name: 'asset', fields: { condition, title } }, + 'view', + ); + expect(d.fields?.reordered).toBe(false); + }); +}); + +/* ─────────────── the panel ─────────────── */ + +type Body = Record; + +function mockServer(published: Body, draft: Body): void { + global.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + if (url.includes('/_drafts')) return ok([{ type: 'object', name: 'asset', packageId: 'com.x' }]); + const [type, name] = url.split('?')[0].replace('/api/v1/meta/', '').split('/'); + if (!name) return ok([{ name: 'asset' }]); + return url.includes('state=draft') ? ok({ type, name, item: draft }) : ok(published); + }) as unknown as typeof fetch; +} + +function assetRow(): HTMLElement { + const hit = screen + .getAllByTestId('draft-entry-toggle') + .find((el) => el.querySelector('.font-mono')?.textContent === 'asset'); + if (!hit) throw new Error('no row for asset'); + return hit; +} + +describe('the drill-in lists an order-only change (objectstack#27516)', () => { + it('prints a Field order changed row and keeps the Update mark', async () => { + mockServer( + { name: 'asset', fields: { owner_id: OWNER_FIELD_DEF, title, condition, notes } }, + { name: 'asset', fields: { owner_id: OWNER_FIELD_DEF, title, notes, condition }, _draft: true }, + ); + render( {}} packageId="com.x" onPublish={vi.fn()} />); + await waitFor(() => expect(assetRow().textContent).toContain('Update')); + fireEvent.click(assetRow()); + const detail = await screen.findByTestId('draft-entry-detail'); + expect([...detail.querySelectorAll('p')].map((p) => p.textContent)).toEqual([ORDER_ROW]); + expect(screen.queryByText(NO_CHANGE)).toBeNull(); + expect(assetRow().textContent).toContain('Update'); + }); + + it('CONTROL: the same order still reads No differences detected', async () => { + mockServer( + { name: 'asset', fields: { owner_id: OWNER_FIELD_DEF, title, condition } }, + { name: 'asset', fields: { title, owner_id: OWNER_FIELD_DEF, condition }, _draft: true }, + ); + render( {}} packageId="com.x" onPublish={vi.fn()} />); + await waitFor(() => expect(assetRow().textContent).toContain('Update')); + fireEvent.click(assetRow()); + await screen.findByText(NO_CHANGE); + expect(screen.queryByText(ORDER_ROW)).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.pinnedFields-27516.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.pinnedFields-27516.test.tsx new file mode 100644 index 00000000000..619561eb903 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.pinnedFields-27516.test.tsx @@ -0,0 +1,163 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The form canvas never shows a field above one the platform puts back first — + * objectstack#27516. + * + * The defect: an author dragged Condition above Owner, the canvas showed it + * there and the draft write carried that order, but the write strips a column + * that is exactly the platform's own (`stripInjectedSystemColumns`) and every + * `/meta` read puts it back AHEAD of the declared fields + * (`applyInjectedSystemColumns`). After a reload Owner was first again. The + * canvas now settles such a field first in the ungrouped section, so what it + * shows is what the reload shows. + * + * The instrument is the one `ObjectFormDesigner.dndAnnouncements-11802.test.tsx` + * uses: the real `DndContext`, sensors and handlers, with only the collision + * detection replaced by a band geometry (the test DOM measures every box as + * zero). `reload` replays the round trip with the spec's own helpers: the + * write's strip, then the read's injection, ahead of the declared fields. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { OWNER_FIELD_DEF, injectedSystemColumnDefs, isInjectedColumnDefinition } from '@objectstack/spec/data'; + +const geometry = vi.hoisted(() => ({ bands: [] as Array })); + +vi.mock('@dnd-kit/core', async (importOriginal) => { + const actual = await importOriginal(); + const ReactMod = await import('react'); + const byBand: import('@dnd-kit/core').CollisionDetection = ({ pointerCoordinates }) => { + const id = pointerCoordinates ? (geometry.bands[Math.floor(pointerCoordinates.x / 100)] ?? null) : null; + return id ? [{ id }] : []; + }; + const GeometryDndContext = (props: Record) => + ReactMod.createElement(actual.DndContext, { ...props, collisionDetection: byBand } as never); + return { ...actual, DndContext: GeometryDndContext }; +}); + +import { ObjectFormDesigner } from './ObjectFormDesigner'; + +type Fields = Record>; + +const condition = { type: 'select', label: 'Condition' }; +const notes = { type: 'text', label: 'Notes' }; + +/** The draft as the read serves it: the platform's `owner_id` ahead of the declared fields. */ +const served = (fields: Fields) => ({ + name: 'asset', + fields, + fieldGroups: [{ key: 'details', label: 'Details' }], +}); + +/** Every `fields` the designer committed, in order. */ +let commits: Fields[] = []; + +function Host({ initial }: { initial: Record }): React.ReactElement { + const [draft, setDraft] = React.useState>(initial); + return ( + { + if (patch.fields) commits.push(patch.fields as Fields); + setDraft((d) => ({ ...d, ...patch })); + }} + onSelectField={() => {}} + /> + ); +} + +function renderDesigner(draft: Record) { + commits = []; + return render(); +} + +/** + * The draft round trip: the write strips a column that is exactly the + * platform's, the read puts every undeclared one back ahead of the declared + * fields — the order `applyInjectedSystemColumns` builds. + */ +function reload(fields: Fields): Fields { + const body = { name: 'asset', fields }; + const injected = injectedSystemColumnDefs(body); + const declared = Object.fromEntries( + Object.entries(fields).filter(([name, def]) => !(injected[name] && isInjectedColumnDefinition(def, injected[name]))), + ); + const additions = Object.fromEntries(Object.entries(injected).filter(([name]) => declared[name] === undefined)); + return { ...additions, ...declared } as Fields; +} + +afterEach(() => { + cleanup(); + geometry.bands = []; +}); + +const said = () => document.querySelector('[id^="DndLiveRegion"]')?.textContent ?? null; +const card = (label: string) => screen.getByText(label, { exact: true }).closest('.cursor-grab') as HTMLElement; +/** The card labels on the canvas, top to bottom (the cases below fill the ungrouped section only). */ +const ungroupedCards = () => + ([...document.querySelectorAll('.cursor-grab')] as HTMLElement[]).map( + (c) => c.querySelector('.truncate')?.textContent ?? '', + ); + +function pointerDrag(cardEl: HTMLElement, bands: Array): void { + geometry.bands = [null, ...bands]; + fireEvent.pointerDown(cardEl, { isPrimary: true, button: 0, clientX: 50, clientY: 10 }); + fireEvent.pointerMove(document, { isPrimary: true, clientX: 60, clientY: 10 }); + bands.forEach((_, i) => fireEvent.pointerMove(document, { isPrimary: true, clientX: (i + 1) * 100 + 50, clientY: 10 })); + fireEvent.pointerUp(document, { isPrimary: true, button: 0 }); +} + +describe('the form canvas settles the platform’s pinned fields first (objectstack#27516)', () => { + it('a field dropped above Owner lands below it, and the canvas equals the reload', () => { + renderDesigner(served({ owner_id: { ...OWNER_FIELD_DEF }, condition, notes })); + expect(ungroupedCards()).toEqual(['Owner', 'Condition', 'Notes']); + + pointerDrag(card('Condition'), ['f:owner_id']); + // The drop is announced where it lands, under Owner. + expect(said()).toBe('Condition moved to Ungrouped, position 2 of 3.'); + const shown = ungroupedCards(); + expect(shown).toEqual(['Owner', 'Condition', 'Notes']); + expect(Object.keys(commits.at(-1)!)).toEqual(['owner_id', 'condition', 'notes']); + + // After the round trip the canvas shows the same order. + cleanup(); + renderDesigner(served(reload(commits.at(-1)!))); + expect(ungroupedCards()).toEqual(shown); + }); + + it('reorders the author’s fields below Owner as before', () => { + renderDesigner(served({ owner_id: { ...OWNER_FIELD_DEF }, condition, notes })); + pointerDrag(card('Notes'), ['f:condition']); + expect(said()).toBe('Notes moved to Ungrouped, position 2 of 3.'); + expect(ungroupedCards()).toEqual(['Owner', 'Notes', 'Condition']); + + cleanup(); + renderDesigner(served(reload(commits.at(-1)!))); + expect(ungroupedCards()).toEqual(['Owner', 'Notes', 'Condition']); + }); + + it('CONTROL: an `owner_id` the author changed is theirs, and keeps the place it is given', () => { + const owner = { ...OWNER_FIELD_DEF, label: 'Account manager' }; + renderDesigner(served({ owner_id: owner, condition, notes })); + pointerDrag(card('Condition'), ['f:owner_id']); + expect(said()).toBe('Condition moved to Ungrouped, position 1 of 3.'); + expect(ungroupedCards()).toEqual(['Condition', 'Account manager', 'Notes']); + + cleanup(); + renderDesigner(served(reload(commits.at(-1)!))); + expect(ungroupedCards()).toEqual(['Condition', 'Account manager', 'Notes']); + }); + + it('Owner moved into a section carries the section and keeps its place there', () => { + renderDesigner(served({ owner_id: { ...OWNER_FIELD_DEF }, condition: { ...condition, group: 'details' }, notes })); + pointerDrag(card('Owner'), ['f:condition']); + const committed = commits.at(-1)!; + expect(committed.owner_id.group).toBe('details'); + expect(Object.keys(reload(committed))).toEqual(Object.keys(committed)); + }); +}); From 2eef9509dbf50fb79752320d60f60a2cf56e1b4c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:10:13 +0000 Subject: [PATCH 3/3] test(studio): mirror the host's kept-off names in the pinned-field canvas pins Claude-Session: https://claude.ai/code/session_01Utp6CZkEeDsL4LuR4hwqTN Co-authored-by: Claude --- ...ObjectFormDesigner.pinnedFields-27516.test.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.pinnedFields-27516.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.pinnedFields-27516.test.tsx index 619561eb903..43843c847a2 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.pinnedFields-27516.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.pinnedFields-27516.test.tsx @@ -53,6 +53,9 @@ const served = (fields: Fields) => ({ fieldGroups: [{ key: 'details', label: 'Details' }], }); +/** The audit and tenant names the Studio host keeps off the canvas (`STUDIO_SYSTEM_FIELD_NAMES`). */ +const HOST_SYSTEM_NAMES = new Set(['organization_id', 'created_at', 'created_by', 'updated_at', 'updated_by']); + /** Every `fields` the designer committed, in order. */ let commits: Fields[] = []; @@ -61,7 +64,7 @@ function Host({ initial }: { initial: Record }): React.ReactEle return ( { if (patch.fields) commits.push(patch.fields as Fields); setDraft((d) => ({ ...d, ...patch })); @@ -122,7 +125,11 @@ describe('the form canvas settles the platform’s pinned fields first (objectst expect(said()).toBe('Condition moved to Ungrouped, position 2 of 3.'); const shown = ungroupedCards(); expect(shown).toEqual(['Owner', 'Condition', 'Notes']); - expect(Object.keys(commits.at(-1)!)).toEqual(['owner_id', 'condition', 'notes']); + expect(Object.keys(commits.at(-1)!).filter((n) => !HOST_SYSTEM_NAMES.has(n))).toEqual([ + 'owner_id', + 'condition', + 'notes', + ]); // After the round trip the canvas shows the same order. cleanup(); @@ -158,6 +165,8 @@ describe('the form canvas settles the platform’s pinned fields first (objectst pointerDrag(card('Owner'), ['f:condition']); const committed = commits.at(-1)!; expect(committed.owner_id.group).toBe('details'); - expect(Object.keys(reload(committed))).toEqual(Object.keys(committed)); + const authored = (fields: Fields) => Object.keys(fields).filter((n) => ['owner_id', 'condition', 'notes'].includes(n)); + // The section's order is the author's, so the round trip keeps it. + expect(authored(reload(committed))).toEqual(authored(committed)); }); });