diff --git a/.changeset/26674-i18n-external-id.md b/.changeset/26674-i18n-external-id.md new file mode 100644 index 00000000000..d36925d26bf --- /dev/null +++ b/.changeset/26674-i18n-external-id.md @@ -0,0 +1,10 @@ +--- +'@object-ui/i18n': patch +--- + +fix(i18n): remove the External ID strings only the retired Studio switch used + +Clause-②: no +Metadata-protocol: none + +The Field Designer and the field inspector no longer offer an External ID switch (`field.externalId` is graded `dead`), so `appDesigner.fieldDesigner.externalId` is removed from all ten locales and `designer.field.externalId` from the Studio namespace (`en`, `zh`). diff --git a/.changeset/26674-platform-objects-field-form-external-id.md b/.changeset/26674-platform-objects-field-form-external-id.md new file mode 100644 index 00000000000..391bb196477 --- /dev/null +++ b/.changeset/26674-platform-objects-field-form-external-id.md @@ -0,0 +1,10 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix(platform-objects): drop the field form's `externalId` labels from the metadata-forms translations + +Clause-②: no +Metadata-protocol: none + +The field metadata form no longer offers `externalId` (the key is graded `dead`: nothing matches on it), so its label and its "Mark as external ID for upsert operations" help text are regenerated out of the `metadata-forms` bundles for every locale (`en`, `zh-CN`, `ja-JP`, `es-ES`). diff --git a/.changeset/26674-plugin-designer-external-id.md b/.changeset/26674-plugin-designer-external-id.md new file mode 100644 index 00000000000..d37647d7387 --- /dev/null +++ b/.changeset/26674-plugin-designer-external-id.md @@ -0,0 +1,10 @@ +--- +'@object-ui/plugin-designer': patch +--- + +fix(plugin-designer): the Field Designer drawer no longer offers an External ID switch, and keeps a stored `externalId` + +Clause-②: no +Metadata-protocol: none + +`field.externalId` is graded `dead`: no upsert, import or seed matches on it, so the drawer's Advanced section stops offering it. The key is not retired, so a stored value must survive: the drawer's update path used to write `externalId: data.externalId === true`, and now leaves the key to the field's own spread, so a field carrying `externalId: true` keeps it through an edit-and-save on the Metadata Fields page. A newly created field no longer carries `externalId: false`. The `appDesigner.fieldDesigner.externalId` fallback string is removed. diff --git a/.changeset/26674-spec-field-form-external-id.md b/.changeset/26674-spec-field-form-external-id.md new file mode 100644 index 00000000000..f4aa90e0221 --- /dev/null +++ b/.changeset/26674-spec-field-form-external-id.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): the field metadata form no longer offers `externalId` + +Clause-②: no +Metadata-protocol: none + +`field.externalId` is graded `dead`: no upsert, import or seed matches on it. The field form's Advanced section still offered it with the help text "Mark as external ID for upsert operations", so an author who switched it on for an import key got duplicates on re-import and an `os lint` warning on the flag the form had offered. The row is removed from `fieldForm`. The key itself is unchanged: `FieldSchema` still parses `externalId`, and a field that carries it stays valid metadata. To match records on a field, name it in a seed dataset's `externalId` or in an import mapping's `upsertKey`. diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 3f5a8fe0bdd..2a6d1856029 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -813,10 +813,6 @@ export const enMetadataForms: NonNullable = { label: "Filter", helpText: "Only child rows matching this predicate are aggregated (e.g. status == received)" }, - externalId: { - label: "External Id", - helpText: "Mark as external ID for upsert operations" - }, storage: { label: "Storage", helpText: "Physical storage constraints (ADR-0113): the DDL the write contract deliberately does not imply. Unset: none requested." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 82cceae79a0..f0614af3d1e 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -813,10 +813,6 @@ export const esESMetadataForms: NonNullable = label: "Filtro", helpText: "Solo se agregan las filas hijas que coinciden con este predicado (p. ej. status == received)" }, - externalId: { - label: "ID externo", - helpText: "Marca como ID externo para operaciones upsert" - }, storage: { label: "Almacenamiento", helpText: "Restricciones físicas de almacenamiento (ADR-0113): el DDL que el contrato de escritura deliberadamente no implica. Sin definir: no se solicita ninguna." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index a9dbbc0c6ad..416d52eda5e 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -813,10 +813,6 @@ export const jaJPMetadataForms: NonNullable = label: "フィルター", helpText: "この述語に一致する子行のみが集計されます(例: status == received)" }, - externalId: { - label: "外部 ID", - helpText: "upsert 操作用の外部 ID としてマーク" - }, storage: { label: "ストレージ制約", helpText: "物理ストレージの制約(ADR-0113)。書き込み契約があえて含めていない DDL を指定します。未設定の場合、制約は要求されません。" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 7036b1ba7e5..2110024bdef 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1238,7 +1238,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 695 since #27069: the object form's `relatedLists` row and the // `fieldGroups` repeater's `relatedList` sub-row — two row labels, // authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(695); + // 694 since #26674: the field form no longer offers `externalId` (graded + // `dead`) — one row label fewer, in all three locales. + expect(translated.length, `${locale} positive control`).toBe(694); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 8e99cc7c52c..3ea93b3c017 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -813,10 +813,6 @@ export const zhCNMetadataForms: NonNullable = label: "筛选", helpText: "仅聚合匹配该判定式的子记录(例如 status == received)" }, - externalId: { - label: "外部 ID", - helpText: "标记为外部 ID 用于 upsert 操作" - }, storage: { label: "存储约束", helpText: "物理存储约束(ADR-0113):写入契约刻意不隐含的 DDL。未设置:不要求任何约束。" diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 1049fc070ee..6b7226560c5 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -382,7 +382,7 @@ "status": "dead", "verifiedAt": "2026-10-27", "evidenceScope": "in-repo", - "evidence": "no reader: at 1c632311, outside packages/spec, nothing under packages/** or ui/** reads a field's `externalId`. The `ui/` mentions only carry it: the Studio field inspector edits it (ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx#ObjectFieldInspector), ui/packages/app-shell/src/services/MetadataService.ts copies it into a field summary, and the designer's field form edits and round-trips it (ui/packages/plugin-designer/src/FieldDesigner.tsx, ui/packages/plugin-designer/src/MetadataFieldsPage.tsx). None is a preview: no `registerMetadataPreview('field', …)` exists, and no registered preview (ui/packages/app-shell/src/views/metadata-admin/previews/, `object` included) reads `externalId`. Every upsert matches on something else: a seed dataset's `externalId` (packages/metadata-protocol/src/seed-loader.ts, `name` by default) or an import mapping's `upsertKey` (packages/rest/src/import-prepare.ts)", + "evidence": "no reader: at 1c632311, outside packages/spec, nothing under packages/** or ui/** reads a field's `externalId`. The protocol still parses it (`FieldSchema` keeps the key; not a retirement), and no Studio surface offers it (#26674): the field metadata form has no row for it (packages/spec/src/data/field.form.ts, a recorded root omission), and neither the designer's field drawer (ui/packages/plugin-designer/src/FieldDesigner.tsx) nor the field inspector (ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx#ObjectFieldInspector) has a control for it. The `ui/` mentions only carry a stored value through a save: those two surfaces, ui/packages/plugin-designer/src/MetadataFieldsPage.tsx, and ui/packages/app-shell/src/services/MetadataService.ts, which copies it into a field summary. None is a preview: no `registerMetadataPreview('field', …)` exists, and no registered preview (ui/packages/app-shell/src/views/metadata-admin/previews/, `object` included) reads `externalId`. Every upsert matches on something else: a seed dataset's `externalId` (packages/metadata-protocol/src/seed-loader.ts, `name` by default) or an import mapping's `upsertKey` (packages/rest/src/import-prepare.ts)", "note": "RE-GRADED live -> dead 2026-10-27 (#25753, follow-up F23 of #25712). The `live` grade rested on objectui console `metadataConverters.ts` (:125, commit not pinned), which objectui dc14bcb deleted for having zero importers; the re-measure above found no other reader. A boolean `default(false)`, so the author lint warns only on an authored `true`. Owed enforce-or-remove (ADR-0049).", "authorHint": "Remove it: no upsert reads a field's `externalId`. To match seed records on this field, name it in the seed dataset's `externalId`; for an import, in the mapping's `upsertKey`." }, diff --git a/packages/spec/src/data/field-form-external-id-not-offered.test.ts b/packages/spec/src/data/field-form-external-id-not-offered.test.ts new file mode 100644 index 00000000000..dd35fcd3ee7 --- /dev/null +++ b/packages/spec/src/data/field-form-external-id-not-offered.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #26674 — the field form must not OFFER `field.externalId`. + * + * The key is graded `dead` in `liveness/field.json`: no upsert, import or seed + * matches on it. The form's row still read "Mark as external ID for upsert + * operations", so a Studio author who switched it on for an import key got + * duplicates on re-import and an `os lint` warning on the flag the form had + * offered. The row is gone; the key is NOT retired — `FieldSchema` keeps + * parsing it, so a stored `externalId: true` stays valid metadata and every + * Studio writer carries it through untouched. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { fieldForm } from './field.form'; +import { FieldSchema } from './field.zod'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +type FormEntry = { field?: string; helpText?: string; fields?: FormEntry[] }; + +/** Every entry of the form, nested composite / list members included. */ +function allEntries(): FormEntry[] { + const out: FormEntry[] = []; + const walk = (entries: FormEntry[] | undefined) => { + for (const e of entries ?? []) { + out.push(e); + walk(e.fields); + } + }; + for (const section of (fieldForm as { sections: { fields?: FormEntry[] }[] }).sections) { + walk(section.fields); + } + return out; +} + +describe('field form: externalId is not offered', () => { + it('declares no externalId row at any depth', () => { + const entries = allEntries(); + // Positive control: the walk does reach the advanced section's rows. + expect(entries.some((e) => e.field === 'storage')).toBe(true); + expect(entries.filter((e) => e.field === 'externalId')).toEqual([]); + }); + + it('no help text still calls a field flag an upsert key', () => { + const offenders = allEntries().filter((e) => /external id for upsert/i.test(e.helpText ?? '')); + expect(offenders).toEqual([]); + }); + + it('the key itself is kept: FieldSchema still parses an authored externalId', () => { + const parsed = FieldSchema.safeParse({ type: 'text', label: 'Code', externalId: true }); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.externalId).toBe(true); + }); + + it('the liveness row still grades field.externalId dead', () => { + const liveness = JSON.parse(readFileSync(resolve(HERE, '../../liveness/field.json'), 'utf8')) as { + props: Record; + }; + expect(liveness.props.externalId?.status).toBe('dead'); + }); +}); diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index c2f21f71e9a..b1fdf31228b 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -390,7 +390,11 @@ export const fieldForm = defineForm({ columns: 2, fields: [ // Database & Performance - { field: 'externalId', colSpan: 1, helpText: 'Mark as external ID for upsert operations' }, + // No `externalId` row (#26674). `field.externalId` is graded `dead` + // (liveness/field.json): no upsert, import or seed reads it, so a + // switch here only invited an author to rely on it. The schema still + // parses the key; upsert matching lives on a seed dataset's + // `externalId` and an import mapping's `upsertKey`. // #19332 G1b — `storage` is a strict object with one boolean, so it takes // the same declared-composite face as `currencyConfig` above. The one // contradiction the schema refuses (`notNull` beside `requiredWhen`) is diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 8d23c62903d..107043d87cc 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -586,6 +586,13 @@ const LEDGER: ReadonlyArray = [ key: 'relatedList', why: "no registered widget spells this union (ruling record 5861442317): `true` / `false` or `'primary'`, and a boolean cannot be an option value while a union renders only its first arm on create, so a control would be a switch that never promotes the list to its own tab (`'primary'`)", }, + { + kind: 'omit', + type: 'field', + path: ROOT_PATH, + key: 'externalId', + why: "graded `dead` (`liveness/field.json`): no upsert, import or seed matches on a field's `externalId`, so a control would invite an author to rely on a flag nothing reads, and `os lint` then warns on the value the form offered. The schema still parses the key, so a stored value stays valid. A seed dataset names its match field in its own `externalId`, an import mapping in its `upsertKey`", + }, // Measured, and deliberately NOT recorded: seven `view` keys with no // liveness verdict at any coordinate. `liveness/view.json` cannot hold them: diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.externalIdNotOffered-26674.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.externalIdNotOffered-26674.test.tsx new file mode 100644 index 00000000000..d981cee70f7 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.externalIdNotOffered-26674.test.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26674 — the inspector no longer offers an `External ID` switch, + * and a field that already carries `externalId: true` keeps it. + * + * `field.externalId` is graded `dead` (`packages/spec/liveness/field.json`): + * no upsert, import or seed matches on it, so the switch only invited an + * author to rely on it. The key is NOT retired — `FieldSchema` still parses + * it — so removing the control must never strip a stored value: the write + * path (`{ ...def, ...patch }`) carries it through every edit. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; + +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => ({ + list: vi.fn().mockResolvedValue([]), + listDrafts: vi.fn().mockResolvedValue([]), + }), +})); + +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); + +import { ObjectFieldInspector } from './ObjectFieldInspector'; + +afterEach(cleanup); + +function renderField(fields: Record>, selectedId: string) { + const onPatch = vi.fn(); + render( + , + ); + /** The fields map the host's Save would persist after the last edit. */ + const savedFields = () => + onPatch.mock.calls.at(-1)![0].fields as Record>; + return { onPatch, savedFields }; +} + +describe('ObjectFieldInspector · no External ID switch (objectstack#26674)', () => { + it('renders no External ID control, while its ADVANCED siblings still render', () => { + renderField({ code: { type: 'text', label: 'Code' } }, 'code'); + + expect(screen.queryByText('External ID')).toBeNull(); + expect(screen.getByText('Read-only')).toBeInTheDocument(); + expect(screen.getByText('Hidden')).toBeInTheDocument(); + expect(screen.getByText('Track history')).toBeInTheDocument(); + }); + + it('a stored externalId does not resurrect the control', () => { + renderField({ code: { type: 'text', label: 'Code', externalId: true } }, 'code'); + expect(screen.queryByText('External ID')).toBeNull(); + }); + + it('an edit-and-save round-trip keeps a stored externalId: true', () => { + const { savedFields } = renderField( + { code: { type: 'text', label: 'Code', externalId: true } }, + 'code', + ); + + fireEvent.change(screen.getByTestId('field-label-input'), { target: { value: 'Code x' } }); + + const fields = savedFields(); + // Falsification: the edit itself landed. + expect(fields.code.label).toBe('Code x'); + expect(fields.code.externalId).toBe(true); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.retiredIndexed.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.retiredIndexed.test.tsx index 9109591d5af..c82db2054c8 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.retiredIndexed.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.retiredIndexed.test.tsx @@ -72,7 +72,6 @@ describe('ObjectFieldInspector · the Indexed toggle is retired (objectui#4644)' // targeted retirement, not a section that failed to render. expect(screen.getByText('Read-only')).toBeInTheDocument(); expect(screen.getByText('Hidden')).toBeInTheDocument(); - expect(screen.getByText('External ID')).toBeInTheDocument(); expect(screen.getByText('Track history')).toBeInTheDocument(); }); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index eec70145449..5f6eb948161 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -9,7 +9,7 @@ * • Basic — name (rename), label, type, required, unique, description * • Specific — picklist options / lookup target / formula / numeric * precision / max length / value domain, conditional on type - * • Advanced — readonly, hidden, externalId, group + * • Advanced — readonly, hidden, group * * All edits are applied as immutable splices of `draft.fields` via * the object-fields-io helpers, preserving the original array-vs-record @@ -27,6 +27,11 @@ * `indexed` by name, and offering it only ever produced a 422 that blocked * the save. Object-level `indexes[]` is the real surface. * + * Nor is there an `External ID` switch (objectstack#26674): `field.externalId` + * is graded `dead` — no upsert, import or seed matches on it — so offering it + * only invited an author to rely on it. `patchDef` spreads the whole stored + * definition, so a stored `externalId: true` survives every edit here. + * * Nor is there a `Relationship name` control on a lookup-family field * (objectui#12758): it wrote `relationshipName`, a key `FieldSchema` never * declared under any spelling, so typing into it put the field in the spec @@ -1351,12 +1356,6 @@ export function ObjectFieldInspector({ onCommit={(v) => patchDef({ hidden: v || undefined })} disabled={readOnly} /> - patchDef({ externalId: v || undefined })} - disabled={readOnly} - /> = { 'designer.field.description': 'Description', 'designer.field.readonly': 'Read-only', 'designer.field.hidden': 'Hidden', - 'designer.field.externalId': 'External ID', 'designer.field.trackHistory': 'Track history', 'designer.field.placeholder': 'Placeholder', 'designer.field.group': 'Group', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 41f8aac5d6a..97fe06ac50c 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -270,7 +270,6 @@ const STUDIO_ZH: Record = { 'designer.field.description': '描述', 'designer.field.readonly': '只读', 'designer.field.hidden': '隐藏', - 'designer.field.externalId': '外部 ID', 'designer.field.trackHistory': '记录历史', 'designer.field.placeholder': '占位符', 'designer.field.group': '分组', diff --git a/ui/packages/plugin-designer/src/FieldDesigner.tsx b/ui/packages/plugin-designer/src/FieldDesigner.tsx index 7d29c802306..a0985e1770f 100644 --- a/ui/packages/plugin-designer/src/FieldDesigner.tsx +++ b/ui/packages/plugin-designer/src/FieldDesigner.tsx @@ -340,7 +340,9 @@ export function FieldDesigner({ unique: data.unique === true, readonly: data.readonly === true, hidden: data.hidden === true, - externalId: data.externalId === true, + // No `externalId` here (objectstack#26674): the drawer no longer + // offers it, so the spread above carries a stored value through an + // edit untouched. trackHistory: data.trackHistory === true, defaultValue: data.defaultValue ? String(data.defaultValue) : undefined, placeholder: data.placeholder ? String(data.placeholder) : undefined, @@ -360,7 +362,6 @@ export function FieldDesigner({ unique: data.unique === true, readonly: data.readonly === true, hidden: data.hidden === true, - externalId: data.externalId === true, trackHistory: data.trackHistory === true, defaultValue: data.defaultValue ? String(data.defaultValue) : undefined, placeholder: data.placeholder ? String(data.placeholder) : undefined, @@ -473,7 +474,11 @@ export function FieldDesigner({ // index flag — `FieldSchema.safeParse` rejects `indexed` by name, so // ticking it only ever produced a 422 on save. Object-level // `indexes[]` is the real surface. - { name: 'externalId', label: t('appDesigner.fieldDesigner.externalId'), type: 'boolean', disabled: readOnly }, + // No `externalId` control (objectstack#26674): `field.externalId` + // is graded `dead` — no upsert, import or seed matches on it — so + // the switch only invited an author to rely on it. A stored value + // is carried through an edit by `handleFormSuccess`'s spread, never + // stripped. { name: 'trackHistory', label: t('appDesigner.fieldDesigner.trackHistory'), type: 'boolean', disabled: readOnly }, { name: 'readonly', label: t('appDesigner.fieldDesigner.readOnly'), type: 'boolean', disabled: readOnly }, { name: 'hidden', label: t('appDesigner.fieldDesigner.hidden'), type: 'boolean', disabled: readOnly }, @@ -491,7 +496,6 @@ export function FieldDesigner({ unique: editingField.unique || false, readonly: editingField.readonly || false, hidden: editingField.hidden || false, - externalId: editingField.externalId || false, trackHistory: editingField.trackHistory || false, defaultValue: editingField.defaultValue != null ? String(editingField.defaultValue) : '', placeholder: editingField.placeholder || '', diff --git a/ui/packages/plugin-designer/src/MetadataFieldsPage.externalIdRoundTrip-26674.test.tsx b/ui/packages/plugin-designer/src/MetadataFieldsPage.externalIdRoundTrip-26674.test.tsx new file mode 100644 index 00000000000..dc9f2225aea --- /dev/null +++ b/ui/packages/plugin-designer/src/MetadataFieldsPage.externalIdRoundTrip-26674.test.tsx @@ -0,0 +1,132 @@ +/** + * 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#26674 — a stored `externalId: true` survives a Studio + * edit-and-save through this page, now that the designer drawer no longer + * offers the switch. + * + * `field.externalId` is graded `dead` but NOT retired: `FieldSchema` still + * parses it, so removing the control must never strip a stored value. This + * runs the REAL `FieldDesigner` (only the grid and form renderers are mocked) + * over a REAL `MetadataClient` and a fetch double, so the assertion is on the + * PUT bytes after the drawer's own update path ran — the path that wrote + * `externalId: data.externalId === true` before this card. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { FieldSchema } from '@objectstack/spec/data'; +import { MetadataClient } from '@object-ui/data-objectstack'; + +vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ + ...(await importOriginal>()), + ...(await import('./__tests__/__mocks__/plugin-grid')), +})); +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ...(await import('./__tests__/__mocks__/plugin-form')), +})); + +import { MetadataFieldsPage } from './MetadataFieldsPage'; + +/** + * `code` is the field the author edits; `sku` carries the flag on a field the + * author never opens; `title` is the control with no flag at all. + */ +const OBJECT_BODY = { + name: 'probe_widget', + label: 'Widget', + fields: { + code: { type: 'text', label: 'Code', externalId: true }, + sku: { type: 'text', label: 'SKU', externalId: true }, + title: { type: 'text', label: 'Title' }, + }, +}; + +const OBJECT_ENVELOPE = { + type: 'object', + name: 'probe_widget', + item: OBJECT_BODY, + lock: 'none', + provenance: 'org', + editable: true, +}; + +let puts: Array> = []; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function realClient(): MetadataClient { + return new MetadataClient({ + baseUrl: 'http://localhost:3000', + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? 'GET').toUpperCase(); + if (method === 'PUT') { + puts.push(JSON.parse(String(init?.body ?? '{}')) as Record); + return json({ success: true, name: 'probe_widget' }); + } + if (/\/meta\/object\/probe_widget(\?|$)/.test(url)) return json(OBJECT_ENVELOPE); + return json({ items: [] }); + }) as unknown as typeof fetch, + }); +} + +/** The fields map exactly as it went over the wire on the last PUT. */ +function savedFields(): Record> { + return puts[puts.length - 1].fields as Record>; +} + +beforeEach(() => { + puts = []; +}); + +afterEach(cleanup); + +describe('objectstack#26674 · a stored externalId survives an edit-and-save', () => { + it('keeps `externalId: true` on the edited field and on an untouched one', async () => { + render(); + + fireEvent.click(await screen.findByTestId('grid-edit-code')); + await waitFor(() => expect(screen.getByTestId('mock-drawer-form')).toBeTruthy()); + // The drawer no longer offers the switch it would otherwise have reset. + expect(screen.getByTestId('drawer-field-trackHistory')).toBeTruthy(); + expect(screen.queryByTestId('drawer-field-externalId')).toBeNull(); + + fireEvent.click(screen.getByTestId('drawer-submit')); + await waitFor(() => expect(puts).toHaveLength(1)); + + const fields = savedFields(); + expect(fields.code.externalId).toBe(true); + expect(fields.sku.externalId).toBe(true); + // Control: a field that never carried the flag does not gain one. + expect(fields.title.externalId).toBeUndefined(); + // Falsification: the save re-serialised the edited field intact. + expect(fields.code.label).toBe('Code'); + expect(fields.code.type).toBe('text'); + }); + + it('every field it PUTs still parses through the real FieldSchema', async () => { + render(); + + fireEvent.click(await screen.findByTestId('grid-edit-code')); + await waitFor(() => expect(screen.getByTestId('mock-drawer-form')).toBeTruthy()); + fireEvent.click(screen.getByTestId('drawer-submit')); + await waitFor(() => expect(puts).toHaveLength(1)); + + for (const [name, def] of Object.entries(savedFields())) { + expect(FieldSchema.safeParse(def).success, `field \`${name}\` did not parse`).toBe(true); + } + }); +}); diff --git a/ui/packages/plugin-designer/src/__tests__/FieldDesigner.externalIdNotOffered-26674.test.tsx b/ui/packages/plugin-designer/src/__tests__/FieldDesigner.externalIdNotOffered-26674.test.tsx new file mode 100644 index 00000000000..2879bc4bcd8 --- /dev/null +++ b/ui/packages/plugin-designer/src/__tests__/FieldDesigner.externalIdNotOffered-26674.test.tsx @@ -0,0 +1,109 @@ +/** + * 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#26674 — the Field Designer drawer offers no `External ID` + * switch, and a field already carrying `externalId: true` keeps it. + * + * `field.externalId` is graded `dead` (`packages/spec/liveness/field.json`): + * no upsert, import or seed matches on it. The key is NOT retired — + * `FieldSchema` still parses it — so removing the control must never strip a + * stored value. Before this card the update path wrote + * `externalId: data.externalId === true`; with the control gone that line + * would have turned every stored `true` into `false` on the next save. + * + * Each absence is paired with a positive control from the same section and + * testid channel, so a drawer that never rendered cannot pass as a result. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import type { DesignerFieldDefinition } from '@object-ui/types'; +import { FieldDesigner } from '../FieldDesigner'; + +vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ + ...(await importOriginal>()), + ...(await import('./__mocks__/plugin-grid')), +})); +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ...(await import('./__mocks__/plugin-form')), +})); + +const FIELDS: DesignerFieldDefinition[] = [ + { id: 'code', name: 'code', label: 'Code', type: 'text', externalId: true }, + { id: 'title', name: 'title', label: 'Title', type: 'text' }, +]; + +afterEach(cleanup); + +function renderDesigner(onFieldsChange?: (f: DesignerFieldDefinition[]) => void) { + render(); +} + +async function openEditDrawer(fieldName: string) { + const row = await screen.findByTestId(`grid-edit-${fieldName}`); + fireEvent.click(row); + await waitFor(() => expect(screen.getByTestId('mock-drawer-form')).toBeTruthy()); +} + +describe('objectstack#26674 · the drawer offers no External ID switch', () => { + it('renders no `externalId` field in the create drawer', async () => { + renderDesigner(); + fireEvent.click(screen.getByTestId('grid-add-btn')); + await waitFor(() => expect(screen.getByTestId('mock-drawer-form')).toBeTruthy()); + + // POSITIVE CONTROL: a sibling of the same ADVANCED section. + expect(screen.getByTestId('drawer-field-trackHistory')).toBeTruthy(); + expect(screen.queryByTestId('drawer-field-externalId')).toBeNull(); + }); + + it('renders no `externalId` field when editing a field that carries it', async () => { + renderDesigner(); + await openEditDrawer('code'); + + expect(screen.getByTestId('drawer-mode').textContent).toBe('edit'); + expect(screen.getByTestId('drawer-field-trackHistory')).toBeTruthy(); + expect(screen.queryByTestId('drawer-field-externalId')).toBeNull(); + }); +}); + +describe('objectstack#26674 · a stored externalId survives an edit-and-save', () => { + it('keeps `externalId: true` through the update path', async () => { + // The mock submits `schema.initialValues` back into the real + // `handleFormSuccess`, so this exercises the edit seed and the update + // write path together. + const changed = vi.fn(); + renderDesigner(changed); + await openEditDrawer('code'); + fireEvent.click(screen.getByTestId('drawer-submit')); + + await waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + const emitted: DesignerFieldDefinition[] = changed.mock.calls[0][0]; + const code = emitted.find((f) => f.name === 'code')!; + + expect(code.externalId).toBe(true); + // Falsification: the round-trip happened and kept the field's identity. + expect(code.label).toBe('Code'); + expect(code.type).toBe('text'); + }); + + it('a created field carries no externalId key at all', async () => { + const changed = vi.fn(); + renderDesigner(changed); + fireEvent.click(screen.getByTestId('grid-add-btn')); + await waitFor(() => expect(screen.getByTestId('mock-drawer-form')).toBeTruthy()); + fireEvent.click(screen.getByTestId('drawer-submit')); + + await waitFor(() => expect(changed).toHaveBeenCalledTimes(1)); + const emitted: DesignerFieldDefinition[] = changed.mock.calls[0][0]; + expect(emitted).toHaveLength(FIELDS.length + 1); + expect('externalId' in emitted[emitted.length - 1]).toBe(false); + }); +}); diff --git a/ui/packages/plugin-designer/src/hooks/useDesignerTranslation.ts b/ui/packages/plugin-designer/src/hooks/useDesignerTranslation.ts index 7acf72c1f32..dc96e0b435f 100644 --- a/ui/packages/plugin-designer/src/hooks/useDesignerTranslation.ts +++ b/ui/packages/plugin-designer/src/hooks/useDesignerTranslation.ts @@ -182,7 +182,6 @@ export const DESIGNER_DEFAULT_TRANSLATIONS: Record = { 'appDesigner.fieldDesigner.unique': 'Unique', 'appDesigner.fieldDesigner.readOnly': 'Read Only', 'appDesigner.fieldDesigner.hidden': 'Hidden', - 'appDesigner.fieldDesigner.externalId': 'External ID', 'appDesigner.fieldDesigner.trackHistory': 'Track History', 'appDesigner.fieldDesigner.defaultValue': 'Default Value', 'appDesigner.fieldDesigner.placeholder': 'Placeholder',