Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/26674-i18n-external-id.md
Original file line number Diff line number Diff line change
@@ -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`).
10 changes: 10 additions & 0 deletions .changeset/26674-platform-objects-field-form-external-id.md
Original file line number Diff line number Diff line change
@@ -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`).
10 changes: 10 additions & 0 deletions .changeset/26674-plugin-designer-external-id.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions .changeset/26674-spec-field-form-external-id.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
Expand Up @@ -813,10 +813,6 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -813,10 +813,6 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -813,10 +813,6 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "フィルター",
helpText: "この述語に一致する子行のみが集計されます(例: status == received)"
},
externalId: {
label: "外部 ID",
helpText: "upsert 操作用の外部 ID としてマーク"
},
storage: {
label: "ストレージ制約",
helpText: "物理ストレージの制約(ADR-0113)。書き込み契約があえて含めていない DDL を指定します。未設定の場合、制約は要求されません。"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -813,10 +813,6 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "筛选",
helpText: "仅聚合匹配该判定式的子记录(例如 status == received)"
},
externalId: {
label: "外部 ID",
helpText: "标记为外部 ID 用于 upsert 操作"
},
storage: {
label: "存储约束",
helpText: "物理存储约束(ADR-0113):写入契约刻意不隐含的 DDL。未设置:不要求任何约束。"
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/field.json
Original file line number Diff line number Diff line change
Expand Up @@ -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`."
},
Expand Down
68 changes: 68 additions & 0 deletions packages/spec/src/data/field-form-external-id-not-offered.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { status: string }>;
};
expect(liveness.props.externalId?.status).toBe('dead');
});
});
6 changes: 5 additions & 1 deletion packages/spec/src/data/field.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,13 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>>, selectedId: string) {
const onPatch = vi.fn();
render(
<ObjectFieldInspector
type="object"
name="probe_widget"
draft={{ name: 'probe_widget', fields }}
selection={{ kind: 'field', id: selectedId }}
onPatch={onPatch}
onClearSelection={vi.fn()}
onSelectionChange={vi.fn()}
readOnly={false}
locale={'en-US'}
/>,
);
/** The fields map the host's Save would persist after the last edit. */
const savedFields = () =>
onPatch.mock.calls.at(-1)![0].fields as Record<string, Record<string, unknown>>;
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -1351,12 +1356,6 @@ export function ObjectFieldInspector({
onCommit={(v) => patchDef({ hidden: v || undefined })}
disabled={readOnly}
/>
<InspectorCheckboxField
label={tr('designer.field.externalId')}
value={!!def.externalId}
onCommit={(v) => patchDef({ externalId: v || undefined })}
disabled={readOnly}
/>
<InspectorCheckboxField
label={tr('designer.field.trackHistory')}
value={!!def.trackHistory}
Expand Down
1 change: 0 additions & 1 deletion ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1827,7 +1827,6 @@ const ar = {
unique: "فريد",
readOnly: "للقراءة فقط",
hidden: "مخفي",
externalId: "معرف خارجي",
trackHistory: "تتبع السجل",
defaultValue: "القيمة الافتراضية",
placeholder: "نص تلميحي",
Expand Down
1 change: 0 additions & 1 deletion ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1652,7 +1652,6 @@ const de = {
unique: "Eindeutig",
readOnly: "Nur lesen",
hidden: "Ausgeblendet",
externalId: "Externe ID",
trackHistory: "Verlauf verfolgen",
defaultValue: "Standardwert",
placeholder: "Platzhalter",
Expand Down
1 change: 0 additions & 1 deletion ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2033,7 +2033,6 @@ const en = {
unique: 'Unique',
readOnly: 'Read Only',
hidden: 'Hidden',
externalId: 'External ID',
trackHistory: 'Track History',
defaultValue: 'Default Value',
placeholder: 'Placeholder',
Expand Down
1 change: 0 additions & 1 deletion ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1698,7 +1698,6 @@ const es = {
unique: "Único",
readOnly: "Solo lectura",
hidden: "Oculto",
externalId: "ID externo",
trackHistory: "Rastrear historial",
defaultValue: "Valor predeterminado",
placeholder: "Marcador de posición",
Expand Down
1 change: 0 additions & 1 deletion ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1694,7 +1694,6 @@ const fr = {
unique: "Unique",
readOnly: "Lecture seule",
hidden: "Masqué",
externalId: "ID externe",
trackHistory: "Suivre l'historique",
defaultValue: "Valeur par défaut",
placeholder: "Espace réservé",
Expand Down
1 change: 0 additions & 1 deletion ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1652,7 +1652,6 @@ const ja = {
unique: "ユニーク",
readOnly: "読み取り専用",
hidden: "非表示",
externalId: "外部ID",
trackHistory: "履歴追跡",
defaultValue: "デフォルト値",
placeholder: "プレースホルダー",
Expand Down
1 change: 0 additions & 1 deletion ui/packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1652,7 +1652,6 @@ const ko = {
unique: "고유",
readOnly: "읽기 전용",
hidden: "숨김",
externalId: "외부 ID",
trackHistory: "기록 추적",
defaultValue: "기본값",
placeholder: "자리 표시자",
Expand Down
Loading
Loading