diff --git a/.changeset/19332-g2b-remaining-g2-form-rows.md b/.changeset/19332-g2b-remaining-g2-form-rows.md new file mode 100644 index 00000000000..61d9ad9864e --- /dev/null +++ b/.changeset/19332-g2b-remaining-g2-form-rows.md @@ -0,0 +1,21 @@ +--- +"@objectstack/spec": minor +"@objectstack/platform-objects": patch +--- + +Clause-②: no + +Four more live structured keys are authorable in the metadata forms: `activityMilestones`, `publicSharing` and `userActions` on the object form, and `inlineColumns` on the field form. Each was **declared** by its schema, graded `live` by the liveness ledger, and offered by **no** form in `METADATA_FORM_REGISTRY`, so an author's only door was the Source tab. Each is now a row whose sub-rows are declared by hand rather than derived from the schema: + +- `activityMilestones` (object form, Advanced, beside `validations`) — a `type: 'repeater'` over the milestone's four keys: `field` (`widget: 'text'`, required), `value` and `summary` (text, required) and `type` (text). `field` pins its widget because the console turns a string sub-row named `field` into a field picker whose catalogue an object draft never fills. +- `publicSharing` (object form, Advanced, after `requiredPermissions`) — a `type: 'composite'` over all six keys of the share-link policy: `enabled` (switch), `allowedAudiences` and `allowedPermissions` (`widget: 'multiselect'` over their enum members), `maxExpiryDays` (number, at least 1), `redactFields` (`widget: 'string-tags'`) and `eligibility` (`type: 'code'`, `language: 'expression'`). +- `userActions` (object form, Advanced, under `managedBy`) — a `type: 'composite'` over the five affordance keys. `create`, `import`, `edit` and `delete` are each a boolean **or** a `{ enabled, visibleWhen, disabledWhen }` object, so they take `widget: 'json'`: the console renders a switch for a new entry or a stored boolean, and the object's own keys for a stored object, and never writes one arm over the other. `exportCsv` is a switch. +- `inlineColumns` (field form, Configuration, beside `inlineTitle`, shown on `master_detail` fields) — a `type: 'repeater'` over a **curated subset** of the twenty keys an inline grid column accepts: `name` (required), `label`, `width` and `defaultHidden`. The metadata-form reconciliation ledger records the nested `subset` row and names what is left to source and why: `type` opts a column out of hydration from the child field, the type-specific keys cannot be gated on a type the column takes from the child field at render, and the rules are copies of the child field's own. + +The help text states what the runtime does with each value, read from its consumer, and claims a refusal only where one exists. A misspelt `publicSharing.redactFields` entry is refused at publish and by `os validate`. `activityMilestones[].field`, a `{token}` in its `summary`, and `inlineColumns[].name` are judged by no authoring door, and their help texts say so and name what happens instead: the milestone never fires, the token renders empty, the column renders as plain text. + +The two new repeaters' row schemas also carry a JSON Schema `title` on every property, as every repeater row schema must: the four keys of an `activityMilestones` entry, and all twenty keys of `InlineGridColumnSchema`. Each title is a `.meta({ title })` call and nothing more. + +⛔ **No schema accept set moves and no export changes.** `METADATA_FORM_REGISTRY` is declared as an opaque `Readonly>`, so row contents were never part of the declared surface. What changes is the **form payload** `getMetaTypes()` serves (its rows, and the titles above in its JSON Schema) and the translation keys `os i18n extract` walks, hence the regenerated `platform-objects` metadata-form bundles. Their 46 new leaves are authored in `zh-CN`, `ja-JP` and `es-ES` rather than left as extractor fills. + +⛔ **The gate that would notice a missing row is NOT landed here.** The reconciliation gate's top-level `zodOnly` direction stays unwired; this change lands offers and one nested ledger row only. diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 43075f9681a..e1acefb1155 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -605,7 +605,16 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // `field :: accept | currencyConfig | dependsOn | lookupColumns | // lookupFilters | relatedListColumns`, plus `action :: patch` and // `action :: bodyExtra`. The other eight rows carry no predicate. - expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(81); + // It is 82 today, an ADDITION of ONE: #19332 (flight G2b) gave four live + // structured keys a form row each, and one of them carries a meaningfulness + // gate — the field form's `inlineColumns` repeater, read only on a + // `master_detail` field, like its `inlineTitle` / `inlineAmountField` + // siblings. Measured, not inferred: the shipped corpus was differenced + // against the merge base `e956924e` by `
::::`, + // 81 → 82, `field :: inlineColumns :: data.type == 'master_detail'` added + // and NONE removed. The other three rows and all their sub-rows carry no + // predicate. + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(82); const findings = validatePredicatePathRefs(corrupted); expect(findings).toHaveLength(predicates); @@ -701,7 +710,10 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // `data.type == 'currency'`, `action :: patch` on // `data.operation == 'update'`, `action :: bodyExtra` on // `data.type == 'api'`); the other five are `in`-list gates. - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(56); + // It is 57 today: #19332 G2b's one new predicate, `field :: inlineColumns` + // on `data.type == 'master_detail'`, compares against a single-quoted + // literal. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(57); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED); 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 ca8d0084293..474a67d3bec 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 @@ -293,6 +293,26 @@ export const enMetadataForms: NonNullable = { label: "Validations", helpText: "Object-level validation rules — an array of rule objects, e.g. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. State-machine transition tables are declared here too (ADR-0020)" }, + activityMilestones: { + label: "Activity Milestones", + helpText: "Timeline entries fired by a field reaching a value (ADR-0052 §5b.2): when an update moves the watched field into the value, the audit plugin writes the milestone's summary to the record's activity timeline instead of the field-change entry. The first milestone that matches wins." + }, + "activityMilestones.field": { + label: "Field", + helpText: "Name of the field to watch on this object (e.g. status). Nothing checks it when you save or publish: a name that is not a field of this object never fires." + }, + "activityMilestones.value": { + label: "Value", + helpText: "The stored value the field must change into, compared exactly as text — for a select field the option value, not its label (e.g. done). A milestone on a number or boolean field never fires." + }, + "activityMilestones.summary": { + label: "Summary", + helpText: "Timeline text (e.g. \"Deal won: {name}\"). A {field_name} token takes the record's value after the update, and the token of a lookup, master-detail or user field shows the referenced record's title; a token that names no field renders empty." + }, + "activityMilestones.type": { + label: "Type", + helpText: "Activity type of the timeline entry: a built-in kind such as completed, or your own word, stored as written. Unset: updated." + }, datasource: { label: "Datasource", helpText: "Target datasource ID (default: \"default\")" @@ -307,7 +327,7 @@ export const enMetadataForms: NonNullable = { }, "indexes.fields": { label: "Fields", - helpText: "Column names of this object, in key order (e.g. status, owner). Nothing checks them when you save or publish: a name that is not a stored column makes the SQL driver skip the whole index, with a warning in the server log." + helpText: "Column names of this object, in key order (e.g. status, owner). Saving does not check them; publishing and os validate refuse a name that is not a field of this object. A field that is not a stored column (a formula, say) makes the SQL driver skip the whole index, with a warning in the server log." }, "indexes.unique": { label: "Unique", @@ -333,10 +353,62 @@ export const enMetadataForms: NonNullable = { label: "Required Permissions", helpText: "Capabilities (permission-set systemPermissions) a caller must hold to reach this object, checked in addition to CRUD grants (ADR-0066 D3). A list gates every operation; a {read, create, update, delete} map gates only the operations it lists. Absent or empty: no capability gate." }, + publicSharing: { + label: "Public Sharing", + helpText: "Share-link policy: whether records of this object can be published through a link that anyone holding it opens, and on what terms. Separate from sharingModel, which shares with named users and teams. Unset or off: no link can be created, and none opens." + }, + "publicSharing.enabled": { + label: "Enabled", + helpText: "Allow share links for this object's records. Checked on every redemption: switching it off stops every existing link from opening, and switching it back on serves them again. Off (the default): nothing else here applies." + }, + "publicSharing.allowedAudiences": { + label: "Allowed Audiences", + helpText: "Audiences a new link may name; any other is refused. Unset: link only. Every audience still needs the link itself: signed in also needs a signed-in user, and email also needs the recipient's address on the link's list." + }, + "publicSharing.allowedPermissions": { + label: "Allowed Permissions", + helpText: "Permission levels a new link may grant; any other is refused. Unset: view only." + }, + "publicSharing.maxExpiryDays": { + label: "Max Expiry Days", + helpText: "Latest expiry a new link may request, in days from now; a later one is refused. Unset: 365. It does not force an expiry: a link created without one never expires." + }, + "publicSharing.redactFields": { + label: "Redact Fields", + helpText: "Field names of this object removed from every record a link serves, whatever the audience; the owner's own access is unaffected. A name that is not a field of this object is refused at publish." + }, + "publicSharing.eligibility": { + label: "Eligibility", + helpText: "CEL predicate over the record (e.g. record.status == 'published'): a link is created only while it is TRUE, and an existing link stops opening once its record no longer qualifies. A predicate that does not compile, or faults, refuses the link." + }, managedBy: { label: "Managed By", helpText: "Lifecycle bucket: platform (user CRUD), config (admin authored), system-data (platform-defined schema with admin/user-writable data), engine-owned (no user writes), append-only (audit), better-auth (identity). UI clients derive their CRUD affordances from it, so it decides what a user is offered on records of this object." }, + userActions: { + label: "User Actions", + helpText: "Which generic entries (New, Import, Edit, Delete, Export) UI clients offer on this object's records, overriding the managedBy default one entry at a time. An unset entry keeps that default: platform offers all five; config and system-data all but Import; engine-owned, append-only and better-auth only Export. An untouched switch writes nothing, so it reads off even where the default offers the entry. On an engine-owned or append-only object, turning an entry on also lets users make that write through the data API. Users still need the matching permission." + }, + "userActions.create": { + label: "Create", + helpText: "The New button: on shows it, off hides it. A stored {enabled, visibleWhen, disabledWhen} object is edited key by key; write one in source to gate the button on the record in scope, evaluated once per toolbar (the host record on a related list)." + }, + "userActions.import": { + label: "Import", + helpText: "The CSV import entry: on shows it, off hides it. A stored {enabled, visibleWhen, disabledWhen} object is edited key by key; write one in source to gate the entry on the record in scope, evaluated once per toolbar." + }, + "userActions.edit": { + label: "Edit", + helpText: "Editing existing records, inline and in the form: on offers it, off hides it. A stored {enabled, visibleWhen, disabledWhen} object is edited key by key; write one in source to gate each row on its own record." + }, + "userActions.delete": { + label: "Delete", + helpText: "Row and bulk delete: on offers it, off hides it. A stored {enabled, visibleWhen, disabledWhen} object is edited key by key; write one in source to gate each row on its own record." + }, + "userActions.exportCsv": { + label: "Export CSV", + helpText: "The CSV export entry. Unset: shown, since every managedBy bucket offers export." + }, editMode: { label: "Edit Mode", helpText: "Edit-interaction intent for records of this object. Absent, the renderer picks its own default. Cross-renderer intent, not styling." @@ -622,6 +694,26 @@ export const enMetadataForms: NonNullable = { label: "Inline Title", helpText: "Title for the inline master-detail grid on the parent record." }, + inlineColumns: { + label: "Inline Columns", + helpText: "Columns of the inline grid on the parent's form, in display order; used only when this field sets inlineEdit, which is written in source. Unset: derived from this object's editable fields, and past six the rest start in the grid's column chooser. An entry that names only a field takes its type, options and rules from that field; the other column keys, type first, are written in source." + }, + "inlineColumns.name": { + label: "Name", + helpText: "Field of this (the child) object that the column shows and edits (e.g. quantity). Nothing checks it when you save or publish: a name that is not a field of this object renders a plain text column." + }, + "inlineColumns.label": { + label: "Label", + helpText: "Column header. Unset: the field's own label." + }, + "inlineColumns.width": { + label: "Width", + helpText: "Fixed column width in pixels. Unset: sized by the cell type, with text columns flexing and number, date and select columns staying narrow." + }, + "inlineColumns.defaultHidden": { + label: "Default Hidden", + helpText: "Start the column in the grid's column chooser instead of on screen; the user can show it. A column whose field is required is always shown." + }, inlineAmountField: { label: "Inline Amount Field", helpText: "Numeric child field summed for the inline grid total." 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 4b2164cb34d..4a6d8d03806 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 @@ -293,6 +293,26 @@ export const esESMetadataForms: NonNullable = label: "Validaciones", helpText: "Reglas de validación a nivel de objeto — un array de objetos de regla, p. ej. [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]. Las tablas de transición de máquinas de estado también se declaran aquí (ADR-0020)" }, + activityMilestones: { + label: "Hitos de actividad", + helpText: "Entradas de la cronología que se disparan cuando un campo alcanza un valor (ADR-0052 §5b.2): cuando una actualización cambia el campo vigilado a ese valor, el plugin de auditoría escribe el resumen del hito en la cronología de actividad del registro en lugar de la entrada de cambio de campo. Gana el primer hito que coincide." + }, + "activityMilestones.field": { + label: "Campo", + helpText: "Nombre del campo de este objeto que se vigila (p. ej., status). No se comprueba al guardar ni al publicar: un nombre que no es un campo de este objeto nunca se dispara." + }, + "activityMilestones.value": { + label: "Valor", + helpText: "El valor almacenado al que debe cambiar el campo, comparado exactamente como texto; para un campo de selección, el valor de la opción, no su etiqueta (p. ej., done). Un hito sobre un campo numérico o booleano nunca se dispara." + }, + "activityMilestones.summary": { + label: "Resumen", + helpText: "Texto de la cronología (p. ej., \"Deal won: {name}\"). Un token {field_name} toma el valor del registro tras la actualización, y el token de un campo de búsqueda, maestro-detalle o usuario muestra el título del registro referenciado; un token que no nombra ningún campo queda vacío." + }, + "activityMilestones.type": { + label: "Tipo", + helpText: "Tipo de actividad de la entrada de la cronología: un tipo integrado como completed, o una palabra propia, guardada tal cual. Sin definir: updated." + }, datasource: { label: "Fuente de datos", helpText: "ID de fuente de datos de destino (valor predeterminado: \"default\")" @@ -307,7 +327,7 @@ export const esESMetadataForms: NonNullable = }, "indexes.fields": { label: "Campos", - helpText: "Nombres de columna de este objeto, en el orden de la clave (p. ej., status, owner). Nada los comprueba al guardar ni al publicar: un nombre que no sea una columna almacenada hace que el driver SQL omita el índice entero, con una advertencia en el registro del servidor." + helpText: "Nombres de columna de este objeto, en el orden de la clave (p. ej., status, owner). Guardar no los comprueba; publicar y os validate rechazan un nombre que no sea un campo de este objeto. Un campo que no sea una columna almacenada (una fórmula, por ejemplo) hace que el driver SQL omita el índice entero, con una advertencia en el registro del servidor." }, "indexes.unique": { label: "Único", @@ -333,10 +353,62 @@ export const esESMetadataForms: NonNullable = label: "Permisos requeridos", helpText: "Capacidades (systemPermissions de conjuntos de permisos) que quien llama debe tener para acceder a este objeto, comprobadas además de las concesiones CRUD (ADR-0066 D3). Una lista restringe todas las operaciones; un mapa {read, create, update, delete} restringe solo las operaciones que enumera. Ausente o vacío: sin restricción por capacidad." }, + publicSharing: { + label: "Uso compartido público", + helpText: "Política de enlaces compartidos: si los registros de este objeto pueden publicarse mediante un enlace que cualquiera que lo tenga puede abrir, y en qué condiciones. Es distinta de sharingModel, que comparte con usuarios y equipos concretos. Sin definir o desactivada: no se puede crear ningún enlace y ninguno se abre." + }, + "publicSharing.enabled": { + label: "Habilitado", + helpText: "Permite enlaces compartidos para los registros de este objeto. Se comprueba en cada canje: al desactivarlo, ningún enlace existente se abre, y al reactivarlo vuelven a servirse. Desactivado (el valor predeterminado): no se aplica nada más de este bloque." + }, + "publicSharing.allowedAudiences": { + label: "Audiencias permitidas", + helpText: "Audiencias que puede indicar un enlace nuevo; cualquier otra se rechaza. Sin definir: solo enlace. Toda audiencia sigue necesitando el propio enlace: signed in además necesita un usuario con sesión iniciada, y email además necesita la dirección del destinatario en la lista del enlace." + }, + "publicSharing.allowedPermissions": { + label: "Permisos permitidos", + helpText: "Niveles de permiso que puede conceder un enlace nuevo; cualquier otro se rechaza. Sin definir: solo ver." + }, + "publicSharing.maxExpiryDays": { + label: "Días máximos de caducidad", + helpText: "Caducidad más lejana que puede solicitar un enlace nuevo, en días a partir de ahora; una posterior se rechaza. Sin definir: 365. No obliga a caducar: un enlace creado sin caducidad nunca caduca." + }, + "publicSharing.redactFields": { + label: "Campos ocultados", + helpText: "Nombres de campo de este objeto que se eliminan de todo registro servido por un enlace, sea cual sea la audiencia; el acceso propio del propietario no se ve afectado. Un nombre que no es un campo de este objeto se rechaza al publicar." + }, + "publicSharing.eligibility": { + label: "Elegibilidad", + helpText: "Predicado CEL sobre el registro (p. ej., record.status == 'published'): solo se crea un enlace mientras sea TRUE, y un enlace existente deja de abrirse cuando su registro deja de cumplirlo. Un predicado que no compila, o que falla al evaluarse, rechaza el enlace." + }, managedBy: { label: "Gestionado por", helpText: "Categoría de ciclo de vida: platform (CRUD de usuario), config (escrito por el administrador), system-data (esquema definido por la plataforma con datos escribibles por administrador o usuario), engine-owned (sin escrituras de usuario), append-only (auditoría), better-auth (identidad). Los clientes de UI derivan de aquí las acciones CRUD disponibles, así que decide qué se ofrece al usuario en los registros de este objeto." }, + userActions: { + label: "Acciones de usuario", + helpText: "Qué entradas genéricas (Nuevo, Importar, Editar, Eliminar, Exportar) ofrecen los clientes de UI en los registros de este objeto, sustituyendo el valor predeterminado de managedBy entrada por entrada. Una entrada sin definir mantiene ese valor predeterminado: platform ofrece las cinco; config y system-data todas salvo Importar; engine-owned, append-only y better-auth solo Exportar. Un interruptor que no se ha tocado no escribe nada, así que aparece desactivado aunque el valor predeterminado ofrezca la entrada. En un objeto engine-owned o append-only, activar una entrada también permite a los usuarios hacer esa escritura a través de la API de datos. Los usuarios siguen necesitando el permiso correspondiente." + }, + "userActions.create": { + label: "Crear", + helpText: "El botón Nuevo: activado lo muestra, desactivado lo oculta. Un objeto {enabled, visibleWhen, disabledWhen} almacenado se edita clave por clave; escríbelo en el código fuente para condicionar el botón al registro en contexto, evaluado una vez por barra de herramientas (el registro anfitrión en una lista relacionada)." + }, + "userActions.import": { + label: "Importar", + helpText: "La entrada de importación CSV: activada la muestra, desactivada la oculta. Un objeto {enabled, visibleWhen, disabledWhen} almacenado se edita clave por clave; escríbelo en el código fuente para condicionar la entrada al registro en contexto, evaluado una vez por barra de herramientas." + }, + "userActions.edit": { + label: "Editar", + helpText: "Edición de registros existentes, en línea y en el formulario: activada la ofrece, desactivada la oculta. Un objeto {enabled, visibleWhen, disabledWhen} almacenado se edita clave por clave; escríbelo en el código fuente para condicionar cada fila a su propio registro." + }, + "userActions.delete": { + label: "Eliminar", + helpText: "Eliminación por fila y masiva: activada la ofrece, desactivada la oculta. Un objeto {enabled, visibleWhen, disabledWhen} almacenado se edita clave por clave; escríbelo en el código fuente para condicionar cada fila a su propio registro." + }, + "userActions.exportCsv": { + label: "Exportar CSV", + helpText: "La entrada de exportación CSV. Sin definir: se muestra, porque todas las categorías de managedBy ofrecen la exportación." + }, editMode: { label: "Modo de edición", helpText: "Intención de interacción al editar registros de este objeto. Si se omite, el renderizador elige su propio valor predeterminado. Es una intención entre renderizadores, no un estilo." @@ -622,6 +694,26 @@ export const esESMetadataForms: NonNullable = label: "Título en línea", helpText: "Título de la cuadrícula maestro-detalle incrustada en el registro padre." }, + inlineColumns: { + label: "Columnas en línea", + helpText: "Columnas de la cuadrícula en línea del formulario del registro padre, en orden de visualización; solo se usan cuando este campo define inlineEdit, que se escribe en el código fuente. Sin definir: se derivan de los campos editables de este objeto y, a partir de seis, el resto empieza en el selector de columnas de la cuadrícula. Una entrada que solo nombra un campo toma de ese campo su tipo, opciones y reglas; las demás claves de columna, empezando por type, se escriben en el código fuente." + }, + "inlineColumns.name": { + label: "Nombre", + helpText: "Campo de este objeto (el hijo) que la columna muestra y edita (p. ej., quantity). No se comprueba al guardar ni al publicar: un nombre que no es un campo de este objeto se muestra como una columna de texto simple." + }, + "inlineColumns.label": { + label: "Etiqueta", + helpText: "Encabezado de la columna. Sin definir: la propia etiqueta del campo." + }, + "inlineColumns.width": { + label: "Ancho", + helpText: "Ancho fijo de la columna en píxeles. Sin definir: según el tipo de celda; las columnas de texto se expanden y las de número, fecha y selección se mantienen estrechas." + }, + "inlineColumns.defaultHidden": { + label: "Oculta por defecto", + helpText: "Empieza la columna en el selector de columnas de la cuadrícula en lugar de en pantalla; el usuario puede mostrarla. Una columna cuyo campo es obligatorio siempre se muestra." + }, inlineAmountField: { label: "Campo de importe en línea", helpText: "Campo numérico del objeto hijo que se suma para el total de la cuadrícula en línea." diff --git a/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts index c4e6035ba5e..749e4918c91 100644 --- a/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/field-panel-echo-decisions.test.ts @@ -665,9 +665,16 @@ describe('#19403 round 4 — the blind spot, FOURTH shape: a type-level coverage // repeater — `options`, with six children — and every one of those six // carried its own text in all three locales before this round. Everything // the existing pin could see here was fine. - expect(ROW_PROPERTIES.length).toBe(6); + // + // 10 since #19332 (flight G2b): that flight gave the form a second + // repeater, `inlineColumns`, with four declared children (`name`, `label`, + // `width`, `defaultHidden`), all twelve of their translated labels + // authored by the same flight, so the loop below still reads every one of + // them translated. + expect(ROW_PROPERTIES.length).toBe(10); expect(ROW_PROPERTIES).toContain('options.color'); expect(ROW_PROPERTIES).toContain('options.visibleWhen'); + expect(ROW_PROPERTIES).toContain('inlineColumns.defaultHidden'); for (const [locale, forms] of TRANSLATED_LOCALES) { for (const key of ROW_PROPERTIES) { const en = (enMetadataForms as Record).field?.fields?.[key]?.label; @@ -726,7 +733,10 @@ describe('#19403 round 4 — the blind spot, FOURTH shape: a type-level coverage // as round 3 measured. const coveredParents = new Set(ROW_PROPERTIES.map((k) => k.replace(/\.[^.]+$/, ''))); const skippedParents = new Set(UNWALKED_CHILDREN.map((k) => k.replace(/\.[^.]+$/, ''))); - expect([...coveredParents]).toEqual(['options']); + // `inlineColumns` joined in #19332 (flight G2b), after `options` in form + // order: a second walked parent, whose four children that flight authored. + // The broken five still hang off the unwalked `summaryOperations`. + expect([...coveredParents]).toEqual(['options', 'inlineColumns']); // `currencyConfig` and `storage` joined in #19332 (flight G1b), in form // order around it; their children were authored by that flight, so the // broken five still hang off `summaryOperations` alone. 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 a761eb545cd..fa3b8141d60 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 @@ -293,6 +293,26 @@ export const jaJPMetadataForms: NonNullable = label: "検証ルール", helpText: "オブジェクトレベルの検証ルール — ルールオブジェクトの配列。例: [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]。ステートマシンの遷移テーブルもここで宣言します(ADR-0020)" }, + activityMilestones: { + label: "アクティビティのマイルストーン", + helpText: "フィールドが特定の値に達したときに書き込まれるタイムラインのエントリ(ADR-0052 §5b.2)。更新で監視対象のフィールドがその値に変わると、監査プラグインはフィールド変更のエントリの代わりに、マイルストーンの概要をレコードのアクティビティタイムラインに書き込みます。最初に一致したマイルストーンが使われます。" + }, + "activityMilestones.field": { + label: "フィールド", + helpText: "監視する、このオブジェクトのフィールド名(例:status)。保存時や公開時には検査されません。このオブジェクトのフィールドではない名前は決して発火しません。" + }, + "activityMilestones.value": { + label: "値", + helpText: "フィールドが変わるべき保存値で、テキストとして完全一致で比較されます。選択フィールドではラベルではなく選択肢の値を指定します(例:done)。数値または真偽値フィールドのマイルストーンは決して発火しません。" + }, + "activityMilestones.summary": { + label: "概要", + helpText: "タイムラインのテキスト(例:\"Deal won: {name}\")。{field_name} トークンは更新後のレコードの値になり、参照・主従・ユーザーフィールドのトークンは参照先レコードのタイトルを表示します。どのフィールドも指さないトークンは空になります。" + }, + "activityMilestones.type": { + label: "種類", + helpText: "タイムラインのエントリのアクティビティ種別。completed などの組み込みの種別か独自の語で、書いたとおりに保存されます。未設定の場合は updated。" + }, datasource: { label: "データソース", helpText: "対象データソース ID(既定: \"default\")" @@ -307,7 +327,7 @@ export const jaJPMetadataForms: NonNullable = }, "indexes.fields": { label: "フィールド", - helpText: "このオブジェクトの列名を、キーの順に指定します(例:status、owner)。保存時にも公開時にも検査されません。保存される列ではない名前があると、SQL ドライバーはそのインデックス全体をスキップし、サーバーログに警告を出します。" + helpText: "このオブジェクトの列名を、キーの順に指定します(例:status、owner)。保存時には検査されませんが、公開時と os validate では、このオブジェクトのフィールドではない名前が拒否されます。保存される列ではないフィールド(数式など)があると、SQL ドライバーはそのインデックス全体をスキップし、サーバーログに警告を出します。" }, "indexes.unique": { label: "一意", @@ -333,10 +353,62 @@ export const jaJPMetadataForms: NonNullable = label: "必要な権限", helpText: "このオブジェクトにアクセスするために呼び出し元が保持すべき機能(権限セットの systemPermissions)。CRUD 付与に加えてチェックされます(ADR-0066 D3)。リストはすべての操作を制限し、{read, create, update, delete} マップは列挙した操作だけを制限します。省略または空の場合、機能による制限はありません。" }, + publicSharing: { + label: "公開共有", + helpText: "共有リンクのポリシー。このオブジェクトのレコードを、持っている人なら誰でも開けるリンクで公開できるかどうかと、その条件を定めます。指定したユーザーやチームと共有する sharingModel とは別物です。未設定またはオフの場合、リンクは作成できず、どのリンクも開けません。" + }, + "publicSharing.enabled": { + label: "有効", + helpText: "このオブジェクトのレコードに共有リンクを許可します。リンクが開かれるたびに確認されるため、オフにすると既存のリンクはすべて開けなくなり、再びオンにすると再び提供されます。オフ(既定)の場合、ここにある他の設定はいずれも適用されません。" + }, + "publicSharing.allowedAudiences": { + label: "許可する対象者", + helpText: "新しいリンクで指定できる対象者。それ以外は拒否されます。未設定の場合はリンクのみ。どの対象者でもリンクそのものが必要で、signed in ではさらにサインイン済みのユーザーが、email ではさらに受信者のアドレスがリンクのリストに載っていることが必要です。" + }, + "publicSharing.allowedPermissions": { + label: "許可する権限", + helpText: "新しいリンクで付与できる権限レベル。それ以外は拒否されます。未設定の場合は閲覧のみ。" + }, + "publicSharing.maxExpiryDays": { + label: "最長有効日数", + helpText: "新しいリンクが要求できる最も遅い有効期限(今からの日数)。それより遅いものは拒否されます。未設定の場合は 365。有効期限を強制するものではなく、有効期限なしで作成されたリンクは期限切れになりません。" + }, + "publicSharing.redactFields": { + label: "秘匿フィールド", + helpText: "リンク経由で提供されるすべてのレコードから、対象者に関係なく取り除かれる、このオブジェクトのフィールド名。所有者自身のアクセスには影響しません。このオブジェクトのフィールドではない名前は、公開時に拒否されます。" + }, + "publicSharing.eligibility": { + label: "適格条件", + helpText: "レコードに対する CEL 述語(例:record.status == 'published')。TRUE の間だけリンクが作成され、レコードが条件を満たさなくなると既存のリンクは開けなくなります。コンパイルできない、または評価に失敗した述語はリンクを拒否します。" + }, managedBy: { label: "ライフサイクル区分", helpText: "ライフサイクル区分: platform(ユーザーによる CRUD)、config(管理者が記述)、system-data(プラットフォーム定義のスキーマで、データは管理者/ユーザーが書き込み可)、engine-owned(エンジン所有、ユーザー書き込み不可)、append-only(監査)、better-auth(ID)。UI クライアントはこの値から CRUD の可否を導くため、このオブジェクトのレコードでユーザーに何が提供されるかを決めます。" }, + userActions: { + label: "ユーザー操作", + helpText: "UI クライアントがこのオブジェクトのレコードで提供する汎用の入口(新規、インポート、編集、削除、エクスポート)を、managedBy の既定から 1 項目ずつ上書きします。未設定の項目は既定のままで、platform は 5 つすべて、config と system-data はインポート以外すべて、engine-owned、append-only、better-auth はエクスポートのみを提供します。一度も操作していないスイッチは何も書き込まないため、既定で提供される項目でもオフと表示されます。engine-owned または append-only のオブジェクトでは、項目をオンにすると、ユーザーはその書き込みをデータ API 経由でも行えるようになります。ユーザーには引き続き対応する権限が必要です。" + }, + "userActions.create": { + label: "作成", + helpText: "「新規」ボタン。オンで表示、オフで非表示。保存済みの {enabled, visibleWhen, disabledWhen} オブジェクトはキーごとに編集されます。スコープ内のレコードでボタンを制御するにはソースに記述してください。ツールバーごとに 1 回評価されます(関連リストではホストレコード)。" + }, + "userActions.import": { + label: "インポート", + helpText: "CSV インポートの入口。オンで表示、オフで非表示。保存済みの {enabled, visibleWhen, disabledWhen} オブジェクトはキーごとに編集されます。スコープ内のレコードで入口を制御するにはソースに記述してください。ツールバーごとに 1 回評価されます。" + }, + "userActions.edit": { + label: "編集", + helpText: "既存レコードの編集(インラインとフォーム)。オンで提供、オフで非表示。保存済みの {enabled, visibleWhen, disabledWhen} オブジェクトはキーごとに編集されます。各行をその行自身のレコードで制御するにはソースに記述してください。" + }, + "userActions.delete": { + label: "削除", + helpText: "行削除と一括削除。オンで提供、オフで非表示。保存済みの {enabled, visibleWhen, disabledWhen} オブジェクトはキーごとに編集されます。各行をその行自身のレコードで制御するにはソースに記述してください。" + }, + "userActions.exportCsv": { + label: "CSV エクスポート", + helpText: "CSV エクスポートの入口。未設定の場合は表示されます。どの managedBy 区分でも既定でエクスポートが提供されるためです。" + }, editMode: { label: "編集の開き方", helpText: "このオブジェクトのレコードを編集するときの操作意図。未設定ならレンダラー側の既定に従います。スタイルではなく、レンダラー横断の意図表明です。" @@ -622,6 +694,26 @@ export const jaJPMetadataForms: NonNullable = label: "インライン表のタイトル", helpText: "親レコードに埋め込まれるマスター/ディテール表のタイトル。" }, + inlineColumns: { + label: "インライン表の列", + helpText: "親レコードのフォームにあるインライン表の列で、表示順に並べます。このフィールドが inlineEdit(ソースに記述)を設定している場合にのみ使われます。未設定の場合はこのオブジェクトの編集可能なフィールドから導出され、6 列を超えた分は最初は表の列選択に収められます。フィールド名だけのエントリは、型・選択肢・ルールをそのフィールドから受け取ります。その他の列のキー(まず type)はソースに記述します。" + }, + "inlineColumns.name": { + label: "名前", + helpText: "列が表示・編集する、このオブジェクト(子オブジェクト)のフィールド(例:quantity)。保存時や公開時には検査されません。このオブジェクトのフィールドではない名前は、プレーンなテキスト列として表示されます。" + }, + "inlineColumns.label": { + label: "ラベル", + helpText: "列の見出し。未設定の場合はフィールド自身のラベル。" + }, + "inlineColumns.width": { + label: "幅", + helpText: "ピクセル単位の固定列幅。未設定の場合はセルの種類に応じた幅になり、テキスト列は伸縮し、数値・日付・選択の列は狭いままです。" + }, + "inlineColumns.defaultHidden": { + label: "既定で非表示", + helpText: "列を画面に表示せず、最初は表の列選択に収めておきます。ユーザーは表示に切り替えられます。フィールドが必須の列は常に表示されます。" + }, inlineAmountField: { label: "インライン合計項目", helpText: "インライン表の合計に用いる、子オブジェクト側の数値項目。" diff --git a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts index ae25091ee36..1adf4e729cd 100644 --- a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts @@ -905,10 +905,17 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape', // (`name`, `fields`, `unique`), a label and a help text each, four rows and // eight leaves, all twenty-four translated leaves authored by the same // flight. `advanced` reads 52 → 60; `capabilities` is untouched. - expect(PANEL_LEAVES.length).toBe(69); + // 105 since #19332 flight G2b: that flight gave `advanced` three more rows — + // the `activityMilestones` repeater beside `validations` (four declared + // sub-rows), the `publicSharing` composite after `requiredPermissions` (six) + // and the `userActions` composite under `managedBy` (five) — eighteen rows, + // a label and a help text each, thirty-six leaves, all hundred and eight + // translated leaves authored by the same flight. `advanced` reads 60 → 96; + // `capabilities` is untouched. + expect(PANEL_LEAVES.length).toBe(105); expect(PANEL_LEAVES.every((l) => l.prop === 'label' || l.prop === 'helpText')).toBe(true); expect(PANEL_LEAVES.filter((l) => l.section === 'capabilities').length).toBe(9); - expect(PANEL_LEAVES.filter((l) => l.section === 'advanced').length).toBe(60); + expect(PANEL_LEAVES.filter((l) => l.section === 'advanced').length).toBe(96); }); it('⭐ DARK, OUTWARD — the open sections are excluded, and `fields.placeholder` is the one that proves it', () => { 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 5fc078ca9bb..0765c01c7f8 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 @@ -1137,7 +1137,16 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // `collapse` and `visibleWhen` sub-rows) and its `indexes` repeater (and // its `name`, `fields` and `unique` sub-rows) — authored in all three // locales. - expect(translated.length, `${locale} positive control`).toBe(634); + // 657 since #19332 flight G2b: twenty-three new row labels — the object + // form's `activityMilestones` repeater (and its `field`, `value`, + // `summary` and `type` sub-rows), `publicSharing` composite (and its + // `enabled`, `allowedAudiences`, `allowedPermissions`, `maxExpiryDays`, + // `redactFields` and `eligibility` sub-rows) and `userActions` composite + // (and its `create`, `import`, `edit`, `delete` and `exportCsv` + // sub-rows), and the field form's `inlineColumns` repeater (and its + // `name`, `label`, `width` and `defaultHidden` sub-rows) — authored in all + // three locales. + expect(translated.length, `${locale} positive control`).toBe(657); } // ⭐ 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 8320a671e8f..0bec2ba3361 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 @@ -293,6 +293,26 @@ export const zhCNMetadataForms: NonNullable = label: "校验规则", helpText: "对象级校验规则——由规则对象组成的数组,例如 [{ \"type\": \"script\", \"name\": \"amount_positive\", \"condition\": \"amount > 0\", \"message\": \"Amount must be positive\" }]。状态机转移表也在此声明(ADR-0020)" }, + activityMilestones: { + label: "活动里程碑", + helpText: "由字段到达某个值触发的时间线条目(ADR-0052 §5b.2):当一次更新把被监视的字段变为该值时,审计插件会把该里程碑的摘要写入记录的活动时间线,代替字段变更条目。第一个匹配的里程碑生效。" + }, + "activityMilestones.field": { + label: "字段", + helpText: "要监视的本对象字段名(例如 status)。保存或发布时不会检查它:不是本对象字段的名称永远不会触发。" + }, + "activityMilestones.value": { + label: "值", + helpText: "字段必须变为的存储值,按文本精确比较——对于选择字段,填写选项值而不是其标签(例如 done)。数字或布尔字段上的里程碑永远不会触发。" + }, + "activityMilestones.summary": { + label: "摘要", + helpText: "时间线文本(例如 \"Deal won: {name}\")。{field_name} 标记取更新后记录中的值;查找、主从或用户字段的标记显示被引用记录的标题;不指向任何字段的标记显示为空。" + }, + "activityMilestones.type": { + label: "类型", + helpText: "时间线条目的活动类型:内置类型(如 completed)或你自己的词,按原样存储。未设置:updated。" + }, datasource: { label: "数据源", helpText: "目标数据源 ID(默认:\"default\")" @@ -307,7 +327,7 @@ export const zhCNMetadataForms: NonNullable = }, "indexes.fields": { label: "字段", - helpText: "本对象的列名,按键的顺序排列(例如 status、owner)。保存或发布时不会检查它们:若某个名称不是已存储的列,SQL 驱动会跳过整个索引,并在服务器日志中记录一条警告。" + helpText: "本对象的列名,按键的顺序排列(例如 status、owner)。保存时不会检查它们;发布和 os validate 会拒绝不是本对象字段的名称。若某个字段不是已存储的列(例如公式字段),SQL 驱动会跳过整个索引,并在服务器日志中记录一条警告。" }, "indexes.unique": { label: "唯一", @@ -333,10 +353,62 @@ export const zhCNMetadataForms: NonNullable = label: "所需权限", helpText: "调用方访问本对象必须持有的能力(权限集的 systemPermissions),在增删改查授权之外额外检查(ADR-0066 D3)。列表限制所有操作;{read, create, update, delete} 映射只限制其中列出的操作。不声明或为空:不设能力门槛。" }, + publicSharing: { + label: "公开分享", + helpText: "分享链接策略:本对象的记录能否通过任何持有者都能打开的链接发布,以及发布的条件。它不同于 sharingModel——后者与指定的用户和团队共享。未设置或关闭:无法创建链接,也没有链接能打开。" + }, + "publicSharing.enabled": { + label: "已启用", + helpText: "允许为本对象的记录创建分享链接。每次访问链接时都会检查:关闭后所有已有链接都无法打开,重新开启后又会恢复服务。关闭(默认):此处其他设置均不生效。" + }, + "publicSharing.allowedAudiences": { + label: "允许的受众", + helpText: "新链接可以指定的受众;其他受众会被拒绝。未设置:仅限链接。每种受众都仍需要链接本身:signed in 还需要一位已登录的用户,email 还需要收件人地址在该链接的名单上。" + }, + "publicSharing.allowedPermissions": { + label: "允许的权限", + helpText: "新链接可以授予的权限级别;其他级别会被拒绝。未设置:仅查看。" + }, + "publicSharing.maxExpiryDays": { + label: "最长有效天数", + helpText: "新链接可请求的最晚过期时间,按从现在起的天数计;更晚的会被拒绝。未设置:365。它不会强制设置过期时间:创建时未设置过期时间的链接永不过期。" + }, + "publicSharing.redactFields": { + label: "脱敏字段", + helpText: "通过链接提供的每条记录中都会移除的本对象字段名,与受众无关;所有者自身的访问不受影响。不是本对象字段的名称会在发布时被拒绝。" + }, + "publicSharing.eligibility": { + label: "资格条件", + helpText: "基于记录的 CEL 谓词(例如 record.status == 'published'):仅当其为 TRUE 时才会创建链接;记录不再符合条件后,已有链接将无法打开。无法编译或求值出错的谓词会拒绝该链接。" + }, managedBy: { label: "生命周期归属", helpText: "生命周期分类:platform(用户可增删改查)、config(管理员编写)、system-data(平台定义结构、管理员/用户可写数据)、engine-owned(引擎独占,用户不可写)、append-only(审计)、better-auth(身份)。UI 客户端据此推导 CRUD 能力,因此它决定用户在该对象记录上能做什么。" }, + userActions: { + label: "用户操作", + helpText: "UI 客户端在本对象的记录上提供哪些通用入口(新建、导入、编辑、删除、导出),逐项覆盖 managedBy 的默认值。未设置的入口保持该默认值:platform 提供全部五项;config 和 system-data 提供除导入外的全部;engine-owned、append-only 和 better-auth 只提供导出。未改动过的开关不写入任何值,所以即使默认提供该入口,它也显示为关闭。在 engine-owned 或 append-only 对象上,打开某个入口还会允许用户通过数据 API 执行该写入。用户仍需要相应的权限。" + }, + "userActions.create": { + label: "新建", + helpText: "“新建”按钮:打开则显示,关闭则隐藏。已存储的 {enabled, visibleWhen, disabledWhen} 对象会逐键编辑;如需按当前范围内的记录控制该按钮,请在源码中编写该对象,它在每个工具栏上求值一次(在相关列表上是宿主记录)。" + }, + "userActions.import": { + label: "导入", + helpText: "CSV 导入入口:打开则显示,关闭则隐藏。已存储的 {enabled, visibleWhen, disabledWhen} 对象会逐键编辑;如需按当前范围内的记录控制该入口,请在源码中编写该对象,它在每个工具栏上求值一次。" + }, + "userActions.edit": { + label: "编辑", + helpText: "编辑已有记录(行内和表单中):打开则提供,关闭则隐藏。已存储的 {enabled, visibleWhen, disabledWhen} 对象会逐键编辑;如需按每一行自身的记录控制,请在源码中编写该对象。" + }, + "userActions.delete": { + label: "删除", + helpText: "行删除和批量删除:打开则提供,关闭则隐藏。已存储的 {enabled, visibleWhen, disabledWhen} 对象会逐键编辑;如需按每一行自身的记录控制,请在源码中编写该对象。" + }, + "userActions.exportCsv": { + label: "导出 CSV", + helpText: "CSV 导出入口。未设置:显示,因为每个 managedBy 类别默认都提供导出。" + }, editMode: { label: "编辑方式", helpText: "该对象记录的编辑交互意图。留空则由渲染端自行选择默认方式。这是跨渲染端的意图声明,不是样式。" @@ -622,6 +694,26 @@ export const zhCNMetadataForms: NonNullable = label: "内嵌表格标题", helpText: "父记录上内嵌主从表格的标题。" }, + inlineColumns: { + label: "内嵌表格列", + helpText: "父记录表单上内嵌表格的列,按显示顺序排列;仅当本字段设置了 inlineEdit(在源码中编写)时才使用。未设置:从本对象的可编辑字段推导,超过六列时其余列起初收在表格的列选择器中。只写字段名的条目会从该字段获取类型、选项和规则;其他列设置(首先是 type)在源码中编写。" + }, + "inlineColumns.name": { + label: "名称", + helpText: "该列显示和编辑的本(子)对象字段(例如 quantity)。保存或发布时不会检查它:不是本对象字段的名称会显示为普通文本列。" + }, + "inlineColumns.label": { + label: "标签", + helpText: "列标题。未设置:使用该字段自身的标签。" + }, + "inlineColumns.width": { + label: "宽度", + helpText: "固定列宽,单位为像素。未设置:按单元格类型确定宽度,文本列自适应伸展,数字、日期和选择列保持较窄。" + }, + "inlineColumns.defaultHidden": { + label: "默认隐藏", + helpText: "让该列起初收在表格的列选择器中,而不是显示在屏幕上;用户可以将其显示出来。字段为必填的列始终显示。" + }, inlineAmountField: { label: "内嵌合计字段", helpText: "用于内嵌表格合计行求和的子对象数值字段。" diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 92bcf8d9583..962ec0876c7 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -244,6 +244,41 @@ export const fieldForm = defineForm({ // reads a catalog from the field draft. { field: 'relatedListColumns', widget: 'string-tags', visibleWhen: "data.type in ['lookup','master_detail']", helpText: "Columns of this relationship's related list on the parent's detail page, as field names of this (the child) object, e.g. name, status. Unset: derived from the child object. Names only — labels, cell types and formatting come from the child's field definitions." }, { field: 'inlineTitle', visibleWhen: "data.type == 'master_detail'", helpText: 'Title for the inline master-detail grid on the parent record.' }, + // #19332 (flight G2b of ruling record 5861442317) — the inline grid's + // explicit columns, between the grid's title and its total and behind the + // same gate. A repeater with declared sub-rows, the `object.form.ts` + // `fieldGroups` repeater's face, over a CURATED SUBSET of the twenty + // keys `InlineGridColumnSchema` accepts; the reconciliation ledger + // records the `subset` row and names what is left out and why. + // + // The subset is the entry the key's own contract recommends: `name` + // alone, which objectui's `hydrateColumns` completes from the child + // field (type, options, lookup target, rules, computed expression), plus + // the three keys that apply to a column of any type (`label`, `width`, + // `defaultHidden`). `type` is not offered because declaring it opts the + // column out of that hydration. The keys that apply to one cell type + // only are not offered because a column takes its type from the child + // field at render, which no sub-row `visibleWhen` here can see, so each + // would be offered on every column. The rules (`required`, + // `readonlyWhen`, `requiredWhen`) are copies of the child field's own. + // + // `name` names a field of THIS (the child) object, like + // `relatedListColumns` above, and no authoring door judges it: not the + // parse, not the publish door, not `os validate`. At render an unknown + // name is left unhydrated, a plain text column, which is what the help + // text claims. + { + field: 'inlineColumns', + type: 'repeater', + visibleWhen: "data.type == 'master_detail'", + helpText: 'Columns of the inline grid on the parent\'s form, in display order; used only when this field sets inlineEdit, which is written in source. Unset: derived from this object\'s editable fields, and past six the rest start in the grid\'s column chooser. An entry that names only a field takes its type, options and rules from that field; the other column keys, type first, are written in source.', + fields: [ + { field: 'name', label: 'Name', type: 'text', required: true, helpText: 'Field of this (the child) object that the column shows and edits (e.g. quantity). Nothing checks it when you save or publish: a name that is not a field of this object renders a plain text column.' }, + { field: 'label', label: 'Label', type: 'text', helpText: 'Column header. Unset: the field\'s own label.' }, + { field: 'width', label: 'Width', type: 'number', helpText: 'Fixed column width in pixels. Unset: sized by the cell type, with text columns flexing and number, date and select columns staying narrow.' }, + { field: 'defaultHidden', label: 'Default Hidden', type: 'boolean', helpText: 'Start the column in the grid\'s column chooser instead of on screen; the user can show it. A column whose field is required is always shown.' }, + ], + }, { field: 'inlineAmountField', visibleWhen: "data.type == 'master_detail'", helpText: 'Numeric child field summed for the inline grid total.' }, ], }, diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 3c9b421ad9e..ef1593846ab 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -903,11 +903,11 @@ export const InlineGridColumnSchema = lazySchema(() => strictObject({ hidden: 'defaultHidden', }, }, { - name: z.string().min(1).describe('Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name). The retired `field` spelling is refused.'), - label: z.string().optional().describe("Column header; defaults to the child field's label via hydration."), - type: z.enum(['text', 'number', 'currency', 'date', 'datetime', 'time', 'select', 'lookup', 'file']).optional().describe("Cell control, derived from the child field's type when omitted. Declaring it opts the column out of schema hydration — supply the extras (options / reference / …) yourself."), - width: z.number().positive().optional().describe('Fixed column width in px; omitted columns use type-based role sizing (text flexes, numeric/date/select stay fixed).'), - required: z.boolean().optional().describe('Cell is flagged inline-invalid while empty. Computed columns are never required.'), + name: z.string().min(1).describe('Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name). The retired `field` spelling is refused.').meta({ title: 'Name' }), + label: z.string().optional().describe("Column header; defaults to the child field's label via hydration.").meta({ title: 'Label' }), + type: z.enum(['text', 'number', 'currency', 'date', 'datetime', 'time', 'select', 'lookup', 'file']).optional().describe("Cell control, derived from the child field's type when omitted. Declaring it opts the column out of schema hydration — supply the extras (options / reference / …) yourself.").meta({ title: 'Type' }), + width: z.number().positive().optional().describe('Fixed column width in px; omitted columns use type-based role sizing (text flexes, numeric/date/select stay fixed).').meta({ title: 'Width' }), + required: z.boolean().optional().describe('Cell is flagged inline-invalid while empty. Computed columns are never required.').meta({ title: 'Required' }), options: z.array(strictObject({ surface: 'this inline grid column option', history: INLINE_GRID_COLUMN_HISTORY, @@ -915,21 +915,21 @@ export const InlineGridColumnSchema = lazySchema(() => strictObject({ }, { label: z.string().describe('Option label shown in the select cell.'), value: z.string().min(1).describe("Stored option value; must match the child select field's option values."), - })).optional().describe("Select-cell options for `type: 'select'`; derived from the child field's options when the column declares no `type`."), + })).optional().describe("Select-cell options for `type: 'select'`; derived from the child field's options when the column declares no `type`.").meta({ title: 'Options' }), // #20045 — no default symbol: the grid shows the resolved currency's own // symbol when this is omitted (objectui GridField `currencyAdornment`, // objectui#10355), so the former 「(default '¥')」 described a fallback the // renderer no longer has. - prefix: z.string().optional().describe("Symbol shown in a `currency` cell in place of the resolved currency's own symbol. No default: when omitted, the cell shows the symbol of the currency it resolves. It replaces the symbol only — the amount's decimal places stay the currency's."), - step: z.number().positive().optional().describe('Input step for numeric cells.'), - reference: z.string().optional().describe("Referenced object for `type: 'lookup'` cells; derived from the child lookup field when the column declares no `type`."), - displayField: z.string().optional().describe('Label field shown for a picked lookup record.'), - idField: z.string().optional().describe('Id field stored for a picked lookup record.'), - multiple: z.boolean().optional().describe('Multi-value column: multi-record lookup, or multi-file upload cell.'), - accept: z.array(z.string()).optional().describe("Accepted MIME types / extensions for a `file` cell's picker (e.g. ['image/*', '.pdf']); omit to accept anything."), - defaultHidden: z.boolean().optional().describe("Collapsed into the grid's column chooser by default (not dropped); required columns are never default-hidden."), - computed: z.boolean().optional().describe('Read-only computed column, recomputed live from sibling cells via `expr` and written back into the row.'), - expr: z.string().min(1).optional().describe("Arithmetic expression for a computed column — a BARE string over `+ - * / %`, parentheses, numeric literals and field refs (`record.qty` or `qty`), evaluated by the grid's own safe evaluator. Deliberately NOT a CEL Expression envelope; `{ dialect, source }` is refused here."), + prefix: z.string().optional().describe("Symbol shown in a `currency` cell in place of the resolved currency's own symbol. No default: when omitted, the cell shows the symbol of the currency it resolves. It replaces the symbol only — the amount's decimal places stay the currency's.").meta({ title: 'Prefix' }), + step: z.number().positive().optional().describe('Input step for numeric cells.').meta({ title: 'Step' }), + reference: z.string().optional().describe("Referenced object for `type: 'lookup'` cells; derived from the child lookup field when the column declares no `type`.").meta({ title: 'Reference' }), + displayField: z.string().optional().describe('Label field shown for a picked lookup record.').meta({ title: 'Display Field' }), + idField: z.string().optional().describe('Id field stored for a picked lookup record.').meta({ title: 'ID Field' }), + multiple: z.boolean().optional().describe('Multi-value column: multi-record lookup, or multi-file upload cell.').meta({ title: 'Multiple' }), + accept: z.array(z.string()).optional().describe("Accepted MIME types / extensions for a `file` cell's picker (e.g. ['image/*', '.pdf']); omit to accept anything.").meta({ title: 'Accept' }), + defaultHidden: z.boolean().optional().describe("Collapsed into the grid's column chooser by default (not dropped); required columns are never default-hidden.").meta({ title: 'Default Hidden' }), + computed: z.boolean().optional().describe('Read-only computed column, recomputed live from sibling cells via `expr` and written back into the row.').meta({ title: 'Computed' }), + expr: z.string().min(1).optional().describe("Arithmetic expression for a computed column — a BARE string over `+ - * / %`, parentheses, numeric literals and field refs (`record.qty` or `qty`), evaluated by the grid's own safe evaluator. Deliberately NOT a CEL Expression envelope; `{ dialect, source }` is refused here.").meta({ title: 'Expression' }), // #18972 — the upper bound is the SAME platform ceiling as `FieldSchema.scale` // below, reached by a different primitive: this key is the one objectui's // `computeRow` hands to `Number(v.toFixed(scale))`, which throws above 100. @@ -937,10 +937,10 @@ export const InlineGridColumnSchema = lazySchema(() => strictObject({ // `type: 'currency'` by the `.superRefine` below (ruling B carried to this // mirror); the describe names the one type set it still applies to. scale: z.number().int().nonnegative().max(MAX_RENDERABLE_SCALE, { message: SCALE_UPPER_BOUND_MESSAGE }).optional() - .describe('Decimal places to round a computed numeric result to (integer 0-100). REFUSED on a column declaring `type: \'currency\'` — delete it there: the currency\'s ISO 4217 minor unit decides. The upper bound is the renderer\'s: the grid rounds with `toFixed`, which throws a RangeError above 100.'), - autofill: z.boolean().optional().describe("For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable."), - readonlyWhen: EvaluatedExpressionInputSchema.optional().describe("Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`)."), - requiredWhen: EvaluatedExpressionInputSchema.optional().describe('Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD\'s own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.'), + .describe('Decimal places to round a computed numeric result to (integer 0-100). REFUSED on a column declaring `type: \'currency\'` — delete it there: the currency\'s ISO 4217 minor unit decides. The upper bound is the renderer\'s: the grid rounds with `toFixed`, which throws a RangeError above 100.').meta({ title: 'Scale' }), + autofill: z.boolean().optional().describe("For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable.").meta({ title: 'Autofill' }), + readonlyWhen: EvaluatedExpressionInputSchema.optional().describe("Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`).").meta({ title: 'Read-only When' }), + requiredWhen: EvaluatedExpressionInputSchema.optional().describe('Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it. The server-enforced contract is the child FIELD\'s own `requiredWhen` — a transition gate, see `Field.requiredWhen` — which hydration copies onto an identity-only column, so declaring the requirement here alone enforces nothing.').meta({ title: 'Required When' }), }).superRefine((column, ctx) => { // #20045 — ruling B (5791803339) on #19629 retired `scale` from the currency // FIELD type; triage read this card as inherited from that ruling and from diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index 763c53eabda..60713e06f3a 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -465,6 +465,38 @@ export const objectForm = defineForm({ // `lifecycle.*.onlyWhen` rows — ⛔ not a reconciliation, and not this // row's price of admission. { field: 'validations', widget: 'json', helpText: 'Object-level validation rules — an array of rule objects, e.g. [{ "type": "script", "name": "amount_positive", "condition": "amount > 0", "message": "Amount must be positive" }]. State-machine transition tables are declared here too (ADR-0020)' }, + // #19332 (flight G2b of ruling record 5861442317) — the object's + // declarative timeline milestones (ADR-0052 §5b.2), beside `validations`: + // a milestone fires on a field reaching a value, the same transition a + // `state_machine` rule above governs. A repeater with declared sub-rows, + // the `fieldGroups` repeater's face, over the four keys the entry schema + // has, all plain text, as plugin-audit reads them + // (`audit-writers.ts` `matchMilestone` / `renderMilestoneSummary`). + // + // `field` pins `widget: 'text'`, and that is a measurement, not a + // preference: with no `widget`, objectui's name convention turns a + // string sub-row named `field` into the `field-ref` picker, whose catalog + // an object draft never fills (it names no `object` / `objectName` / + // `data.object` / `interfaceConfig.source`), so the picker would offer + // only "None" and a new milestone could not name its field. An explicit + // `widget` skips the name convention; `text` is a passthrough hint, so + // the face is the plain text input. + // + // No authoring door judges `field` or a `{token}` of `summary`: not the + // parse, not the publish door, not `os validate` + // (`validate-object-field-refs` leaves `activityMilestones[].field` out + // by name). The help text claims what the runtime does with a miss. + { + field: 'activityMilestones', + type: 'repeater', + helpText: 'Timeline entries fired by a field reaching a value (ADR-0052 §5b.2): when an update moves the watched field into the value, the audit plugin writes the milestone\'s summary to the record\'s activity timeline instead of the field-change entry. The first milestone that matches wins.', + fields: [ + { field: 'field', label: 'Field', widget: 'text', required: true, helpText: 'Name of the field to watch on this object (e.g. status). Nothing checks it when you save or publish: a name that is not a field of this object never fires.' }, + { field: 'value', label: 'Value', type: 'text', required: true, helpText: 'The stored value the field must change into, compared exactly as text — for a select field the option value, not its label (e.g. done). A milestone on a number or boolean field never fires.' }, + { field: 'summary', label: 'Summary', type: 'text', required: true, helpText: 'Timeline text (e.g. "Deal won: {name}"). A {field_name} token takes the record\'s value after the update, and the token of a lookup, master-detail or user field shows the referenced record\'s title; a token that names no field renders empty.' }, + { field: 'type', label: 'Type', type: 'text', helpText: 'Activity type of the timeline entry: a built-in kind such as completed, or your own word, stored as written. Unset: updated.' }, + ], + }, { field: 'datasource', type: 'text', helpText: 'Target datasource ID (default: "default")' }, // #19332 (flight G2a of ruling record 5861442317) — the object's declared // indexes, beside `datasource`: storage. A repeater with declared @@ -472,12 +504,14 @@ export const objectForm = defineForm({ // (`name`, `fields`, `unique`); `type` and `partial` are tombstones and // need no row. // - // `fields` is a free-text list, the `highlightFields` row's face. No - // authoring door judges its names: not the schema parse, not the publish - // door, not `os validate` (`validate-object-field-refs` leaves it to the - // storage layer by design). The SQL driver's `syncDeclaredIndexes` skips - // an index naming a column the table does not have, logging a warning, - // so the help text claims that and no refusal. + // `fields` is a free-text list, the `highlightFields` row's face. The + // schema parse, so a draft save, does not judge its names. Publishing and + // `os validate` refuse one that is not a field of this object + // (`object-field-ref-unknown`, `error`, since #20432). The SQL driver's + // `syncDeclaredIndexes` skips an index naming a column the table does + // not have, logging a warning, which is what still befalls a real field + // that is not a stored column (a formula). The help text claims exactly + // those three. // // `unique` is a select over `global` / `organization` ONLY, as the // ruling says. The node is `boolean | 'global' | 'organization'`: a @@ -494,7 +528,7 @@ export const objectForm = defineForm({ helpText: 'Database indexes on this object\'s table. The SQL driver creates each one the table lacks when it syncs the table; a sync never drops an index.', fields: [ { field: 'name', label: 'Name', type: 'text', helpText: 'Physical index name. Unset: generated from the table and the columns (e.g. idx_task_status).' }, - { field: 'fields', label: 'Fields', widget: 'string-tags', required: true, helpText: 'Column names of this object, in key order (e.g. status, owner). Nothing checks them when you save or publish: a name that is not a stored column makes the SQL driver skip the whole index, with a warning in the server log.' }, + { field: 'fields', label: 'Fields', widget: 'string-tags', required: true, helpText: 'Column names of this object, in key order (e.g. status, owner). Saving does not check them; publishing and os validate refuse a name that is not a field of this object. A field that is not a stored column (a formula, say) makes the SQL driver skip the whole index, with a warning in the server log.' }, { field: 'unique', label: 'Unique', type: 'select', helpText: 'Uniqueness scope (ADR-0120). Unset: not unique. The deprecated bare true (it means global) is not offered; an index that carries it keeps it until you pick a scope.', options: [ { label: 'Global — one holder across the installation, over exactly these columns', value: 'global' }, { label: 'Organization — one holder per organization (the driver prepends the organization column)', value: 'organization' }, @@ -549,6 +583,52 @@ export const objectForm = defineForm({ // renders a list input. On a create the first branch (the list) // renders; the map arm is written in source or reached once stored. { field: 'requiredPermissions', widget: 'json', helpText: 'Capabilities (permission-set systemPermissions) a caller must hold to reach this object, checked in addition to CRUD grants (ADR-0066 D3). A list gates every operation; a {read, create, update, delete} map gates only the operations it lists. Absent or empty: no capability gate.' }, + // #19332 (flight G2b of ruling record 5861442317) — the share-LINK policy, + // after the three principal-access rows above: `sharingModel` shares with + // named principals, this block with whoever holds a link. A composite + // with declared sub-rows, the `access` / `lifecycle` face, over all six + // keys of the strict block, each read by plugin-sharing + // (`share-link-service.ts` `getPolicy`, `createLink`, `resolveToken`): + // + // - `enabled` a switch, the `enable` toggles' face. + // - `allowedAudiences` / `allowedPermissions` are arrays of an enum + // whose members are all spellable option values, so they take the + // `multiselect` widget objectui derives for an array of enum (the + // derived `appearance.allowedVisualizations` of the view and page + // forms), declared here so each choice carries a label. It writes + // nothing when every choice is cleared, so the form cannot store the + // empty list the service reads as "any audience". + // - `maxExpiryDays` a number, `min` the schema's positive integer. + // - `redactFields` a free-text list, the `highlightFields` face and for + // the same reason (`field-multi` has no catalog on an object draft). + // It pins its widget on purpose: a string list named `*Fields` is + // otherwise turned into that very picker by objectui's name + // convention. A misspelt entry is refused at publish + // (`object-field-ref-unknown`, `error`), and by `os validate`. + // - `eligibility` a plain CEL string (not an ADR-0089 envelope), the + // `type: 'code'` / `language: 'expression'` predicate rows' face. + { + field: 'publicSharing', + type: 'composite', + helpText: 'Share-link policy: whether records of this object can be published through a link that anyone holding it opens, and on what terms. Separate from sharingModel, which shares with named users and teams. Unset or off: no link can be created, and none opens.', + fields: [ + { field: 'enabled', label: 'Enabled', type: 'boolean', helpText: 'Allow share links for this object\'s records. Checked on every redemption: switching it off stops every existing link from opening, and switching it back on serves them again. Off (the default): nothing else here applies.' }, + { field: 'allowedAudiences', label: 'Allowed Audiences', widget: 'multiselect', helpText: 'Audiences a new link may name; any other is refused. Unset: link only. Every audience still needs the link itself: signed in also needs a signed-in user, and email also needs the recipient\'s address on the link\'s list.', options: [ + { label: 'Public', value: 'public' }, + { label: 'Link only — anyone with the link', value: 'link_only' }, + { label: 'Signed in — signed-in users with the link', value: 'signed_in' }, + { label: 'Email — listed recipients with the link', value: 'email' }, + ] }, + { field: 'allowedPermissions', label: 'Allowed Permissions', widget: 'multiselect', helpText: 'Permission levels a new link may grant; any other is refused. Unset: view only.', options: [ + { label: 'View', value: 'view' }, + { label: 'Comment', value: 'comment' }, + { label: 'Edit', value: 'edit' }, + ] }, + { field: 'maxExpiryDays', label: 'Max Expiry Days', type: 'number', min: 1, helpText: 'Latest expiry a new link may request, in days from now; a later one is refused. Unset: 365. It does not force an expiry: a link created without one never expires.' }, + { field: 'redactFields', label: 'Redact Fields', widget: 'string-tags', helpText: 'Field names of this object removed from every record a link serves, whatever the audience; the owner\'s own access is unaffected. A name that is not a field of this object is refused at publish.' }, + { field: 'eligibility', label: 'Eligibility', type: 'code', language: 'expression', helpText: 'CEL predicate over the record (e.g. record.status == \'published\'): a link is created only while it is TRUE, and an existing link stops opening once its record no longer qualifies. A predicate that does not compile, or faults, refuses the link.' }, + ], + }, // No inline `options` here, and that is a CONSTRAINT rather than a // preference: `FormSelectOptionSchema.value` is a system identifier // (`^[a-z][a-z0-9_.]*$`), so the four hyphenated members of this enum — @@ -557,6 +637,37 @@ export const objectForm = defineForm({ // Schema, which carries every member verbatim, and the meanings ride the // help text instead of a list the form face would refuse. { field: 'managedBy', helpText: 'Lifecycle bucket: platform (user CRUD), config (admin authored), system-data (platform-defined schema with admin/user-writable data), engine-owned (no user writes), append-only (audit), better-auth (identity). UI clients derive their CRUD affordances from it, so it decides what a user is offered on records of this object.' }, + // #19332 (flight G2b of ruling record 5861442317) — the per-entry override + // of the matrix `managedBy` above resolves (`resolveCrudAffordances`), + // so it sits directly under it. A composite with declared sub-rows over + // all five keys of the strict block (its alias and guidance words — + // `new`, `export`, the VIEW block's `sort`, … — are refusals, not keys). + // + // `create` / `import` / `edit` / `delete` are each a UNION — a boolean, + // or a strict `{ enabled, visibleWhen, disabledWhen }` object — so the + // ruling's union rule applies: `json`, ⛔ never a face that can only + // write one arm. `json` is not a registered widget, so objectui resolves + // the face from the stored value's union branch: a stored boolean or a + // new entry (the first arm) renders a switch, and a stored object + // renders its three keys, whose edits merge into it. The object arm is + // therefore written in source and edited here once stored. `exportCsv` is + // a plain boolean, the `enable` toggles' face. + // + // A switch reads `false` for an unset entry, which is not what the + // resolved default says for most buckets, so the composite's help text + // states the defaults the switch cannot show. + { + field: 'userActions', + type: 'composite', + helpText: 'Which generic entries (New, Import, Edit, Delete, Export) UI clients offer on this object\'s records, overriding the managedBy default one entry at a time. An unset entry keeps that default: platform offers all five; config and system-data all but Import; engine-owned, append-only and better-auth only Export. An untouched switch writes nothing, so it reads off even where the default offers the entry. On an engine-owned or append-only object, turning an entry on also lets users make that write through the data API. Users still need the matching permission.', + fields: [ + { field: 'create', label: 'Create', widget: 'json', helpText: 'The New button: on shows it, off hides it. A stored {enabled, visibleWhen, disabledWhen} object is edited key by key; write one in source to gate the button on the record in scope, evaluated once per toolbar (the host record on a related list).' }, + { field: 'import', label: 'Import', widget: 'json', helpText: 'The CSV import entry: on shows it, off hides it. A stored {enabled, visibleWhen, disabledWhen} object is edited key by key; write one in source to gate the entry on the record in scope, evaluated once per toolbar.' }, + { field: 'edit', label: 'Edit', widget: 'json', helpText: 'Editing existing records, inline and in the form: on offers it, off hides it. A stored {enabled, visibleWhen, disabledWhen} object is edited key by key; write one in source to gate each row on its own record.' }, + { field: 'delete', label: 'Delete', widget: 'json', helpText: 'Row and bulk delete: on offers it, off hides it. A stored {enabled, visibleWhen, disabledWhen} object is edited key by key; write one in source to gate each row on its own record.' }, + { field: 'exportCsv', label: 'Export CSV', type: 'boolean', helpText: 'The CSV export entry. Unset: shown, since every managedBy bucket offers export.' }, + ], + }, { field: 'editMode', type: 'select', helpText: "Edit-interaction intent for records of this object. Absent, the renderer picks its own default. Cross-renderer intent, not styling.", options: [ { label: 'Modal — edit form as a dialog over the current view', value: 'modal' }, { label: 'Page — navigate to a dedicated full-page edit route', value: 'page' }, diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index ab9b5aa5b42..befde04ca8e 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2117,10 +2117,10 @@ const ObjectSchemaBase = strictObject( 'rule in `validations` or a hook.', }, }, { - field: z.string().describe('Field to watch (typically a status/stage select).'), - value: z.string().describe('The value the field must transition INTO to fire the milestone.'), - summary: z.string().describe('Activity summary template; {field} tokens interpolate the record value. e.g. "Deal won: {name}".'), - type: z.string().optional().describe('Activity type for the emitted row (default "completed").'), + field: z.string().describe('Field to watch (typically a status/stage select).').meta({ title: 'Field' }), + value: z.string().describe('The value the field must transition INTO to fire the milestone.').meta({ title: 'Value' }), + summary: z.string().describe('Activity summary template; {field} tokens interpolate the record value. e.g. "Deal won: {name}".').meta({ title: 'Summary' }), + type: z.string().optional().describe('Activity type for the emitted row (default "completed").').meta({ title: 'Type' }), })).optional().describe('Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2).'), // ADR-0020: record state machines are not a separate `stateMachines` map — 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 027c8a605f7..696dfd83e8e 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -258,6 +258,19 @@ const LEDGER: ReadonlyArray = [ key: 'collapsed', why: "`[DEPRECATED → collapse]` UI-dialect alias (ADR-0085), the other half of the `collapsible` / `collapsed` pair, deliberately not offered to new authors (ruling record 5861442317, #19332): the parse maps it onto `collapse` only when `collapse` is absent (`true` → 'collapsed' on its own), and this repeater offers the canonical `collapse` select", }, + // ── The `inlineColumns` repeater (ruling record 5861442317, #19332) ── + // + // The `object.fields` subset precedent at the top of this ledger, on the + // field form: a curated repeater over four of `InlineGridColumnSchema`'s + // twenty keys. Unlike `object.fields` there is no fuller editor to defer to, + // so the long tail is authored in source, and the reason says why that is + // the right place for each group of keys left out. + { + kind: 'subset', + type: 'field', + path: 'inlineColumns', + why: "a curated inline-grid column (ruling record 5861442317, #19332): `name` plus the three keys that apply to a column of any type (`label`, `width`, `defaultHidden`). An entry naming only a field is the shape the key's own describe recommends, because objectui's `hydrateColumns` completes it from the child field, so the rest is authored in source: `type`, since declaring it opts the column out of that hydration; the keys that apply to one cell type only (`options`, `reference`, `displayField`, `idField`, `autofill`, `multiple`, `accept`, `prefix`, `step`, `scale`, `computed`, `expr`), since a column takes its type from the child field at render and a sub-row here cannot be gated on it, so each would be offered on every column; and `required` / `readonlyWhen` / `requiredWhen`, which hydration copies from the child field, where the rule the server enforces lives", + }, // ── The root coordinate (#19333): top-level keys no form may offer ── // // Three reasons, each read off the key's own `describe()` or its liveness