diff --git a/.changeset/view-ref-build-lint.md b/.changeset/view-ref-build-lint.md new file mode 100644 index 0000000000..dccd387972 --- /dev/null +++ b/.changeset/view-ref-build-lint.md @@ -0,0 +1,16 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": minor +--- + +Add a build-time view-reference lint that fails `os compile` on a broken form-view reference, and surfaces the previously-silent `_2` rename collision as a warning (#2554). + +`expandViewContainer` gains a behaviour-preserving companion `expandViewContainerWithDiagnostics` that also reports every `.` name collision. List and form views share one namespace during expansion, and the default `list` implicitly claims `.default`; a colliding key was previously renamed to `._2` **silently**, so references (form action `target`s, navigation `viewName`s) resolved to the *other* view. + +The new `lint-view-refs` build lint consumes those diagnostics with a broken/fragile severity split, tuned so an upgrade does NOT break existing apps that merely have a colliding key: + +- **view-ref-form-target-kind** — ERROR (fails the build): a `type:'form'` action whose `target` resolves to an existing LIST view — the concrete #2554 breakage (a blank form, a silently no-op submit). High-confidence, so it fails. +- **view-key-collision** — WARNING: a key silently renamed on collision. Fragile, not broken — it breaks something only if the requested name is referenced — so it warns. +- **view-ref-form-target-missing** — WARNING: a form target resolving to no view; probably a typo, but possibly a view the lint failed to collect, so it warns rather than risk a false-positive build failure. + +This shifts objectui's runtime `viewKind` guard left to compile time: the author — very often an AI generating templates — discovers the mistake on `os compile` instead of when an end user clicks. It mirrors the existing broken/fragile two-level authoring lints (flow-patterns, autonumber, liveness). `expandViewContainer`'s runtime behaviour is unchanged; the fix is diagnostics-only plus the build gate. diff --git a/content/docs/references/ai/solution-blueprint.mdx b/content/docs/references/ai/solution-blueprint.mdx index 2fd1d00bf5..78bda68e39 100644 --- a/content/docs/references/ai/solution-blueprint.mdx +++ b/content/docs/references/ai/solution-blueprint.mdx @@ -110,6 +110,7 @@ const result = BlueprintApp.parse(data); | **label** | `string` | optional | Human-readable singular label | | **description** | `string` | optional | What this object represents | | **fields** | `Object[]` | ✅ | Fields to create on the object | +| **nameField** | `string` | optional | The record title field — which field holds the human-readable name shown on cards, lookup chips, breadcrumbs and search (ADR-0079). Set it to the object's text label field (e.g. "product_name"). For a numbered entity (invoice/ticket), set it to a formula field that composes number + name (e.g. "`{order_no}` · `{customer}`"). Omitting it lets the platform auto-pick a text field, but declaring it is strongly preferred. | --- @@ -135,9 +136,9 @@ const result = BlueprintApp.parse(data); | **object** | `string` | ✅ | Object this view displays (snake_case) | | **name** | `string` | ✅ | View machine name (snake_case) | | **label** | `string` | optional | Human-readable view label | -| **type** | `Enum<'list' \| 'form' \| 'kanban' \| 'calendar'>` | ✅ | View kind | -| **columns** | `string[]` | optional | Field names shown as columns (in order) | -| **groupBy** | `string` | optional | REQUIRED for kanban views: the select/status field whose options become the board columns (e.g. "stage", "status"). Without it a kanban renders as a plain list. | +| **type** | `Enum<'list' \| 'form' \| 'kanban' \| 'calendar' \| 'gallery' \| 'gantt'>` | ✅ | View kind. Pick the surface that fits the data: "gallery" for a visual card/cover browse when the user asks for a 画廊/相册/卡片墙/封面/海报/图集 (a gallery / card wall / cover / poster grid) or the object has an image/avatar/file field worth showing as a card cover; "gantt" for a 甘特图/时间线/排期 (timeline / schedule) when the object has BOTH a start and an end date field; "kanban" for a board grouped by a status/select field; "calendar" for a single-date schedule; "form" for a record editor; else "list". | +| **columns** | `string[]` | optional | Field names shown as columns (in order). For a gallery, INCLUDE the image/avatar/file field (it becomes the card cover); for a gantt, INCLUDE the start date column before the end date column. | +| **groupBy** | `string` | optional | REQUIRED for kanban views: the select/status field whose options become the board columns (e.g. "stage", "status"). Without it a kanban renders as a plain list. Optional for gantt (groups leaf tasks into summary rows). | --- diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index 8ecdeeb9d8..36a1763e37 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -24,8 +24,8 @@ Base path: /api/v1/data/\{object\}/export ## TypeScript Usage ```typescript -import { CreateExportJobRequest, CreateExportJobResponse, DeduplicationStrategy, ExportFormat, ExportImportTemplate, ExportJobProgress, ExportJobStatus, ExportJobSummary, FieldMappingEntry, GetExportJobDownloadRequest, GetExportJobDownloadResponse, ImportValidationConfig, ImportValidationMode, ImportValidationResult, ListExportJobsRequest, ListExportJobsResponse, ScheduleExportResponse } from '@objectstack/spec/api'; -import type { CreateExportJobRequest, CreateExportJobResponse, DeduplicationStrategy, ExportFormat, ExportImportTemplate, ExportJobProgress, ExportJobStatus, ExportJobSummary, FieldMappingEntry, GetExportJobDownloadRequest, GetExportJobDownloadResponse, ImportValidationConfig, ImportValidationMode, ImportValidationResult, ListExportJobsRequest, ListExportJobsResponse, ScheduleExportResponse } from '@objectstack/spec/api'; +import { CreateExportJobRequest, CreateExportJobResponse, CreateImportJobRequest, CreateImportJobResponse, DeduplicationStrategy, ExportFormat, ExportImportTemplate, ExportJobProgress, ExportJobStatus, ExportJobSummary, FieldMappingEntry, GetExportJobDownloadRequest, GetExportJobDownloadResponse, ImportJobProgress, ImportJobResults, ImportJobStatus, ImportJobSummary, ImportMapping, ImportRequest, ImportResponse, ImportRowResult, ImportValidationConfig, ImportValidationMode, ImportValidationResult, ImportWriteMode, ListExportJobsRequest, ListExportJobsResponse, ListImportJobsRequest, ListImportJobsResponse, ScheduleExportResponse, UndoImportJobResponse } from '@objectstack/spec/api'; +import type { CreateExportJobRequest, CreateExportJobResponse, CreateImportJobRequest, CreateImportJobResponse, DeduplicationStrategy, ExportFormat, ExportImportTemplate, ExportJobProgress, ExportJobStatus, ExportJobSummary, FieldMappingEntry, GetExportJobDownloadRequest, GetExportJobDownloadResponse, ImportJobProgress, ImportJobResults, ImportJobStatus, ImportJobSummary, ImportMapping, ImportRequest, ImportResponse, ImportRowResult, ImportValidationConfig, ImportValidationMode, ImportValidationResult, ImportWriteMode, ListExportJobsRequest, ListExportJobsResponse, ListImportJobsRequest, ListImportJobsResponse, ScheduleExportResponse, UndoImportJobResponse } from '@objectstack/spec/api'; // Validate data const result = CreateExportJobRequest.parse(data); @@ -64,6 +64,45 @@ const result = CreateExportJobRequest.parse(data); | **data** | `Object` | ✅ | | +--- + +## CreateImportJobRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **format** | `Enum<'csv' \| 'json' \| 'xlsx'>` | optional | Payload shape: csv text, a rows[] array, or a base64 xlsx (inferred when omitted) | +| **csv** | `string` | optional | CSV text (when format = csv) | +| **rows** | `Record[]` | optional | Row objects (when format = json) | +| **xlsxBase64** | `string` | optional | Base64-encoded .xlsx workbook bytes (when format = xlsx); parsed server-side | +| **sheet** | `string \| integer` | optional | Worksheet name or 1-based index to read (xlsx; defaults to the first sheet) | +| **mapping** | `Record \| Object[]` | optional | Source column → target field mapping | +| **dryRun** | `boolean` | ✅ | Validate + coerce every row without persisting | +| **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | ✅ | insert / update / upsert semantics | +| **matchFields** | `string[]` | optional | Fields that identify an existing record (required for update/upsert) | +| **runAutomations** | `boolean` | ✅ | Fire triggers/hooks for each imported row (off by default for bulk) | +| **trimWhitespace** | `boolean` | ✅ | Trim leading/trailing whitespace from string cells | +| **nullValues** | `string[]` | optional | Strings treated as null/blank (besides empty string) | +| **createMissingOptions** | `boolean` | ✅ | Keep unmatched select values instead of failing the row | +| **skipBlankMatchKey** | `boolean` | ✅ | Skip rows whose matchFields are blank (default: upsert creates them, update skips them) | + + +--- + +## CreateImportJobResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **jobId** | `string` | ✅ | Import job id — poll progress/results with this | +| **object** | `string` | ✅ | Target object name | +| **status** | `Enum<'pending' \| 'running' \| 'succeeded' \| 'failed' \| 'cancelled'>` | ✅ | Initial job status (usually "pending") | +| **total** | `integer` | ✅ | Rows accepted for processing | +| **createdAt** | `string` | ✅ | Job creation timestamp (ISO 8601) | + + --- ## DeduplicationStrategy @@ -198,6 +237,182 @@ const result = CreateExportJobRequest.parse(data); | **data** | `Object` | ✅ | | +--- + +## ImportJobProgress + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **jobId** | `string` | ✅ | Import job id | +| **object** | `string` | ✅ | Target object name | +| **status** | `Enum<'pending' \| 'running' \| 'succeeded' \| 'failed' \| 'cancelled'>` | ✅ | Current job status | +| **dryRun** | `boolean` | ✅ | Whether this is a validate-only pass | +| **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | ✅ | Write mode used | +| **total** | `integer` | ✅ | Total rows to process | +| **processed** | `integer` | ✅ | Rows processed so far | +| **created** | `integer` | ✅ | Rows that created a new record | +| **updated** | `integer` | ✅ | Rows that updated an existing record | +| **skipped** | `integer` | ✅ | Rows skipped | +| **errors** | `integer` | ✅ | Rows that failed | +| **percentComplete** | `number` | ✅ | processed / total as a percentage | +| **undoable** | `boolean` | ✅ | Whether this job can still be logically rolled back (undo log captured, terminal state, not yet reverted) | +| **revertedAt** | `string` | optional | When the job was undone / rolled back (ISO 8601) | +| **error** | `string` | optional | Fatal error message (when status = failed) | +| **startedAt** | `string` | optional | Processing start timestamp (ISO 8601) | +| **completedAt** | `string` | optional | Completion timestamp (ISO 8601) | +| **createdAt** | `string` | ✅ | Job creation timestamp (ISO 8601) | + + +--- + +## ImportJobResults + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **jobId** | `string` | ✅ | Import job id | +| **object** | `string` | ✅ | Target object name | +| **status** | `Enum<'pending' \| 'running' \| 'succeeded' \| 'failed' \| 'cancelled'>` | ✅ | Current job status | +| **dryRun** | `boolean` | ✅ | Whether this is a validate-only pass | +| **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | ✅ | Write mode used | +| **total** | `integer` | ✅ | Total rows to process | +| **processed** | `integer` | ✅ | Rows processed so far | +| **created** | `integer` | ✅ | Rows that created a new record | +| **updated** | `integer` | ✅ | Rows that updated an existing record | +| **skipped** | `integer` | ✅ | Rows skipped | +| **errors** | `integer` | ✅ | Rows that failed | +| **percentComplete** | `number` | ✅ | processed / total as a percentage | +| **undoable** | `boolean` | ✅ | Whether this job can still be logically rolled back (undo log captured, terminal state, not yet reverted) | +| **revertedAt** | `string` | optional | When the job was undone / rolled back (ISO 8601) | +| **error** | `string` | optional | Fatal error message (when status = failed) | +| **startedAt** | `string` | optional | Processing start timestamp (ISO 8601) | +| **completedAt** | `string` | optional | Completion timestamp (ISO 8601) | +| **createdAt** | `string` | ✅ | Job creation timestamp (ISO 8601) | +| **results** | `Object[]` | ✅ | Capped sample of per-row outcomes (failures first) | +| **resultsTruncated** | `boolean` | ✅ | Whether `results` is a capped sample of a larger set | + + +--- + +## ImportJobStatus + +### Allowed Values + +* `pending` +* `running` +* `succeeded` +* `failed` +* `cancelled` + + +--- + +## ImportJobSummary + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **jobId** | `string` | ✅ | Import job id | +| **object** | `string` | ✅ | Target object name | +| **status** | `Enum<'pending' \| 'running' \| 'succeeded' \| 'failed' \| 'cancelled'>` | ✅ | Job status | +| **total** | `integer` | ✅ | Total rows | +| **processed** | `integer` | ✅ | Rows processed | +| **created** | `integer` | ✅ | Rows created | +| **updated** | `integer` | ✅ | Rows updated | +| **skipped** | `integer` | ✅ | Rows skipped | +| **errors** | `integer` | ✅ | Rows failed | +| **createdAt** | `string` | ✅ | Job creation timestamp (ISO 8601) | +| **completedAt** | `string` | optional | Completion timestamp (ISO 8601) | +| **undoable** | `boolean` | ✅ | Whether this job can still be logically rolled back | +| **revertedAt** | `string` | optional | When the job was undone / rolled back (ISO 8601) | + + +--- + +## ImportMapping + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +Type: `Record` + +--- + +#### Option 2 + +Type: `Object[]` + +--- + + +--- + +## ImportRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **format** | `Enum<'csv' \| 'json' \| 'xlsx'>` | optional | Payload shape: csv text, a rows[] array, or a base64 xlsx (inferred when omitted) | +| **csv** | `string` | optional | CSV text (when format = csv) | +| **rows** | `Record[]` | optional | Row objects (when format = json) | +| **xlsxBase64** | `string` | optional | Base64-encoded .xlsx workbook bytes (when format = xlsx); parsed server-side | +| **sheet** | `string \| integer` | optional | Worksheet name or 1-based index to read (xlsx; defaults to the first sheet) | +| **mapping** | `Record \| Object[]` | optional | Source column → target field mapping | +| **dryRun** | `boolean` | ✅ | Validate + coerce every row without persisting | +| **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | ✅ | insert / update / upsert semantics | +| **matchFields** | `string[]` | optional | Fields that identify an existing record (required for update/upsert) | +| **runAutomations** | `boolean` | ✅ | Fire triggers/hooks for each imported row (off by default for bulk) | +| **trimWhitespace** | `boolean` | ✅ | Trim leading/trailing whitespace from string cells | +| **nullValues** | `string[]` | optional | Strings treated as null/blank (besides empty string) | +| **createMissingOptions** | `boolean` | ✅ | Keep unmatched select values instead of failing the row | +| **skipBlankMatchKey** | `boolean` | ✅ | Skip rows whose matchFields are blank (default: upsert creates them, update skips them) | + + +--- + +## ImportResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | Target object name | +| **dryRun** | `boolean` | ✅ | Whether this was a validate-only pass | +| **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | ✅ | Write mode used | +| **total** | `integer` | ✅ | Rows processed | +| **ok** | `integer` | ✅ | Rows that succeeded | +| **errors** | `integer` | ✅ | Rows that failed | +| **created** | `integer` | ✅ | Rows that created a new record | +| **updated** | `integer` | ✅ | Rows that updated an existing record | +| **skipped** | `integer` | ✅ | Rows skipped (no match in update mode, etc.) | +| **results** | `Object[]` | ✅ | Per-row outcomes | + + +--- + +## ImportRowResult + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **row** | `integer` | ✅ | 1-based row number in the source data | +| **ok** | `boolean` | ✅ | Whether the row succeeded | +| **action** | `Enum<'created' \| 'updated' \| 'skipped' \| 'failed'>` | ✅ | What happened to the row | +| **id** | `string` | optional | Record id (created/updated rows) | +| **field** | `string` | optional | Field that caused a coercion/validation error | +| **code** | `string` | optional | Error code (failed rows) | +| **error** | `string` | optional | Human-readable error message (failed rows) | + + --- ## ImportValidationConfig @@ -239,6 +454,17 @@ const result = CreateExportJobRequest.parse(data); | **data** | `Object` | ✅ | | +--- + +## ImportWriteMode + +### Allowed Values + +* `insert` +* `update` +* `upsert` + + --- ## ListExportJobsRequest @@ -267,6 +493,31 @@ const result = CreateExportJobRequest.parse(data); | **data** | `Object` | ✅ | | +--- + +## ListImportJobsRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | optional | Filter to one target object | +| **status** | `Enum<'pending' \| 'running' \| 'succeeded' \| 'failed' \| 'cancelled'>` | optional | Filter by job status | +| **limit** | `integer` | ✅ | Max rows to return | +| **offset** | `integer` | ✅ | Pagination offset | + + +--- + +## ListImportJobsResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **jobs** | `Object[]` | ✅ | Import jobs, newest first | + + --- ## ScheduleExportResponse @@ -283,3 +534,19 @@ const result = CreateExportJobRequest.parse(data); --- +## UndoImportJobResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **success** | `boolean` | ✅ | Whether the undo completed | +| **jobId** | `string` | ✅ | Import job id | +| **object** | `string` | ✅ | Target object name | +| **deleted** | `integer` | ✅ | Created records deleted | +| **restored** | `integer` | ✅ | Updated records restored to pre-import values | +| **failed** | `integer` | ✅ | Reversal operations that failed | + + +--- + diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index d1b8afc858..16b7c6e087 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -14,8 +14,8 @@ API Operations Enum ## TypeScript Usage ```typescript -import { ApiMethod, Index, ObjectAccessConfig, ObjectCapabilities, ObjectExternalBinding, ObjectOwnershipEnum, PartitioningConfig, SoftDeleteConfig, TenancyConfig, VersioningConfig } from '@objectstack/spec/data'; -import type { ApiMethod, Index, ObjectAccessConfig, ObjectCapabilities, ObjectExternalBinding, ObjectOwnershipEnum, PartitioningConfig, SoftDeleteConfig, TenancyConfig, VersioningConfig } from '@objectstack/spec/data'; +import { ApiMethod, Index, ObjectAccessConfig, ObjectCapabilities, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, SoftDeleteConfig, TenancyConfig, VersioningConfig } from '@objectstack/spec/data'; +import type { ApiMethod, Index, ObjectAccessConfig, ObjectCapabilities, ObjectExternalBinding, ObjectFieldGroup, ObjectOwnershipEnum, SoftDeleteConfig, TenancyConfig, VersioningConfig } from '@objectstack/spec/data'; // Validate data const result = ApiMethod.parse(data); @@ -109,26 +109,30 @@ External datasource binding (ADR-0015) --- -## ObjectOwnershipEnum +## ObjectFieldGroup -### Allowed Values +### Properties -* `own` -* `extend` +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. | +| **label** | `string` | ✅ | Group display label | +| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header | +| **description** | `string` | optional | Optional description shown under the group header | +| **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | ✅ | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). | +| **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. | +| **collapsible** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum. | +| **collapsed** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsible`; use the `collapse` enum. | --- -## PartitioningConfig +## ObjectOwnershipEnum -### Properties +### Allowed Values -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable table partitioning | -| **strategy** | `Enum<'range' \| 'hash' \| 'list'>` | ✅ | Partitioning strategy: range (date ranges), hash (consistent hashing), list (predefined values) | -| **key** | `string` | ✅ | Field name to partition by | -| **interval** | `string` | optional | Partition interval for range strategy (e.g., "1 month", "1 year") | +* `own` +* `extend` --- diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 2544554b34..f1086f7221 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -87,6 +87,8 @@ const result = FieldPermission.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Permission set unique name (lowercase snake_case) | | **label** | `string` | optional | Display label | +| **packageId** | `string` | optional | [ADR-0086 D3] Owning package id for a package-shipped set (absent = env-authored) | +| **managedBy** | `Enum<'package' \| 'platform' \| 'user'>` | optional | [ADR-0086 D3] Record provenance: package (upgrade-owned metadata) vs platform/user (env config) | | **isProfile** | `boolean` | ✅ | Whether this is a user profile | | **isDefault** | `boolean` | ✅ | [ADR-0056 D7] When true, this profile is the FALLBACK assigned to authenticated users who have no explicit grants — an app declares its default access posture here instead of relying on the built-in member_default. Foundation for SSO/JIT provisioning. | | **objects** | `Record` | ✅ | Entity permissions | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 8e6b17a972..a695731fb9 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -274,7 +274,7 @@ const result = AIChatWindowProps.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | ✅ | Number of columns for field layout (1-4) | -| **layout** | `Enum<'auto' \| 'custom'>` | ✅ | Layout mode: auto uses object highlightFields (formerly compactLayout), custom uses explicit sections | +| **layout** | `Enum<'auto' \| 'custom'>` | ✅ | Layout mode: auto uses object highlightFields, custom uses explicit sections | | **sections** | `string[]` | optional | Section IDs to show (required when layout is "custom") | | **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) | | **aria** | `Object` | optional | ARIA accessibility attributes | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 78d87ea58b..13aaa40028 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -353,6 +353,7 @@ User action toggles for the view toolbar | **filter** | `boolean` | ✅ | Allow users to filter records | | **rowHeight** | `boolean` | ✅ | Allow users to toggle row height/density | | **addRecordForm** | `boolean` | ✅ | Add records through a form instead of inline | +| **editInline** | `boolean` | ✅ | Allow users to edit records inline — click a cell to edit it with the field's type-aware widget (the same control the form uses). Off by default: the list is read-only unless the author opts in. | | **buttons** | `string[]` | optional | Custom action button IDs to show in the toolbar | diff --git a/examples/app-showcase/src/actions/index.ts b/examples/app-showcase/src/actions/index.ts index 90f00ce96d..f9cf1ddd98 100644 --- a/examples/app-showcase/src/actions/index.ts +++ b/examples/app-showcase/src/actions/index.ts @@ -117,8 +117,9 @@ export const LogTimeAction = defineAction({ icon: 'clock', objectName: task, type: 'form', - // Targets the `edit` form view — `showcase_task.default` is the LIST view - // (the container's main `list` implicitly claims the `default` key). + // Targets the `edit` FORM view. `showcase_task.default` is the LIST view (the + // container's main `list` implicitly claims the `default` key), so pointing a + // form action there opens a list as a form — now a build error (#2554). target: 'showcase_task.edit', // `record_section` so it surfaces in the Task Detail quick-actions bar too. locations: ['record_header', 'record_related', 'record_section'], diff --git a/examples/app-showcase/src/views/business-unit.view.ts b/examples/app-showcase/src/views/business-unit.view.ts index 9ee3f190ac..7762cbbe3d 100644 --- a/examples/app-showcase/src/views/business-unit.view.ts +++ b/examples/app-showcase/src/views/business-unit.view.ts @@ -37,7 +37,10 @@ export const BusinessUnitViews = defineView({ }, }, formViews: { - default: { + // `edit`, not `default`: the main `list` implicitly claims `.default` + // in the shared view namespace, so a `default` form key collides (build-time + // view-ref lint, framework #2554). + edit: { type: 'simple', data, sections: [ diff --git a/examples/app-showcase/src/views/project.view.ts b/examples/app-showcase/src/views/project.view.ts index 51aa1933bc..7baf77729a 100644 --- a/examples/app-showcase/src/views/project.view.ts +++ b/examples/app-showcase/src/views/project.view.ts @@ -129,7 +129,10 @@ export const ProjectViews = defineView({ }, }, formViews: { - default: { + // `edit`, not `default`: the main `list` implicitly claims `.default` + // in the shared view namespace, so a `default` form key collides (build-time + // view-ref lint, framework #2554). + edit: { type: 'simple', data, sections: [ diff --git a/examples/app-showcase/src/views/task.view.ts b/examples/app-showcase/src/views/task.view.ts index 34ac1ddee9..8d6d3b4c79 100644 --- a/examples/app-showcase/src/views/task.view.ts +++ b/examples/app-showcase/src/views/task.view.ts @@ -219,12 +219,12 @@ export const TaskViews = defineView({ }, formViews: { + // Keyed `edit`, NOT `default`: list and form views share one + // `.` namespace, and the main `list` implicitly claims + // `showcase_task.default`. A `default` form key collides — the build-time + // view-ref lint fails on it (framework #2554) instead of silently renaming + // it to `default_2` and breaking any action target that references it. // simple ── single-section form ────────────────────────────────────── - // Keyed `edit`, NOT `default`: the container's main `list` implicitly - // claims `showcase_task.default`, and list + form views share one - // `.` namespace — a `default` form key would be renamed to - // `default_2` at expansion (with a boot warning), breaking any action - // `target` that references it. See framework issue #2554. edit: { type: 'simple', data, diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 7df7d70aad..00fda324c8 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -14,6 +14,7 @@ import { validateResponsiveStyles } from '@objectstack/lint'; import { lintFlowPatterns } from '../utils/lint-flow-patterns.js'; import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js'; import { lintLivenessProperties } from '../utils/lint-liveness-properties.js'; +import { lintViewRefs } from '../utils/lint-view-refs.js'; import { collectAndLintDocs } from '../utils/collect-docs.js'; import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js'; import { @@ -290,6 +291,38 @@ export default class Compile extends Command { } } + // 3d-quater. View-reference lint (#2554) — resolves form action targets + // and view-key collisions at build time. A `type:'form'` target that + // names a missing view or a LIST view opens a broken/blank form at + // runtime; a list/form key collision silently renames one view so + // references resolve to the OTHER. Both are broken → fail the build. + // This shifts objectui's runtime `viewKind` guard left to compile. + const viewRefLint = lintViewRefs(result.data as Record); + const viewRefErrors = viewRefLint.filter((f) => f.severity === 'error'); + const viewRefWarnings = viewRefLint.filter((f) => f.severity === 'warning'); + if (viewRefErrors.length > 0) { + if (flags.json) { + console.log(JSON.stringify({ success: false, error: 'view reference validation failed', issues: viewRefErrors })); + this.exit(1); + } + console.log(''); + printError(`View reference validation failed (${viewRefErrors.length} issue${viewRefErrors.length > 1 ? 's' : ''})`); + for (const f of viewRefErrors) { + console.log(` • ${f.where}: ${f.message}`); + console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule}`)); + } + this.exit(1); + } + if (viewRefWarnings.length > 0 && !flags.json) { + console.log(''); + for (const f of viewRefWarnings) { + printWarning(`${f.where}: ${f.message}`); + console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule}`)); + } + } + // 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into // `docs: DocSchema[]` and lint the combined set (flatness, // namespace-prefixed names, MDX/image ban, same-package link diff --git a/packages/cli/src/utils/lint-view-refs.test.ts b/packages/cli/src/utils/lint-view-refs.test.ts new file mode 100644 index 0000000000..f7ec14e8f9 --- /dev/null +++ b/packages/cli/src/utils/lint-view-refs.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + lintViewRefs, + VIEW_KEY_COLLISION, + VIEW_REF_FORM_TARGET_MISSING, + VIEW_REF_FORM_TARGET_KIND, +} from './lint-view-refs.js'; + +const listView = (object: string) => ({ + type: 'grid', + label: 'All', + columns: ['title'], + data: { provider: 'object', object }, +}); +const formView = (object: string) => ({ + type: 'simple', + data: { provider: 'object', object }, + sections: [], +}); + +describe('lintViewRefs — clean paths', () => { + it('passes a container whose form key does not collide, with a correct form target', () => { + const stack = { + views: [{ name: 'task', list: listView('task'), formViews: { edit: formView('task') } }], + actions: [{ name: 'log_time', type: 'form', target: 'task.edit' }], + }; + expect(lintViewRefs(stack)).toEqual([]); + }); + + it('ignores non-form action types (their target is not a form-view ref)', () => { + const stack = { + views: [{ name: 'task', list: listView('task') }], + actions: [ + { name: 'open_docs', type: 'url', target: 'https://example.com' }, + { name: 'gallery', type: 'modal', target: 'some_modal' }, + ], + }; + expect(lintViewRefs(stack)).toEqual([]); + }); + + it('skips dynamic (interpolated) and non-qualified targets', () => { + const stack = { + views: [{ name: 'task', list: listView('task'), formViews: { edit: formView('task') } }], + actions: [ + { name: 'a', type: 'form', target: 'task.${param.view}' }, + { name: 'b', type: 'form', target: 'bare_key_no_dot' }, + ], + }; + expect(lintViewRefs(stack)).toEqual([]); + }); +}); + +describe('lintViewRefs — object name derivation (real defineView shape)', () => { + // `defineView({...})` containers carry NO top-level name/object — the object + // lives only in `list.data.object`. The lint must derive it exactly like the + // runtime loader, or every name-less container silently drops out of the index + // and its targets read as "missing" (the false negative found dogfooding + // app-showcase). + it('indexes a name-less container via list.data.object and accepts a good target', () => { + const stack = { + views: [{ list: listView('showcase_task'), formViews: { edit: formView('showcase_task') } }], + actions: [{ name: 'log_time', type: 'form', target: 'showcase_task.edit' }], + }; + expect(lintViewRefs(stack)).toEqual([]); + }); + + it('still flags a genuinely missing target on a name-less container', () => { + const stack = { + views: [{ list: listView('showcase_task'), formViews: { edit: formView('showcase_task') } }], + actions: [{ name: 'x', type: 'form', target: 'showcase_task.nope' }], + }; + expect(lintViewRefs(stack).some((f) => f.rule === VIEW_REF_FORM_TARGET_MISSING)).toBe(true); + }); +}); + +describe('lintViewRefs — view-key collisions (#2554)', () => { + it('warns (does NOT fail the build) when formViews.default collides with the implicit default list', () => { + const stack = { + views: [{ name: 'task', list: listView('task'), formViews: { default: formView('task') } }], + }; + const out = lintViewRefs(stack); + const collision = out.find((f) => f.rule === VIEW_KEY_COLLISION); + expect(collision).toBeDefined(); + // Fragile, not broken: a rename only breaks something if the name is referenced. + expect(collision!.severity).toBe('warning'); + expect(collision!.message).toContain("'task.default'"); + expect(collision!.message).toContain("'task.default_2'"); + }); + + it('detects the collision in object-nested listViews/formViews too', () => { + const stack = { + objects: [ + { + name: 'task', + listViews: { mine: listView('task') }, + formViews: { mine: formView('task') }, + }, + ], + }; + const out = lintViewRefs(stack); + expect(out.some((f) => f.rule === VIEW_KEY_COLLISION && f.message.includes("'task.mine'"))).toBe(true); + }); + + it('a collision-only stack yields NO error-severity finding — build is not blocked', () => { + const stack = { + views: [{ name: 'task', list: listView('task'), formViews: { default: formView('task') } }], + }; + const out = lintViewRefs(stack); + expect(out.some((f) => f.rule === VIEW_KEY_COLLISION)).toBe(true); // collision surfaced… + expect(out.some((f) => f.severity === 'error')).toBe(false); // …but nothing fails the build + }); +}); + +describe('lintViewRefs — form action target resolution', () => { + it('errors when a form target names a LIST view (the #2554 runtime symptom)', () => { + // `default` is the list; the form collides to `default_2`, so `task.default` + // resolves to the list — exactly what opened a blank form at runtime. + const stack = { + views: [{ name: 'task', list: listView('task'), formViews: { default: formView('task') } }], + actions: [{ name: 'log_time', type: 'form', target: 'task.default' }], + }; + const out = lintViewRefs(stack); + const kindErr = out.find((f) => f.rule === VIEW_REF_FORM_TARGET_KIND); + expect(kindErr).toBeDefined(); + expect(kindErr!.severity).toBe('error'); + expect(kindErr!.message).toContain('list view, not a form view'); + }); + + it('warns (possible false positive, does NOT fail) when a form target resolves to no view at all', () => { + const stack = { + views: [{ name: 'task', list: listView('task'), formViews: { edit: formView('task') } }], + actions: [{ name: 'log_time', type: 'form', target: 'task.nope' }], + }; + const out = lintViewRefs(stack); + const missWarn = out.find((f) => f.rule === VIEW_REF_FORM_TARGET_MISSING); + expect(missWarn).toBeDefined(); + // Might be a view the lint failed to collect — warn rather than break the build. + expect(missWarn!.severity).toBe('warning'); + }); + + it('accepts a form target that resolves to an actual form view', () => { + const stack = { + views: [{ name: 'task', list: listView('task'), formViews: { edit: formView('task') } }], + actions: [{ name: 'log_time', type: 'form', target: 'task.edit' }], + }; + expect(lintViewRefs(stack).filter((f) => f.rule.startsWith('view-ref'))).toEqual([]); + }); + + it('validates object-nested actions against object-nested form views', () => { + const stack = { + objects: [ + { + name: 'task', + formViews: { edit: formView('task') }, + actions: [{ name: 'nested_form', type: 'form', target: 'task.missing' }], + }, + ], + }; + const out = lintViewRefs(stack); + expect(out.some((f) => f.rule === VIEW_REF_FORM_TARGET_MISSING && f.where.includes("object 'task'"))).toBe(true); + }); + + it('reports a shared (top-level + object-nested) action only once', () => { + const shared = { name: 'log_time', type: 'form', target: 'task.missing' }; + const stack = { + views: [{ name: 'task', list: listView('task'), formViews: { edit: formView('task') } }], + actions: [shared], + objects: [{ name: 'task', actions: [shared] }], + }; + const out = lintViewRefs(stack).filter((f) => f.rule === VIEW_REF_FORM_TARGET_MISSING); + expect(out).toHaveLength(1); + expect(out[0].where).toContain("object 'task'"); // object-nested context retained + }); +}); diff --git a/packages/cli/src/utils/lint-view-refs.ts b/packages/cli/src/utils/lint-view-refs.ts new file mode 100644 index 0000000000..9d48795b1f Binary files /dev/null and b/packages/cli/src/utils/lint-view-refs.ts differ diff --git a/packages/metadata/src/view-expand.test.ts b/packages/metadata/src/view-expand.test.ts index d9c106e471..93ded92900 100644 --- a/packages/metadata/src/view-expand.test.ts +++ b/packages/metadata/src/view-expand.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { expandViewContainer, isAggregatedViewContainer } from './plugin.js'; +import { expandViewContainerWithDiagnostics } from '@objectstack/spec'; // Mirrors examples/app-crm/src/views/lead.view.ts — the canonical case where // the default `list` and `listViews.all` are structurally identical (the @@ -104,6 +105,46 @@ describe('expandViewContainer — default list with no listViews dup', () => { }); }); +describe('expandViewContainerWithDiagnostics — collision capture (#2554)', () => { + it('is behaviour-preserving: items match expandViewContainer, no false collisions', () => { + const { items, collisions } = expandViewContainerWithDiagnostics('crm_lead', leadContainer); + // The deduped lead container has no real collision (list folds into `all`, + // so `default` is free for the form). + expect(collisions).toEqual([]); + expect(items.map((i) => i.name)).toEqual( + expandViewContainer('crm_lead', leadContainer).map((i) => i.name), + ); + }); + + it('captures a formViews key colliding with the implicit default list', () => { + const { items, collisions } = expandViewContainerWithDiagnostics('task', { + list: { type: 'grid', label: 'All', columns: ['title'], data: { provider: 'object', object: 'task' } }, + formViews: { default: { type: 'simple', data: { provider: 'object', object: 'task' }, sections: [] } }, + }); + // Rename behaviour is unchanged (backward compat): list keeps the name, + // the form loses and is renamed. + expect(items.find((i) => i.viewKind === 'list')!.name).toBe('task.default'); + expect(items.find((i) => i.viewKind === 'form')!.name).toBe('task.default_2'); + // …and the collision is captured, naming both sides + the losing kind. + expect(collisions).toHaveLength(1); + expect(collisions[0]).toMatchObject({ + requested: 'task.default', + renamedTo: 'task.default_2', + viewKind: 'form', + key: 'default', + }); + }); + + it('captures a formViews key colliding with a listViews key', () => { + const { collisions } = expandViewContainerWithDiagnostics('task', { + listViews: { mine: { type: 'grid', label: 'Mine', columns: ['title'], data: { provider: 'object', object: 'task' } } }, + formViews: { mine: { type: 'simple', data: { provider: 'object', object: 'task' }, sections: [] } }, + }); + expect(collisions.map((c) => c.requested)).toContain('task.mine'); + expect(collisions.find((c) => c.requested === 'task.mine')!.viewKind).toBe('form'); + }); +}); + describe('expandViewContainer — name collisions carry _diagnostics warnings (#2554)', () => { it('warns when formViews.default collides with the implicit default list', () => { const items = expandViewContainer('task', { diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 3bb3f4019e..397f00cbf2 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -20,6 +20,7 @@ "EvalUser (type)", "EvalUserInput (type)", "EvalUserSchema (const)", + "ExpandViewResult (interface)", "ExpandedViewItem (interface)", "Expression (type)", "ExpressionDialect (type)", @@ -55,6 +56,7 @@ "Skill (type)", "TemplateExpressionInputSchema (const)", "Tool (type)", + "ViewKeyCollision (interface)", "cel (function)", "composeStacks (function)", "createEvalUser (function)", @@ -86,6 +88,7 @@ "defineViewItem (function)", "defineWebhook (function)", "expandViewContainer (function)", + "expandViewContainerWithDiagnostics (function)", "expression (function)", "findClosestMatches (function)", "formatSuggestion (function)", @@ -3149,6 +3152,7 @@ "EmbedConfigSchema (const)", "EvictionPolicy (type)", "EvictionPolicySchema (const)", + "ExpandViewResult (interface)", "ExpandedViewItem (interface)", "FieldWidgetProps (type)", "FieldWidgetPropsSchema (const)", @@ -3382,6 +3386,7 @@ "ViewItem (type)", "ViewItemNameSchema (const)", "ViewItemSchema (const)", + "ViewKeyCollision (interface)", "ViewKind (type)", "ViewKindSchema (const)", "ViewSchema (const)", @@ -3426,6 +3431,7 @@ "defineView (function)", "defineViewItem (function)", "expandViewContainer (function)", + "expandViewContainerWithDiagnostics (function)", "isAggregatedViewContainer (function)", "pageForm (const)", "reportForm (const)", diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index ea76a79fc9..60736b3a5f 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -71,8 +71,8 @@ export type { DefineStackOptions, ComposeStacksOptions, ConflictStrategy, Object export * from './stack.zod'; // DX Helper Functions (re-exported for convenience) -export { defineView, defineForm, defineViewItem, isAggregatedViewContainer, expandViewContainer } from './ui/view.zod'; -export type { ExpandedViewItem } from './ui/view.zod'; +export { defineView, defineForm, defineViewItem, isAggregatedViewContainer, expandViewContainer, expandViewContainerWithDiagnostics } from './ui/view.zod'; +export type { ExpandedViewItem, ViewKeyCollision, ExpandViewResult } from './ui/view.zod'; export { defineApp } from './ui/app.zod'; export { defineFlow } from './automation/flow.zod'; export { defineJob } from './system/job.zod'; diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 512a0d9030..74d93c4034 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1199,17 +1199,50 @@ function cloneViewConfig(v: any): any { } /** - * Expand an aggregated view container into independent ViewItems. + * A view-key collision detected while expanding one `defineView` container. + * + * List and form views share a single `.` namespace during + * expansion (see {@link expandViewContainer}), and the default `list` + * implicitly claims `.default`. When a later view competes for a name + * already taken, it is renamed (`.` → `._2`) to keep + * the runtime registry key unique. That rename is a silent footgun: references + * to the requested name (form action `target`s, navigation `viewName`s) resolve + * to the OTHER view. The build-time view-ref lint turns each collision into a + * hard error so the author fixes the key instead of shipping a broken reference. + */ +export interface ViewKeyCollision { + /** The `.` name that was already registered by an earlier view. */ + requested: string; + /** The disambiguated name the colliding view was renamed to (e.g. `…_2`). */ + renamedTo: string; + /** Which family the renamed (losing) view belongs to. */ + viewKind: 'list' | 'form'; + /** The raw author-supplied key that collided. */ + key: string; +} + +/** Result of {@link expandViewContainerWithDiagnostics}. */ +export interface ExpandViewResult { + items: ExpandedViewItem[]; + collisions: ViewKeyCollision[]; +} + +/** + * Expand an aggregated view container into independent ViewItems, ALSO reporting + * every name collision that forced a rename. * * List family: `listViews` entries first (keys taken from the author), then the * default `list` — deduped by structural signature so a `listViews.all` that * merely restates `list` collapses into one item. The view matching the - * declared default is flagged `isDefault`. + * declared default is flagged `isDefault`. Form family: `formViews` entries, + * then the default `form`. * - * Form family: `formViews` entries, then the default `form`. + * Collisions are captured at the exact points the shared `used` set forces a + * rename, so the diagnostic can never drift from the expansion it describes. */ -export function expandViewContainer(object: string, container: any): ExpandedViewItem[] { +export function expandViewContainerWithDiagnostics(object: string, container: any): ExpandViewResult { const out: ExpandedViewItem[] = []; + const collisions: ViewKeyCollision[] = []; const used = new Set(); let order = 0; @@ -1221,6 +1254,7 @@ export function expandViewContainer(object: string, container: any): ExpandedVie if (!v || typeof v !== 'object') continue; const requested = `${object}.${k}`; const name = uniqueViewName(requested, used); + if (name !== requested) collisions.push({ requested, renamedTo: name, viewKind: 'list', key: k }); listSigToName.set(viewSignature(v), name); const item: ExpandedViewItem = { name, object, viewKind: 'list', label: v.label, config: cloneViewConfig(v), order: order++, scope: 'package' }; stampRenameWarning(item, requested); @@ -1236,6 +1270,7 @@ export function expandViewContainer(object: string, container: any): ExpandedVie const key = typeof defaultList.name === 'string' && defaultList.name ? defaultList.name : 'default'; const requested = `${object}.${key}`; const name = uniqueViewName(requested, used); + if (name !== requested) collisions.push({ requested, renamedTo: name, viewKind: 'list', key }); const item: ExpandedViewItem = { name, object, viewKind: 'list', label: defaultList.label, config: cloneViewConfig(defaultList), order: order++, scope: 'package' }; stampRenameWarning(item, requested); out.push(item); @@ -1256,6 +1291,7 @@ export function expandViewContainer(object: string, container: any): ExpandedVie if (!v || typeof v !== 'object') continue; const requested = `${object}.${k}`; const name = uniqueViewName(requested, used); + if (name !== requested) collisions.push({ requested, renamedTo: name, viewKind: 'form', key: k }); formSigSeen.add(viewSignature(v)); const item: ExpandedViewItem = { name, object, viewKind: 'form', label: v.label, config: cloneViewConfig(v), order: order++, scope: 'package' }; stampRenameWarning(item, requested); @@ -1267,6 +1303,7 @@ export function expandViewContainer(object: string, container: any): ExpandedVie const key = typeof defaultForm.name === 'string' && defaultForm.name ? defaultForm.name : 'form'; const requested = `${object}.${key}`; const name = uniqueViewName(requested, used); + if (name !== requested) collisions.push({ requested, renamedTo: name, viewKind: 'form', key }); const item: ExpandedViewItem = { name, object, viewKind: 'form', label: defaultForm.label, config: cloneViewConfig(defaultForm), order: order++, scope: 'package' }; stampRenameWarning(item, requested); out.push(item); @@ -1277,7 +1314,18 @@ export function expandViewContainer(object: string, container: any): ExpandedVie if (out[i].name === defaultFormName) out[i].isDefault = true; } - return out; + return { items: out, collisions }; +} + +/** + * Expand an aggregated view container into independent ViewItems. + * + * Thin wrapper over {@link expandViewContainerWithDiagnostics} that discards the + * collision diagnostics — the shape every runtime loader consumes. Behaviour is + * unchanged: colliding names are still renamed so the registry key stays unique. + */ +export function expandViewContainer(object: string, container: any): ExpandedViewItem[] { + return expandViewContainerWithDiagnostics(object, container).items; } /**