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/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..43843c847a2 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.pinnedFields-27516.test.tsx @@ -0,0 +1,172 @@ +// 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' }], +}); + +/** 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[] = []; + +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)!).filter((n) => !HOST_SYSTEM_NAMES.has(n))).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'); + 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)); + }); +}); 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 5c0f1f6468f..14c4349b5ac 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -4727,6 +4727,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 70c9cc22396..d67c7fb9b5f 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -4350,6 +4350,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 9147738676f..5749bad1fab 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3801,6 +3801,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 683eb738c17..470cb80bde4 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -4445,6 +4445,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 c34ee34d777..18ff624e9fd 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -4441,6 +4441,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 320d0ecb0d2..818661db421 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -4352,6 +4352,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 ed43ffd6e2d..e46629bb011 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -4349,6 +4349,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 a77d65b4be1..1493b3c8e07 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -4440,6 +4440,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 aac45ecd2e6..5cdbcae46ed 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -4549,6 +4549,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 797f41156f1..872929af657 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -3287,6 +3287,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}}」起草的更改',