diff --git a/.changeset/24640-list-view-filterable-fields-retired.md b/.changeset/24640-list-view-filterable-fields-retired.md new file mode 100644 index 00000000000..5fb4e03f701 --- /dev/null +++ b/.changeset/24640-list-view-filterable-fields-retired.md @@ -0,0 +1,99 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: retire the list view's `filterableFields` — declared as the `userFilters.fields` shorthand, read as a Filter-panel whitelist; no per-view whitelist replaces it + +**BREAKING** — `filterableFields` is removed from the list view +(`ListViewSchema`, `ObjectListViewSchema` — a `defineView` container's +`list` / `listViews`, an object's `listViews` — a view item record's list +`config`, the flattened list overlay the `PUT /api/v1/meta/view` door accepts, +and a page `list-view` node's `properties`). ADR-0049 enforce-or-remove; +maintainer ruling C: the platform carries no per-view Filter-panel whitelist. + +The key carried two meanings, and the declared one was never delivered. The +spec described it as the legacy shorthand for `userFilters.fields` — the +quick-filter bar above a list — and the Studio view form offered it as "Field +names available for filtering". Its one renderer, objectui's list view, read it +as something else: the whitelist of fields the Filter panel's "Add filter" +offers, a field the object marks `hidden` included when named. No quick-filter +chip ever came of it. Zero list views in this repo's examples, app packages, +platform objects, scaffolding templates or QA apps authored the key. + +### FROM → TO + +| removed | what to write instead | +| --- | --- | +| `filterableFields: ['status', 'priority']` meant as a curated end-user filter bar | `userFilters: { element: 'dropdown', fields: [{ field: 'status' }, { field: 'priority' }] }` | +| `filterableFields: [...]` meant to narrow the Filter panel | nothing — the Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered | + +**The one-line fix: delete `filterableFields:` from every list view; if you +wanted a curated filter bar, declare `userFilters` with those fields.** `os +migrate meta --from 17` lists the mechanical edits for existing sources; +`--write` applies the ones it can prove. + +```ts +// before — parsed clean; the console used it to narrow "Add filter" +defineView({ + list: { + type: 'grid', columns: ['subject', 'status'], + filterableFields: ['status'], + }, +}); +// after — a quick-filter bar for `status`; "Add filter" offers every readable field +defineView({ + list: { + type: 'grid', columns: ['subject', 'status'], + userFilters: { element: 'dropdown', fields: [{ field: 'status' }] }, + }, +}); +``` + +**Stored rows.** A stored `view` row that carries the key replays without it at +every rehydration seam (the D2 conversion below DROPS it), so it re-saves +through the strict write door, and its Filter panel then offers every field the +user may read — the ruled end state, not a rewrite. ⛔ No conversion turns the +key into `userFilters.fields`: that would keep nothing an author saw (the +whitelist would vanish and quick-filter chips would appear). An object row +whose own `listViews` carries the key is reached by no conversion and is +refused at its door until edited by hand. + +⛔ **Untouched:** `userFilters` and `searchableFields` keep their meaning at +every door. + +### The retirement kit + +- **A `retiredKey()` tombstone on the list-view shape**, beside + `searchableFields`. Every door built from it refuses: `tsc` types the key + `never`, and the parse raises the prescription (delete the key; `userFilters` + for a curated bar; what the Filter panel offers) instead of a bare + unknown-key report. +- **D2 conversion `view-list-filterable-fields-removed`** (protocol 18, retired + from the load path): strips `filterableFields` from every list payload in + `stack.views[]`, in all three persisted spellings. +- **D3 entry `list-view-filterable-fields-retired`** beside it, carrying the + part no conversion can decide: whether the author wanted a curated + `userFilters` bar. +- **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ListView:filterableFields`, + `ui/ObjectListView:filterableFields`; both `authorable-surface/ui.json` rows + become `[RETIRED]`. +- **The Studio view form's `filterableFields` input** leaves with the key, and + the extracted form-label bundles are regenerated. +- **The liveness row flips `live` → `dead`**, re-measured, with a REMOVED note + naming the one reader the ruling makes unreachable. +- **The architecture concept page's list-view example** now declares a + `userFilters` bar instead of the retired key. +- **Pins** (`ui/view-list-filterable-fields-retirement.test.ts`): the refusal, + its issue code, path and prescription at eight doors, each with a lit + control and a `userFilters` / `searchableFields` boundary; the tsc channel; + the conversion's reach, its never-a-rewrite guarantee, boundary and + idempotence; the D2/D3 registration; and a tree-scoped absence walk over the + declared radius. +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` +is published, so this is breaking for consumers no telemetry was consulted for. + +Clause-②: yes (narrowing) + + diff --git a/content/docs/concepts/architecture.mdx b/content/docs/concepts/architecture.mdx index 8d5de14cc76..bbe663d2b18 100644 --- a/content/docs/concepts/architecture.mdx +++ b/content/docs/concepts/architecture.mdx @@ -242,7 +242,8 @@ export const CustomerView = defineView({ { field: 'annual_revenue' }, { field: 'primary_contact' }, ], - filterableFields: ['industry', 'annual_revenue'], + // A quick-filter bar above the list: end users pick an industry to narrow the rows. + userFilters: { element: 'dropdown', fields: [{ field: 'industry' }] }, sort: [{ field: 'name', order: 'asc' }], }, }); diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 1effa27f697..893a5685b52 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1831,7 +1831,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | -| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | +| **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | @@ -1917,7 +1917,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | -| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | +| **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index bf1245a1591..33a087ede94 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -377,7 +377,7 @@ const result = ApiMethod.parse(data); | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | -| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | +| **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index ff5db8274f0..c7d19516020 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -773,7 +773,7 @@ Map view configuration | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | -| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | +| **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields | | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | @@ -1160,7 +1160,7 @@ View filter rule | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | -| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | +| **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | @@ -1737,7 +1737,7 @@ Tab configuration for multi-tab view interface | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | -| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | +| **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | @@ -1823,7 +1823,7 @@ Tab configuration for multi-tab view interface | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | -| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | +| **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | @@ -2032,7 +2032,7 @@ This schema accepts one of the following structures: | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | -| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | +| **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields | | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | @@ -2211,7 +2211,7 @@ This schema accepts one of the following structures: | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | -| **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | +| **filterableFields** | `never` | optional | [REMOVED] `view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare `userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks `hidden: true` is not offered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields | | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | 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 c159d7b286a..9915f9d164d 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 @@ -1109,10 +1109,6 @@ export const enMetadataForms: NonNullable = { label: "Searchable Fields", helpText: "Field names available for quick search" }, - filterableFields: { - label: "Filterable Fields", - helpText: "Field names available for filtering" - }, resizable: { label: "Resizable" }, 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 ccb9bc93677..89fa8bbea09 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 @@ -1109,10 +1109,6 @@ export const esESMetadataForms: NonNullable = label: "Campos buscables", helpText: "Nombres de campo disponibles para búsqueda rápida" }, - filterableFields: { - label: "Campos filtrables", - helpText: "Nombres de campo disponibles para filtrado" - }, resizable: { label: "Redimensionable" }, 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 4f0f16329fb..5650f54f428 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 @@ -1109,10 +1109,6 @@ export const jaJPMetadataForms: NonNullable = label: "検索対象フィールド", helpText: "クイック検索で使用可能なフィールド名" }, - filterableFields: { - label: "フィルター対象フィールド", - helpText: "フィルターで使用可能なフィールド名" - }, resizable: { label: "サイズ変更可" }, 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 460a165d90a..4493e7398d3 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 @@ -1204,7 +1204,11 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // its label — authored in all three locales — out of the catalog. // 674 since ADR-0131 D6: `sharing_rule` joined the registry, so the catalog // gains its type-level display pair — authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(674); + // 673 since #24640: the view form's `filterableFields` input left with its + // key (retired — declared as the `userFilters.fields` shorthand, read as a + // Filter-panel whitelist), taking its label — authored in all three + // locales — out of the catalog. + expect(translated.length, `${locale} positive control`).toBe(673); } // ⭐ 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 466dfce126a..eee06b25d1e 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 @@ -1109,10 +1109,6 @@ export const zhCNMetadataForms: NonNullable = label: "可搜索字段", helpText: "可用于快速搜索的字段名" }, - filterableFields: { - label: "可筛选字段", - helpText: "可用于筛选的字段名" - }, resizable: { label: "可调整大小" }, diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 79090a3d02a..44d7ae5d700 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -754,7 +754,7 @@ "ui/ListView:exportOptions", "ui/ListView:fieldOrder", "ui/ListView:filter", - "ui/ListView:filterableFields", + "ui/ListView:filterableFields [RETIRED]", "ui/ListView:gallery", "ui/ListView:gantt", "ui/ListView:grouping", @@ -941,7 +941,7 @@ "ui/ObjectListView:exportOptions", "ui/ObjectListView:fieldOrder", "ui/ObjectListView:filter", - "ui/ObjectListView:filterableFields", + "ui/ObjectListView:filterableFields [RETIRED]", "ui/ObjectListView:gallery", "ui/ObjectListView:gantt", "ui/ObjectListView:grouping", diff --git a/packages/spec/liveness/state-counts/view.md b/packages/spec/liveness/state-counts/view.md index f13cfe12089..49b06966f2e 100644 --- a/packages/spec/liveness/state-counts/view.md +++ b/packages/spec/liveness/state-counts/view.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `view` | 79 | 0 | 0 | 11 | 0 | 90 | +| `view` | 78 | 0 | 0 | 12 | 0 | 90 | diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 60737e436b9..26ab42e80e0 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -57,8 +57,10 @@ "note": "objectui: ListView.tsx (audit L15)." }, "filterableFields": { - "status": "live", - "note": "objectui: ListView.tsx / UserFilters.tsx (audit L15)." + "status": "dead", + "verifiedAt": "2026-10-19", + "evidenceScope": "cross-repo", + "note": "REMOVED 2026-10-19 (#24640, ADR-0049 enforce-or-remove; maintainer ruling C, record 6278975049, pulled into 18.0 by record 6279033298) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error at every list-view door built from the shape: ListViewSchema, ObjectListViewSchema, the flattened overlay arm and a page list-view node's properties; a react page's props are an opaque source string no door parses against the shape, a stated boundary) and dropped from stored rows and sources by the protocol-18 view-list-filterable-fields-removed conversion, which never rewrites it to userFilters.fields. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); for a curated end-user filter bar declare `userFilters`, and the Filter panel offers every field the user may read (field read security decides; a field the object marks `hidden: true` is not offered) — no per-view whitelist replaces it. The prior `live` verdict (audit L15) named the wrong reader for the declared meaning: the spec described the key as the `userFilters.fields` shorthand, and UserFilters never read it. RE-MEASURED 2026-10-19 before removal, at objectui's pinned sha 4016370a and re-read 2026-10-20 at the moved pin 12dc7b47 (same readers, same lines within a few): the one reader was packages/plugin-list/src/ListView.tsx (the `filterFields` memo), which used it as the Filter panel's field WHITELIST (the fields Add filter offers, a hidden field included when named), fed by packages/app-shell/src/views/ObjectView.tsx (`filterableFields: viewDef.filterableFields ?? listSchema.filterableFields`) and packages/plugin-view/src/ObjectView.tsx; a page list-view node withheld it (packages/plugin-list/src/index.tsx). The ruling is that the platform carries no per-view Filter-panel whitelist, so that reader is unreachable from authored metadata once this lands and leaves in objectui's own follow-up; objectui's types mirror declares the key locally (types/src/objectql.ts) and its zod twin reads the spec shape's member by reference, so the tombstone flows in on the pin bump. In this repo: zero authorings in examples/, packages/apps, packages/platform-objects, packages/create-objectstack or packages/qa — CONTROL on the same instrument: `userFilters` 7 hits in examples/, `searchableFields` 2. The Studio view form's input left with the key." }, "resizable": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 098fbfc8370..dc53cdff704 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -15794,6 +15794,118 @@ const viewOverlayOwnerHiddenRemoved: MetadataConversion = { }, }; +/** + * The list view's `filterableFields` leaves the authorable surface (protocol + * 18, #24640 — ADR-0049 enforce-or-remove; maintainer ruling C, record + * 6278975049, pulled into 18.0 by record 6279033298). + * + * The key carried two meanings: `ListViewSchema` described it as the legacy + * shorthand for `userFilters.fields` (the quick-filter bar), and its one + * renderer — objectui's `ListView`, the `filterFields` memo, fed by the app + * shell's object view — read it as the Filter panel's field WHITELIST, the + * fields "Add filter" offers (a hidden field included when named). The ruling + * is that the platform carries no per-view Filter-panel whitelist, so the key + * retires with no replacement key. + * + * **A DROP, never a rewrite.** ⛔ This conversion must not rewrite the key to + * `userFilters.fields`: measured on the seat's correction (record + * 6278907207), that keeps nothing an author saw — the whitelist would vanish + * and quick-filter chips would appear. It strips the key, the + * `view-list-passthrough-keys-removed` shape, and the D3 entry + * `list-view-filterable-fields-retired` carries the judgement it cannot make + * (whether the author wanted a curated filter bar, which is `userFilters`). + * Unlike that precedent the delete is NOT lossless in pixels: a stored view + * that named a whitelist has its Filter panel widen to every field the user + * may read, minus the fields the object marks `hidden` — the ruled end state. + * + * **Retired from the load path** — the key is a `retiredKey()` tombstone on + * the list-view shape, so a live author is refused at parse with the + * prescription. The entry exists because a stored `view` row CAN carry the + * key: the Studio view form offered it as an input and every list-view door + * accepted and persisted it until this release, and + * `applyConversionsToStoredItem` replays the chain over stored `view` rows as + * `{ views: [row] }`. Without it such a row would carry the retired key back + * to the strict write door, which refuses it on the next save. It also lets + * `os migrate meta --from 17` list the mechanical edits for existing sources. + * `stripKeys` deletion is idempotent by construction. + * + * ⚠️ Coverage boundary, stated rather than left to be discovered: this walks + * `stack.views[]` in all three persisted spellings ({@link mapViewPayloads}), + * the reach `view-list-tabs-removed` has. `objects[].listViews.*` is reached + * by no conversion in this registry, so an object body carrying the key is + * refused at its own door rather than converted. Measured population for + * both: zero authored `filterableFields` in this tree's examples, platform + * objects, app packages, scaffolding templates or QA apps. + */ +const viewListFilterableFieldsRemoved: MetadataConversion = { + id: 'view-list-filterable-fields-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'view.list.filterableFields / view.listViews.*.filterableFields — the list view\'s Filter-panel field whitelist', + summary: + "list-view key 'filterableFields' removed (ADR-0049 enforce-or-remove — declared as a shorthand " + + 'for `userFilters.fields` while its one renderer read it as a whitelist of the fields the Filter ' + + 'panel offers; no per-view whitelist replaces it: a curated end-user filter bar is `userFilters`, ' + + 'and the Filter panel offers every field the user may read)', + apply(stack, emit) { + return mapViewPayloads(stack, (payload, kind, path) => + kind === 'list' ? stripKeys(payload, ['filterableFields'], emit, path) : payload); + }, + fixture: { + before: { + views: [ + // A container: the default `list` and one named entry carry the key, + // the other named entry does not and rides through by reference. + // `searchableFields` beside it survives — a different key that stays. + { + object: 'crm_ticket', + list: { + type: 'grid', + columns: ['subject'], + searchableFields: ['subject'], + filterableFields: ['status', 'priority'], + }, + listViews: { + triage: { type: 'grid', columns: ['subject'], filterableFields: ['status'] }, + all: { type: 'grid', columns: ['subject'] }, + }, + }, + // A ViewItem record: the payload hangs off `config`. + { + name: 'crm_ticket.queue', + object: 'crm_ticket', + viewKind: 'list', + config: { type: 'grid', columns: ['subject'], filterableFields: [] }, + }, + // A form payload has no `filterableFields` key to strip — untouched. + { name: 'crm_ticket.intake', object: 'crm_ticket', viewKind: 'form', config: { type: 'simple' } }, + ], + }, + after: { + views: [ + { + object: 'crm_ticket', + list: { type: 'grid', columns: ['subject'], searchableFields: ['subject'] }, + listViews: { + triage: { type: 'grid', columns: ['subject'] }, + all: { type: 'grid', columns: ['subject'] }, + }, + }, + { + name: 'crm_ticket.queue', + object: 'crm_ticket', + viewKind: 'list', + config: { type: 'grid', columns: ['subject'] }, + }, + { name: 'crm_ticket.intake', object: 'crm_ticket', viewKind: 'form', config: { type: 'simple' } }, + ], + }, + // One notice per key removed: `list`, `listViews.triage`, the record's `config`. + expectedNotices: 3, + }, +}; + /** * The list view's own `tabs` leaves the authorable surface (protocol 18, * #20301 — ADR-0049 enforce-or-remove; triage verdict RETIRE under the @@ -17606,6 +17718,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: translationWidgetSubCaptionRemoved, order: 55 }, { conversion: tursoConfigTimeoutToTimeoutMs, order: 28 }, { conversion: viewItemOwnerHiddenRemoved, order: 37 }, + { conversion: viewListFilterableFieldsRemoved, order: 71 }, { conversion: viewListTabsRemoved, order: 46 }, { conversion: viewOverlayOwnerHiddenRemoved, order: 39 }, { conversion: viewPageMountRemoved, order: 29 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ListView__filterableFields.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ListView__filterableFields.ts new file mode 100644 index 00000000000..f6f76a9d54a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ListView__filterableFields.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #24640 (ADR-0049 enforce-or-remove; maintainer ruling C, record 6278975049, +// pulled into 18.0 by record 6279033298). `ListView.filterableFields` carried +// two meanings: the schema described it as the legacy shorthand for +// `userFilters.fields` (the quick-filter bar), and its one renderer, objectui's +// `ListView` (the `filterFields` memo), read it as the Filter panel's field +// whitelist. No quick filter ever came of it. The ruling is that the platform +// carries no per-view Filter-panel whitelist, so the key retires with no +// replacement key: a curated end-user filter bar is `userFilters`, and the +// Filter panel offers every field the user may read. Tombstoned with +// `retiredKey()` beside `searchableFields` on this shape. D2: +// `view-list-filterable-fields-removed`, a drop that never rewrites the key to +// `userFilters.fields`. +export const entry = 'ui/ListView:filterableFields'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectListView__filterableFields.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectListView__filterableFields.ts new file mode 100644 index 00000000000..c7652a215ac --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectListView__filterableFields.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #24640 — the `ObjectListViewSchema` copy of `ui/ListView:filterableFields`: +// the def is `ListViewShapeSchema.omit({ userFilters }).extend(…)`, so the +// tombstone lands in this walked shape too and `authorable-surface/` marks it +// `[RETIRED]` separately. Registered per key, as gate (b) reads them — nothing +// radiates from the base. An object's own `listViews` is reached by no +// conversion, so an object body carrying the key is refused at its door. D2: +// `view-list-filterable-fields-removed`, a drop that never rewrites the key to +// `userFilters.fields`. +export const entry = 'ui/ObjectListView:filterableFields'; diff --git a/packages/spec/src/migrations/entries/semantic/18.list-view-filterable-fields-retired.ts b/packages/spec/src/migrations/entries/semantic/18.list-view-filterable-fields-retired.ts new file mode 100644 index 00000000000..12b37f05afe --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.list-view-filterable-fields-retired.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #24640 (ADR-0049 enforce-or-remove; maintainer ruling C, record 6278975049, +// pulled into 18.0 by record 6279033298) — the D3 entry of the +// `view-list-filterable-fields-removed` family (ruling B on #17152: one D3 +// entry per retirement family). Registered keys: `ui/ListView:filterableFields` +// and `ui/ObjectListView:filterableFields`. Unlike the `tabs` retirement this +// strip DOES change a screen — a stored whitelist stops narrowing the Filter +// panel — and the conversion cannot know whether the author meant that panel +// or the quick-filter bar the key was declared as; that is the judgement owed. +export const entry: SemanticMigration = { + id: 'list-view-filterable-fields-retired', + surface: 'view.list.filterableFields / view.listViews.*.filterableFields — the list view\'s Filter-panel field whitelist', + replacement: 'Nothing replaces the whitelist: the platform carries no per-view Filter-panel ' + + 'whitelist. For a curated end-user filter bar above the list, declare `userFilters` — ' + + '`userFilters: { element: \'dropdown\', fields: [{ field: \'\' }] }` — on the ' + + 'list view. The Filter panel offers every field the user may read: field read security ' + + 'decides, and a field the object definition marks `hidden: true` is not offered.', + reason: 'The D2 conversion `view-list-filterable-fields-removed` deletes `filterableFields` from ' + + 'every list payload in `stack.views[]`, in all three persisted spellings. It never rewrites ' + + 'the key to `userFilters.fields`, because that would keep nothing an author saw: the key was ' + + 'declared as the quick-filter shorthand, while the one renderer that read it used it as a ' + + 'whitelist of the fields the Filter panel offers, so a rewrite would make the whitelist ' + + 'vanish AND quick-filter chips appear. The delete is not lossless in pixels: a view that ' + + 'named a whitelist now has its Filter panel offer every readable field, minus the fields the ' + + 'object marks hidden, and a hidden field the whitelist named is no longer offered. What the ' + + 'conversion cannot decide is what the author wanted: a curated set of quick filters is a ' + + '`userFilters` declaration the author writes; a field that must not be filterable at all is ' + + 'a field-security question, not a view key. One boundary is the author\'s by construction: ' + + 'a key declared under `objects[].listViews` is reached by no conversion, so such an object is ' + + 'refused at its own door until edited by hand.', + acceptanceCriteria: 'No list view in `stack.views[]` or in any object `listViews` map declares ' + + '`filterableFields`; the parse refuses the key by name at every list-view door. For each view ' + + 'that did: if the author wanted end users to filter on a curated set of fields from the list ' + + 'itself, the view declares `userFilters` with those fields and the quick-filter bar shows ' + + 'them; otherwise nothing replaces it, and the Filter panel offers every field the signed-in ' + + 'user may read except the ones the object marks hidden. `searchableFields` and an existing ' + + '`userFilters` are unchanged.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index dfad5e4b15f..f3d51f4bd37 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2162,6 +2162,23 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'no tombstone, and no D2 conversion exists: a refused hook carries no intent a rewrite could ' + 'keep. Its D3 record is the semantic entry `hook-body-stored-metadata-target-refused`.', }, + { + id: 'list-view-filterable-fields-retired', + order: 105, + text: + 'It also retires the list view\'s `filterableFields` (ADR-0049 enforce-or-remove; maintainer ' + + 'ruling 2026-10-19, letter C). The key was declared as a shorthand for `userFilters.fields`, the ' + + 'quick-filter bar, while its one renderer read it as a whitelist of the fields the Filter ' + + 'panel offers, so one key carried two meanings and the declared one was never delivered. ' + + 'The ruling is that the platform carries no per-view Filter-panel whitelist, so no key ' + + 'replaces it: a curated end-user filter bar is `userFilters`, and the Filter panel offers ' + + 'every field the user may read, minus a field the object marks `hidden`. The key is a ' + + '`retiredKey()` tombstone on the list-view shape. The D2 conversion ' + + '`view-list-filterable-fields-removed` DROPS it from every list payload in `stack.views[]` ' + + 'and never rewrites it to `userFilters.fields`, which would keep nothing an author saw; it ' + + 'is retired from the load path, so authors are refused at parse rather than rewritten. Its ' + + 'D3 record is the semantic entry `list-view-filterable-fields-retired`.', + }, { id: 'list-view-page-mount-retired', order: 27, diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 20c1cfaf110..29c18a41e43 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -90,7 +90,7 @@ import { AdminScopeSchema, PermissionSetSchema } from '../security/permission.zo import { SharingLevel } from '../security/sharing.zod'; import { DatasourceSchema } from '../data/datasource.zod'; import { PageComponentSchema, PageSchema } from '../ui/page.zod'; -import { FormFieldSchema, FormSectionSchema } from '../ui/view.zod'; +import { FormFieldSchema, FormSectionSchema, ListViewSchema, ObjectListViewSchema } from '../ui/view.zod'; import { FlowSchema } from '../automation/flow.zod'; import { JobSchema } from '../system/job.zod'; import { PROTOCOL_MAJOR } from '../kernel/protocol-version'; @@ -344,6 +344,12 @@ const JOIN: Readonly> = { ], // #23650: retired after 17.7.0, so the v18 train's major. 'page-header-show-star-removed': [tombstone(PageHeaderProps, 'page:header', 'showStar')], + // #24640: retired after 17.7.0, so the v18 train's major. One tombstone on + // the list-view shape, walked as both list-view defs. + 'view-list-filterable-fields-removed': [ + tombstone(ListViewSchema, 'view.list', 'filterableFields'), + tombstone(ObjectListViewSchema, 'object.listViews.*', 'filterableFields'), + ], // #23904: the two data-at-rest halves of the #23610 page refusals, retired // after 17.7.0; each prescription is the parse's own refusal at its key — the // one sentence the registry door prints too — and names the release. diff --git a/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts b/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts new file mode 100644 index 00000000000..e352d006407 --- /dev/null +++ b/packages/spec/src/ui/view-list-filterable-fields-retirement.test.ts @@ -0,0 +1,551 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The list view's `filterableFields` RETIRED (#24640) — ADR-0049 + * enforce-or-remove; maintainer ruling C (record 6278975049), pulled into 18.0 + * (record 6279033298). + * + * The key carried two meanings and the declared one was never delivered: + * `ListViewSchema` described it as the legacy shorthand for + * `userFilters.fields` (the quick-filter bar) and the Studio form offered it as + * "Field names available for filtering", while its one renderer — objectui's + * `ListView` (the `filterFields` memo), fed by the app shell's object view — + * read it as the Filter panel's field WHITELIST. Measured at objectui's pinned + * sha `4016370a` and re-read at the moved pin `12dc7b47`, with the reader named + * on the ledger row + * (`liveness/view.json`, `/props/list/children/filterableFields`). The ruling is + * that the platform carries no per-view Filter-panel whitelist, so the key + * retires and NO key replaces it. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on the SHAPE every list-view door is built + * from — `ListViewSchema`, `ObjectListViewSchema` (a container's `list` / + * `listViews`, an object's `listViews`), the flattened overlay arm and a + * page `list-view` node's `properties` — so each refuses with the + * prescription, and `tsc` refuses at the call site. + * 2. The BOUNDARY: `userFilters` (the curated end-user filter bar the + * prescription names) and `searchableFields` (the neighbouring key) still + * parse at every door. + * 3. D2 conversion `view-list-filterable-fields-removed` (step 18) DROPS the + * key over `stack.views[]` in all three persisted spellings and ⛔ never + * rewrites it to `userFilters.fields` — measured on the seat's correction + * (record 6278907207), a rewrite keeps nothing an author saw. + * `objects[].listViews.*` is reached by no conversion — pinned as a + * declared boundary, not discovered later. + * 4. `RETIRED_KEYS_BY_MAJOR[18]` carries `ui/ListView:filterableFields` and + * `ui/ObjectListView:filterableFields`; the D3 entry + * `list-view-filterable-fields-retired` rides beside the D2, with no + * tracker number in any author-shown field. + * 5. The metadata form's `filterableFields` input left with the key. + * + * On the assertion set (the #13823 precedent): a schema refusal raises a + * `ZodError` whose issues carry `code` and `path` but no ADR-0112 `status` — + * that envelope belongs to the API error surface. So these pins assert the + * strongest set this surface has: refusal, the issue `code`, the `path` naming + * the key, and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { listRepoWalkFiles } from '../../scripts/lib/repo-walk-testkit'; +import { collectConversionNotices } from '../conversions/apply'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { ObjectSchema } from '../data/object.zod'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ComponentPropsMap } from './component.zod'; +import { viewForm } from './view.form'; +import { + ListViewSchema, + ObjectListViewSchema, + ViewItemSchema, + ViewMetadataSchema, + ViewSchema, + defineView, +} from './view.zod'; + +const CONVERSION_ID = 'view-list-filterable-fields-removed'; +const SEMANTIC_ID = 'list-view-filterable-fields-retired'; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself below. +const PRESCRIPTION = + /`view\.list\.filterableFields` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove\).*Delete the key; no per-view Filter-panel whitelist replaces it\..*`userFilters.*`os migrate meta --from 17`/s; + +const WHITELIST = ['status', 'priority']; +const LIST = { type: 'grid', columns: ['subject'] } as const; +const QUICK_FILTERS = { element: 'dropdown', fields: [{ field: 'status' }] } as const; + +type Issue = { code: string; path: PropertyKey[]; message: string; expected?: string; errors?: Issue[][] }; +type Parsed = { success: boolean; error?: { issues: readonly unknown[] } }; + +/** Walk `invalid_union` wrappers and return every issue, nested arms included. */ +const flatten = (issues: readonly Issue[]): Issue[] => + issues.flatMap((i) => + i.code === 'invalid_union' && Array.isArray(i.errors) ? [i, ...flatten(i.errors.flat())] : [i]); + +/** + * Every door a list-view payload is judged at, with the path its + * `filterableFields` key sits at there. Each builds the body around the SAME + * list payload, so a door that silently stopped carrying the tombstone is the + * only way a row can go green without a refusal. + */ +const DOORS: ReadonlyArray) => Parsed, string]> = [ + ['ListViewSchema', (list) => ListViewSchema.safeParse(list), 'filterableFields'], + ['ObjectListViewSchema', (list) => ObjectListViewSchema.safeParse(list), 'filterableFields'], + ['ViewSchema (defineView) — the default `list`', (list) => ViewSchema.safeParse({ list }), 'list.filterableFields'], + [ + 'ViewSchema (defineView) — a named `listViews` entry', + (list) => ViewSchema.safeParse({ list: LIST, listViews: { triage: list } }), + 'listViews.triage.filterableFields', + ], + [ + 'ViewItemSchema — a record\'s `config`', + (list) => ViewItemSchema.safeParse({ name: 'crm_ticket.queue', object: 'crm_ticket', viewKind: 'list', config: list }), + 'config.filterableFields', + ], + [ + 'the `view` write door — a flattened list overlay (PUT /api/v1/meta/view)', + (list) => ViewMetadataSchema.safeParse({ name: 'crm_ticket.queue', object: 'crm_ticket', viewKind: 'list', ...list }), + 'filterableFields', + ], + [ + 'ObjectSchema — an object\'s own `listViews`', + (list) => ObjectSchema.safeParse({ name: 'crm_ticket', fields: { subject: { type: 'text' }, status: { type: 'text' } }, listViews: { triage: list } }), + 'listViews.triage.filterableFields', + ], + [ + 'a page `list-view` node\'s `properties` (ComponentPropsMap)', + (list) => ComponentPropsMap['list-view'].safeParse(list), + 'filterableFields', + ], +]; + +describe('list-view filterableFields retirement — the tombstone, at every list-view door', () => { + it.each(DOORS)('%s refuses `filterableFields` at its path, with the prescription', (_label, parse, at) => { + const r = parse({ ...LIST, filterableFields: WHITELIST }); + expect(r.success).toBe(false); + // Select the TOMBSTONE issue by the shape `retiredKey()` raises, not by its + // text: the union doors also lift the text onto a path-less wrapper. + const issue = flatten((r.error?.issues ?? []) as Issue[]).find((i) => i.expected === 'never'); + expect(issue, JSON.stringify(r.error?.issues)).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path.join('.')).toBe(at); + expect(issue!.message).toMatch(PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue!.message.startsWith('`view.list.filterableFields` was removed')).toBe(true); + }); + + it.each(DOORS)('CONTROL: %s accepts the same body without the key', (_label, parse) => { + const r = parse({ ...LIST }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); + + it.each(DOORS)('BOUNDARY: %s accepts `userFilters.fields` and `searchableFields` — the surviving keys', (_label, parse) => { + const r = parse({ ...LIST, searchableFields: ['subject'], userFilters: QUICK_FILTERS }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); + + it('an empty `filterableFields: []` is refused too — the key is gone, not merely emptied', () => { + const r = ListViewSchema.safeParse({ ...LIST, filterableFields: [] }); + expect(r.success).toBe(false); + }); + + it('the `view` registry binding is the union these pins exercise', () => { + // A rebinding of `saveMetaItem`'s schema to some third shape would pass + // the door pins above and still accept the key in production. + expect(getMetadataTypeSchema('view')).toBe(ViewMetadataSchema); + }); + + it('the prescription names the fix — `userFilters` for a curated bar, no whitelist — and carries no tracker number', () => { + const r = ListViewSchema.safeParse({ ...LIST, filterableFields: WHITELIST }); + const message = (r.error?.issues ?? []).map((i) => i.message).join('\n'); + expect(message).toContain("`userFilters: { element: 'dropdown', fields: [{ field: '' }] }`"); + expect(message).toContain('The Filter panel offers every field the user may read'); + expect(message).toContain('Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'); + expect(message).not.toMatch(/#\d/); + }); + + it('fails tsc at the authoring site: the input type of `filterableFields` is `never`', () => { + const attempt = () => + defineView({ + list: { + type: 'grid', + columns: ['subject'], + // @ts-expect-error — `filterableFields` is a retiredKey() tombstone: its input type is `never`. + filterableFields: ['status'], + }, + }); + // The parse channel agrees with the type channel on the same literal. + expect(attempt).toThrow(PRESCRIPTION); + }); +}); + +describe('list-view filterableFields retirement — the metadata form', () => { + it('offers no `filterableFields` input on a list view any more', () => { + const paths: string[] = []; + const walk = (fields: unknown, prefix: string) => { + for (const f of (Array.isArray(fields) ? fields : []) as Array<{ field?: string; fields?: unknown }>) { + if (!f?.field) continue; + const p = prefix ? `${prefix}.${f.field}` : f.field; + paths.push(p); + walk(f.fields, p); + } + }; + for (const section of (viewForm as unknown as { sections: Array<{ fields: unknown }> }).sections) { + walk(section.fields, ''); + } + // Anti-vacuity: the walk reached the neighbours of the removed input. + expect(paths).toContain('searchableFields'); + expect(paths).toContain('userFilters'); + expect(paths).not.toContain('filterableFields'); + }); +}); + +describe('list-view filterableFields retirement — the D2 conversion DROPS the key', () => { + it('a STORED view container carrying the key replays clean through the rehydration seam', () => { + // `database-loader.ts` replays the chain over every stored row it loads; + // the seam wraps a `view` row as `{ views: [row] }`. + const stored = { + name: 'crm_ticket', + object: 'crm_ticket', + list: { ...LIST, filterableFields: WHITELIST }, + listViews: { triage: { ...LIST, filterableFields: [] }, all: { ...LIST } }, + }; + const notices: { conversionId?: string; path?: string }[] = []; + const rehydrated = applyConversionsToStoredItem('view', stored, { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string }), + }) as Record; + + expect(notices.map((n) => n.conversionId)).toEqual([CONVERSION_ID, CONVERSION_ID]); + expect(notices.map((n) => n.path)).toEqual(['views[0].list.filterableFields', 'views[0].listViews.triage.filterableFields']); + expect(rehydrated.list).not.toHaveProperty('filterableFields'); + expect(rehydrated.listViews.triage).not.toHaveProperty('filterableFields'); + // CONTROL: the live keys on the same payloads survive byte-for-byte. + expect(rehydrated.list).toEqual(LIST); + expect(rehydrated.listViews.all).toEqual(LIST); + // And the rehydrated row is exactly what the write door accepts now. + expect(ViewMetadataSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('⛔ never rewrites the key to `userFilters.fields`: no quick filter appears, and an authored one is untouched', () => { + const { stack, notices } = collectConversionNotices( + { + views: [{ + object: 'crm_ticket', + list: { ...LIST, filterableFields: WHITELIST }, + listViews: { mine: { ...LIST, filterableFields: WHITELIST, userFilters: QUICK_FILTERS } }, + }], + }, + { includeRetired: true }, + ); + const own = notices.filter((n) => n.conversionId === CONVERSION_ID); + expect(own.map((n) => n.path)).toEqual(['views[0].list.filterableFields', 'views[0].listViews.mine.filterableFields']); + // Every notice says the key went to `(removed)`, never to a replacement key. + expect(own.map((n) => (n as { to?: unknown }).to)).toEqual(['(removed)', '(removed)']); + const view = (stack.views as Record[])[0]!; + // No `userFilters` was minted where the author wrote none… + expect(view.list).toEqual(LIST); + expect(view.list).not.toHaveProperty('userFilters'); + // …and an authored one rides through byte-for-byte, its fields not widened by the whitelist. + expect(view.listViews.mine).toEqual({ ...LIST, userFilters: QUICK_FILTERS }); + }); + + it('reaches a ViewItem record\'s `config` and a flattened overlay, never a form payload', () => { + const { stack, notices } = collectConversionNotices( + { + views: [ + { name: 'crm_ticket.queue', object: 'crm_ticket', viewKind: 'list', config: { ...LIST, filterableFields: WHITELIST } }, + { name: 'crm_ticket.board', object: 'crm_ticket', viewKind: 'list', ...LIST, filterableFields: WHITELIST }, + { name: 'crm_ticket.intake', object: 'crm_ticket', viewKind: 'form', config: { type: 'simple' } }, + ], + }, + { includeRetired: true }, + ); + const own = notices.filter((n) => n.conversionId === CONVERSION_ID); + expect(own.map((n) => n.path)).toEqual(['views[0].config.filterableFields', 'views[1].filterableFields']); + const views = stack.views as Record[]; + expect(views[0]!.config).toEqual(LIST); + expect(views[1]).not.toHaveProperty('filterableFields'); + expect(views[2]).toEqual({ name: 'crm_ticket.intake', object: 'crm_ticket', viewKind: 'form', config: { type: 'simple' } }); + + // Idempotence, measured: a second replay converts nothing. + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const { stack, notices } = collectConversionNotices({ views: [{ object: 'crm_ticket', list: { ...LIST, filterableFields: WHITELIST } }] }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect((stack.views as Record[])[0]!.list.filterableFields).toEqual(WHITELIST); + }); + + it('BOUNDARY: an object\'s own `listViews` is reached by no conversion — refused at its door instead', () => { + const notices: { conversionId?: string }[] = []; + const row = { name: 'crm_ticket', fields: { subject: { type: 'text' } }, listViews: { triage: { ...LIST, filterableFields: WHITELIST } } }; + const out = applyConversionsToStoredItem('object', row, { + onNotice: (n) => notices.push(n as { conversionId?: string }), + }) as Record; + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(out.listViews.triage.filterableFields).toEqual(WHITELIST); + expect(ObjectSchema.safeParse(out).success).toBe(false); + }); +}); + +describe('list-view filterableFields retirement — ADR-0087 registration', () => { + it('declares the key on both list-view defs under major 18, with the D2 conversion in the step-18 chain', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ui/ListView:filterableFields'); + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('ui/ObjectListView:filterableFields'); + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + }); + + it('carries its D3 entry beside the D2 (ruling B), with no tracker number in any author-shown field', () => { + const entry = MIGRATIONS_BY_MAJOR[18]!.semantic.find((s) => s.id === SEMANTIC_ID); + expect(entry, 'the family owes one D3 entry beside its D2').toBeDefined(); + for (const field of ['surface', 'replacement', 'reason', 'acceptanceCriteria'] as const) { + expect(entry![field], field).not.toMatch(/#\d/); + } + expect(entry!.replacement).toContain('`userFilters`'); + expect(entry!.reason).toContain(`\`${CONVERSION_ID}\``); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types the key `never` on every +// list-view input, so every typed authoring site fails to compile. The residue +// is what `tsc` never judges: JSON, YAML, MD/MDX code fences, untyped `.js`, +// and TS literals typed `unknown` (a test body handed to a raw walker). This +// walk covers that residue across the five roots already declared for +// `@objectstack/spec` in `scripts/cross-package-test-inputs.mjs` and mirrored +// in `turbo.json` — the same roots and extensions the `tabs` pin walks — read +// through `listRepoWalkFiles`, so gitignored generated output is never judged. +// +// The matcher is STRUCTURAL, never a mention: an offender is one object literal +// (or one YAML mapping) whose OWN keys include `filterableFields`. No other +// shape in the platform declares the key, so no sibling test is needed; a +// mention in prose, in a string, or as an array member is not an authoring. +// +// The bound, stated: a payload assembled by SPREAD or computed keys is +// invisible to a text walk; `docs/**`, `.claude/**`, `.github/**` and the +// repo-root files are outside the radius. +describe('tree-scoped absence: no list-view payload inside the declared radius still carries `filterableFields`', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` only the non-code extensions are scanned AND declared. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + const KEY = 'filterableFields'; + const MENTION = /\bfilterableFields\b/; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key as a key. + */ + const EXCLUDED = new Set([ + // The tombstone itself: the list-view SHAPE literal declares the key. + // Schema source, not an authoring. + 'packages/spec/src/ui/view.zod.ts', + // This pin authors the key at every door to assert the refusal. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement payload on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // The liveness ledger keys its rows by the SCHEMA's key names, and the + // tombstone discipline requires that row to STAY (`dead`, REMOVED note). + 'packages/spec/liveness/', + ]; + /** + * RESIDUE, declared and self-expiring — not exempted. Outside this + * retirement's file surface, and reported as its follow-up: the author-time + * reference walks that still classify the key off RAW input — the lint + * list-view field-ref rule (its severity and role maps, and its fixtures), + * the ADR-0106 field-security scrub's list-view name lists in + * `@objectstack/metadata-core`, and the fixtures of + * `computeViewReferenceDiagnostics` (`@objectstack/metadata-protocol`) in + * `@objectstack/objectql`. Each entry is asserted to STILL hold an offender, + * so the day the follow-up deletes a key this set goes red and the entry + * leaves with it. + */ + const RESIDUE = new Set([ + 'packages/lint/src/validate-list-view-field-refs.test.ts', + 'packages/lint/src/validate-list-view-field-refs.ts', + 'packages/metadata-core/src/object-schema-fls-references.ts', + 'packages/objectql/src/metadata-diagnostics.test.ts', + ]); + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** + * One pass over JS/TS/JSON text: a stack of bracket frames, each `{` frame + * collecting its OWN keys — an identifier or quoted string in key position + * (after `{` or `,`) followed by `:`. Strings and comments are skipped; a + * single- or double-quoted string never spans a line, so a mis-lexed quote + * (a regex literal) costs at most that line. Returns the 1-based line of each + * closing brace whose frame is an offender. (The `tabs` pin's lexer.) + */ + const lexOffenders = (text: string): number[] => { + const out: number[] = []; + const stack: { kind: string; keys: Set }[] = []; + let lastSig = ''; + let line = 1; + let i = 0; + const n = text.length; + while (i < n) { + const c = text[i]!; + if (c === '\n') { line += 1; i += 1; continue; } + if (c === '/' && text[i + 1] === '/') { while (i < n && text[i] !== '\n') i += 1; continue; } + if (c === '/' && text[i + 1] === '*') { + i += 2; + while (i < n && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') line += 1; i += 1; } + i += 2; + continue; + } + if (c === '"' || c === "'" || c === '`') { + const start = i; + i += 1; + while (i < n && text[i] !== c) { + if (text[i] === '\\') i += 1; + else if (text[i] === '\n') { if (c !== '`') break; line += 1; } + i += 1; + } + const token = text.slice(start + 1, i); + i += 1; + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + const top = stack[stack.length - 1]; + if (c !== '`' && text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ',')) top.keys.add(token); + lastSig = 'str'; + continue; + } + if (/[A-Za-z_$]/.test(c)) { + const start = i; + while (i < n && /[\w$]/.test(text[i]!)) i += 1; + const token = text.slice(start, i); + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + const top = stack[stack.length - 1]; + if (text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ',')) top.keys.add(token); + lastSig = 'id'; + continue; + } + if (c === '{' || c === '[' || c === '(') stack.push({ kind: c, keys: new Set() }); + else if (c === '}' || c === ']' || c === ')') { + const frame = stack.pop(); + if (frame?.kind === '{' && c === '}' && frame.keys.has(KEY)) out.push(line); + } + if (!/\s/.test(c)) lastSig = c; + i += 1; + } + return out; + }; + + /** YAML: the 1-based line of every `filterableFields` mapping key. */ + const yamlOffenders = (text: string): number[] => { + const out: number[] = []; + text.split('\n').forEach((raw, idx) => { + if (/^\s*(?:-\s+)?["']?filterableFields["']?\s*:/.test(raw)) out.push(idx + 1); + }); + return out; + }; + + /** MD/MDX: only fenced code is judged — prose mentions are not authorings. */ + const markdownOffenders = (text: string): number[] => { + const out: number[] = []; + const fence = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + for (let m = fence.exec(text); m; m = fence.exec(text)) { + const lang = m[1]!.toLowerCase(); + const body = m[2]!; + const offset = text.slice(0, m.index).split('\n').length; + const hits = lang === 'yaml' || lang === 'yml' ? yamlOffenders(body) : lexOffenders(body); + for (const h of hits) out.push(offset + h); + } + return out; + }; + + const offendersIn = (ext: string, text: string): number[] => { + if (!MENTION.test(text)) return []; + if (ext === '.yaml' || ext === '.yml') return yamlOffenders(text); + if (ext === '.md' || ext === '.mdx') return markdownOffenders(text); + return lexOffenders(text); + }; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher finds a list-view authoring and ignores every neighbouring shape (anti-vacuity)', () => { + // Offenders — the retired key as a key, in each syntax the walk reads. + expect(offendersIn('.ts', "defineView({ list: { type: 'grid', columns: ['a'], filterableFields: ['b'] } })")).toEqual([1]); + expect(offendersIn('.ts', "const v = {\n columns: ['a'],\n filterableFields: [],\n};")).toEqual([4]); + expect(offendersIn('.json', '{ "list": { "columns": ["a"], "filterableFields": ["b"] } }')).toEqual([1]); + expect(offendersIn('.yaml', 'list:\n type: grid\n columns: [a]\n filterableFields: [b]\n')).toEqual([4]); + expect(offendersIn('.md', "Prose.\n\n```ts\nlist: { filter: [], filterableFields: ['b'] }\n```\n")).toEqual([4]); + // Neighbours that must NOT match. + // The surviving keys: `searchableFields` and the `userFilters` bar. + expect(offendersIn('.ts', "({ columns: ['a'], searchableFields: ['a'], userFilters: { element: 'dropdown', fields: [{ field: 'b' }] } })")).toEqual([]); + // A mention as an array member (a vocabulary set), a property read, a string. + expect(offendersIn('.ts', "const SIBLINGS = new Set(['columns', 'filterableFields']);")).toEqual([]); + expect(offendersIn('.ts', 'const w = view.filterableFields ?? [];')).toEqual([]); + expect(offendersIn('.ts', "const s = \"{ columns: [], filterableFields: [] }\";")).toEqual([]); + // A spread-assembled payload — the stated bound. + expect(offendersIn('.ts', '({ ...list, [KEY]: [] })')).toEqual([]); + // Prose and inline code are not authorings. + expect(offendersIn('.md', 'A list view once took `filterableFields: []`.')).toEqual([]); + }); + + it('no list-view payload carrying `filterableFields` survives inside the declared radius', () => { + const offenders: string[] = []; + const residueHits = new Map(); + let visited = 0; + let mentioning = 0; + const skipDir = (name: string) => SKIPPED_DIRS.has(name) || name.startsWith('.'); + for (const root of WALK_ROOTS) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + const name = path.basename(full); + const ext = path.extname(name); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; + if (name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(name)) continue; + visited += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + if (MENTION.test(text)) mentioning += 1; + const hits = offendersIn(ext, text); + if (RESIDUE.has(rel)) { residueHits.set(rel, hits.length); continue; } + for (const lineNo of hits) offenders.push(`${rel}:${lineNo}`); + } + } + // Anti-vacuity: the walk covered the tree, and the files that CAN hold the + // key were really judged. + expect(visited).toBeGreaterThan(1000); + expect(mentioning).toBeGreaterThan(10); + expect(offenders, 'a list-view payload carrying `filterableFields` means the retirement is being undone').toEqual([]); + // The residue is self-expiring: every declared file still holds an + // offender, or its entry is stale and leaves. + for (const rel of RESIDUE) { + expect(residueHits.get(rel) ?? 0, `${rel} no longer spells the key — delete its RESIDUE entry`).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/spec/src/ui/view.form.ts b/packages/spec/src/ui/view.form.ts index c3391702c19..c49d9e02a53 100644 --- a/packages/spec/src/ui/view.form.ts +++ b/packages/spec/src/ui/view.form.ts @@ -78,7 +78,12 @@ export const viewForm = defineForm({ ], }, { field: 'searchableFields', widget: 'string-tags', helpText: 'Field names available for quick search' }, - { field: 'filterableFields', widget: 'string-tags', helpText: 'Field names available for filtering' }, + // [#24640] The `filterableFields` input was REMOVED with the key it + // wrote — now a `retiredKey()` tombstone on `ListViewSchema` (ruled + // retired with no replacement: the platform carries no per-view + // Filter-panel whitelist). A form input for an unwritable key is the + // false-compliant UI half of a retirement; a curated end-user filter + // bar is `userFilters`, under End-user controls below. ], }, { diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index d087a0ab56b..d5f8fa72541 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -502,11 +502,14 @@ describe('ListViewSchema', () => { expect(() => ListViewSchema.parse(listView)).not.toThrow(); }); - it('should accept list view with top filter fields', () => { + it('should accept list view with quick-filter fields (userFilters)', () => { + // [#24640] Re-trued from the retired `filterableFields` key to the surviving + // `userFilters.fields` (the curated end-user filter bar); the refusal of the + // retired key is pinned in `view-list-filterable-fields-retirement.test.ts`. const listView: ListView = { type: 'grid', columns: ['name', 'status'], - filterableFields: ['status', 'category', 'owner'], + userFilters: { element: 'dropdown', fields: [{ field: 'status' }, { field: 'category' }, { field: 'owner' }] }, }; expect(() => ListViewSchema.parse(listView)).not.toThrow(); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 18c942e1630..1e5e34e05ab 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2497,6 +2497,44 @@ const LIST_VIEW_SORT_STRING_RETIRED = + 'key, in the same order. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; +/** + * [#24640] Prescription for the retired `filterableFields` key on the + * list-view shape (ADR-0049 enforce-or-remove; maintainer ruling C, record + * 6278975049, pulled into 18.0 by record 6279033298). + * + * The key carried two meanings and the declared one was never delivered. This + * schema described it as the "legacy shorthand for `userFilters.fields`" — the + * quick-filter bar — and the Studio form offered it as "Field names available + * for filtering", while its one renderer (objectui's `ListView`, the + * `filterFields` memo, fed by the app shell's object view) read it as the + * Filter panel's field WHITELIST: the fields "Add filter" offers, a hidden + * field included when named. No quick-filter chip ever came of it. The ruling + * is that the platform carries no per-view Filter-panel whitelist at all, so + * the key retires rather than being redescribed, and NO key replaces it. + * + * What covers the need now, and what the prescription says: + * - a curated end-user filter bar is `userFilters` (`userFilters.fields`); + * - the Filter panel offers every field the user may read — field read + * security decides, and a field the object marks `hidden: true` is not + * offered. + * + * ⛔ No conversion rewrites the key to `userFilters.fields`: that keeps + * nothing an author saw (the whitelist goes and quick-filter chips appear). + * The D2 conversion `view-list-filterable-fields-removed` DROPS it from stored + * and authored view sources instead; the D3 entry + * `list-view-filterable-fields-retired` carries the judgement it cannot make. + */ +const LIST_VIEW_FILTERABLE_FIELDS_RETIRED = + '`view.list.filterableFields` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — ' + + 'the key was declared as a shorthand for `userFilters.fields` (the quick-filter bar), but the one ' + + 'renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one ' + + 'key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel ' + + 'whitelist replaces it. For a curated end-user filter bar, declare ' + + "`userFilters: { element: 'dropdown', fields: [{ field: '' }] }`. The Filter panel " + + 'offers every field the user may read: field read security decides, and a field the object ' + + 'definition marks `hidden: true` is not offered. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + const VIEW_CALENDAR_ALLOWED_NEEDS_START_DATE = "`appearance.allowedVisualizations` includes 'calendar', so end users can switch this view to a " + 'calendar — but no `calendar:` block says which field supplies the event date. There is no ' @@ -2841,7 +2879,23 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** Search & Filter */ searchableFields: z.array(z.string()).optional().describe('Fields enabled for search'), - filterableFields: z.array(z.string()).optional().describe('Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters'), + /** + * [#24640] REMOVED — described here as the `userFilters.fields` shorthand, + * read by its one renderer as the Filter panel's field whitelist; ruled + * retired with no replacement key (see + * {@link LIST_VIEW_FILTERABLE_FIELDS_RETIRED}). Tombstoned rather than + * deleted so the removal is audible in both channels an upgrading author + * hits: `tsc` (the input type is `never`) and the parse (the prescription, + * not a bare unrecognized-key report). The tombstone reaches every + * list-view door built from this shape: `ListViewSchema`, + * `ObjectListViewSchema` (a container's `list` / `listViews`, an object's + * `listViews`), the flattened overlay arm and a page `list-view` node's + * `properties`. ⚠️ Not a react page: the react `ListView` block's schema is + * `ListViewSchema` (its curated `dataProps` never listed this key), but a + * react page's props live in an opaque `source` string no door parses + * against it — a boundary, stated rather than left to be discovered. + */ + filterableFields: retiredKey(LIST_VIEW_FILTERABLE_FIELDS_RETIRED), /** User Filters (ADR-0047, Airtable Interface parity) */ userFilters: UserFiltersSchema.optional() diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 4a3b3b7dd9f..d843676e634 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -67,6 +67,7 @@ "src/ui/page-header-show-star-retirement.test.ts", "src/ui/view-data-api-provider-retirement.test.ts", "src/ui/view-item-owner-hidden-retirement.test.ts", + "src/ui/view-list-filterable-fields-retirement.test.ts", "src/ui/view-list-tabs-retirement.test.ts", "src/ui/visibility-alias-keys-retirement.test.ts" ]