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
14 changes: 14 additions & 0 deletions .changeset/21765-object-image-field-form-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': patch
'@objectstack/platform-objects': patch
---

Studio's object form offers `imageField`, the record's picture, as a text row beside `nameField`

Clause-②: no

The object form in the metadata form registry now has an `imageField` row, a plain text input placed beside `nameField`. Until now the only way to set the record picture from Studio was the Source tab's raw JSON. The help text says what the parse accepts: a field of this object whose type is `image` or `avatar`. Left empty, the object has no record picture and no placeholder is drawn. The row brings no picker and no validator of its own. A name that is not an `image` / `avatar` field of the object is refused when the object is saved, by the same parse rule as before.

`@objectstack/platform-objects` ships the row's label and help text in its metadata-form translation catalogs, translated for `zh-CN`, `ja-JP` and `es-ES`.

No schema key, accept set, refusal, error code or status changes, and you have nothing to re-author.
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Name Field",
helpText: "Field whose value titles each record (e.g. \"name\", \"subject\"). ADR-0079 canonical pointer — read by record display, ObjectQL search and related-record previews."
},
imageField: {
label: "Image Field",
helpText: "Field whose value is each record's picture, drawn beside the title in the record page header. Must name a field of this object whose type is image or avatar; any other name is refused when the object is saved. Empty: no record picture, and no placeholder is drawn."
},
isSystem: {
label: "Is System",
helpText: "System object (protected from deletion; defaults sharing to public)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "Campo de título",
helpText: "Campo cuyo valor titula cada registro (p. ej. \"name\", \"subject\"). Puntero canónico de ADR-0079: lo leen la presentación del registro, la búsqueda de ObjectQL y las vistas previas de registros relacionados."
},
imageField: {
label: "Campo de imagen",
helpText: "Campo cuyo valor es la imagen de cada registro, mostrada junto al título en el encabezado de la página del registro. Debe nombrar un campo de este objeto de tipo image o avatar; cualquier otro nombre se rechaza al guardar el objeto. Vacío: sin imagen del registro y sin marcador de posición."
},
isSystem: {
label: "Integrado del sistema",
helpText: "Objeto de sistema (protegido contra eliminación; el uso compartido predeterminado es público)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "レコードタイトル項目",
helpText: "各レコードのタイトルに使う項目(例: \"name\"、\"subject\")。ADR-0079 の正規ポインタで、レコード表示・ObjectQL 検索・関連レコードのプレビューが参照します。"
},
imageField: {
label: "レコード画像項目",
helpText: "各レコードの画像に使う項目で、レコードページのヘッダーでタイトルの横に表示されます。このオブジェクトの image 型または avatar 型の項目を指定してください。それ以外の名前はオブジェクトの保存時に拒否されます。空欄の場合、レコード画像は表示されず、プレースホルダーも描画されません。"
},
isSystem: {
label: "システム組み込み",
helpText: "システムオブジェクト(削除から保護。共有の既定は公開)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -938,7 +938,10 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape',
// 114 since #19332 flight G2a gave `basics` the `fieldGroups` repeater — the
// row and its six declared sub-rows, fourteen leaves, their forty-two
// translated leaves authored by that flight, so `openEchoes` stays at zero.
expect(OPEN_LEAVES.length).toBe(114);
// 116 since #21765 gave `basics` the `imageField` row beside `nameField` —
// the record-picture pointer, now live — whose label and help text are
// authored in all three locales by that card, so `openEchoes` stays at zero.
expect(OPEN_LEAVES.length).toBe(116);
expect(PANEL_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(false);
expect(OPEN_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(true);
for (const path of ['name', 'label', 'fields', 'fields.valueDomain', 'fields.deleteBehavior', 'fields.expression']) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1167,7 +1167,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
// 659 since #21320: the agent form's `lifecycle` row left with its key
// (the conversation state machine was retired — nothing ever read it),
// taking its label — authored in all three locales — out of the catalog.
expect(translated.length, `${locale} positive control`).toBe(659);
// 660 since #21765: the object form offers `imageField` beside
// `nameField` — one new row label, authored in all three locales.
expect(translated.length, `${locale} positive control`).toBe(660);
}
// ⭐ 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 @@ -60,6 +60,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "记录标题字段",
helpText: "用作每条记录标题的字段(例如 \"name\"、\"subject\")。ADR-0079 规定的规范指针——记录展示、ObjectQL 搜索和关联记录预览都读它。"
},
imageField: {
label: "记录图片字段",
helpText: "用作每条记录图片的字段,显示在记录页头的标题旁。必须是本对象中类型为 image 或 avatar 的字段,其他名称会在保存对象时被拒绝。留空则不显示记录图片,也不画占位图。"
},
isSystem: {
label: "系统内置",
helpText: "系统对象(受保护,不可删除;共享默认为公开)"
Expand Down
10 changes: 10 additions & 0 deletions packages/spec/src/data/object.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ export const objectForm = defineForm({
// takes its candidates from a `dependsOn` source row, which a top-level
// object row has nothing to point at).
{ field: 'nameField', type: 'text', colSpan: 1, helpText: 'Field whose value titles each record (e.g. "name", "subject"). ADR-0079 canonical pointer — read by record display, ObjectQL search and related-record previews.' },
// #21765 (director ruling 5989738766, letter A) — `imageField`, the
// record's picture (#21182 ruling A), is the sibling pointer of
// `nameField` and gets the same face, for the same reason: it names one
// of THIS object's own fields and the registry has no own-field picker.
// No validator here either. The parse (`refuseNonPictureImageField` in
// `object.zod.ts`) is the one judge of the value, and its refusal at
// save is what an author sees for a name that is not an `image` /
// `avatar` field of this object. A cleared input writes no key, which
// the parse reads as no picture.
{ field: 'imageField', type: 'text', colSpan: 1, helpText: 'Field whose value is each record\'s picture, drawn beside the title in the record page header. Must name a field of this object whose type is image or avatar; any other name is refused when the object is saved. Empty: no record picture, and no placeholder is drawn.' },
{ field: 'isSystem', type: 'boolean', colSpan: 1, helpText: 'System object (protected from deletion; defaults sharing to public)' },
// #20349 — the object's two own field-name LISTS, beside the `nameField`
// pointer. Free text, the `view.form.ts` `searchableFields` row's face:
Expand Down
50 changes: 50 additions & 0 deletions packages/spec/src/system/metadata-form-declared-rows.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type FormEntry = {
field?: string;
widget?: string;
type?: string;
colSpan?: number;
language?: string;
helpText?: string;
visibleWhen?: string | { dialect?: string; source?: string };
Expand Down Expand Up @@ -170,6 +171,55 @@ describe('declared keys that now have a form row (#19085)', () => {
});
});

/**
* **`object.imageField` is offered with `nameField`'s face, beside it** (#21765,
* director ruling 5989738766, letter A).
*
* The record's picture pointer is `nameField`'s sibling: both name one of the
* object's own fields, and the registry has no own-field picker. So the ruling
* gives it the same plain `text` row, with no widget and no validator of its
* own — the parse (`refuseNonPictureImageField`) stays the one judge of the
* value. The reconciliation gate only proves the key is offered somewhere on
* the form; it does not read the row's control or where it sits, so this pin
* keeps both.
*/
describe('object.imageField is offered as a plain text row beside nameField, with the parse as the one judge of its value', () => {
/** The section holding `key` on the object form, and the key's index in it. */
const locate = (key: string) => {
const sections = ((METADATA_FORM_REGISTRY.object as any)?.sections ?? []) as Array<{
name?: string;
fields?: FormEntry[];
}>;
for (const section of sections) {
const index = (section.fields ?? []).findIndex((r) => r?.field === key);
if (index >= 0) return { section: section.name, index, rows: section.fields! };
}
return undefined;
};

it('CONTROL: the locator finds nameField and reports a known absence', () => {
expect(locate('nameField'), 'lit control: the ADR-0079 title pointer row').toBeDefined();
expect(locate('noSuchKeyAtAll'), 'dark control: a key the object form does not offer').toBeUndefined();
});

it('is offered once, as a `text` row with no widget', () => {
const [row, ...extra] = rowFor('object', 'imageField');
expect(row, '`imageField` is declared by ObjectSchema and live; the object form must offer it').toBeDefined();
expect(extra, 'one row only').toEqual([]);
expect(row.type).toBe('text');
expect(row.widget, 'no picker: the parse is the one judge of the value').toBeUndefined();
expect(row.helpText?.length ?? 0).toBeGreaterThan(20);
});

it('sits directly after nameField, in the same section and at the same width', () => {
const name = locate('nameField')!;
const image = locate('imageField');
expect(image?.section).toBe(name.section);
expect(image?.index).toBe(name.index + 1);
expect(image!.rows[image!.index].colSpan).toBe(name.rows[name.index].colSpan);
});
});

/**
* **A `type: 'code'` row's `language` must name the slot it actually edits**
* (#20439).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,13 +391,6 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
key: 'externalSharingModel',
why: 'declared, not enforced yet — liveness verdict `planned` (ADR-0090 D11: validated at authoring time only; the audience-aware evaluator branch that would honour it is scheduled, not built). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate',
},
{
kind: 'omit',
type: 'object',
path: ROOT_PATH,
key: 'imageField',
why: 'declared, not enforced yet — liveness verdict `planned` (the record picture: the parse refuses any name but a declared `image` / `avatar` field, and the record chrome that is to draw it in the record page header does not read it yet). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate',
},
{
kind: 'omit',
type: 'field',
Expand Down
Loading