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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/user-filters-allow-add-tab-promote-and-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'@objectstack/spec': major
---

spec(ui): `userFilters.allowAddTab` 提升进契约,`UserFiltersSchema` 随之收紧 (#5073)

`UserFiltersSchema` 是 #4001 批 18 在 `ui/view.zod.ts` 留下的最后一块「不是 strictness 问题」的开放形状 —— 挡住它的是一条**能力声明**,不是姿态判断。

**为什么之前不能直接关。** objectui 的列表渲染器真读 `config.allowAddTab` 并据此渲染「新增 tab」控件(`plugin-list/src/UserFilters.tsx:182` / `:742`),它自己的 `UserFiltersSchema` 也声明了这个键 —— 两边形状的差集恰好只有这一个。而 `saveMetaItem` 用 `safeParse` 校验后**原样存原始 body**(丢弃 `parsed.data`,好让 Studio 的辅助键活过往返),所以被 strip 掉的只是那份被丢弃的解析结果:存储里键还在,渲染器读得到,**这个能力今天是工作的**。直接收紧不是「把静默失效变响亮」,而是把一个已发布、在用的配置变成 422 —— 而且 422 会点名一个作者本来写对了的键,正是本战役 finding 7 的形状(平台权威把作者引向删掉能工作的东西)。

**裁定与落地(维护者 2026-08-04,选项 A):promote 后收紧,同 PR 完成。** `allowAddTab` 现在**声明**在 `UserFiltersSchema` 上,能力因此可从契约被发现 —— JSON Schema、Studio 的 SchemaForm、AI 作者都看得到,而不是只存在于一个 React 文件里。被否决的是判它为 objectui-only 扩展(`SANCTIONED_LOCAL`):那会让 `packages/spec` 与 objectui 成为同一份契约的两个事实来源,正是 #2231 的 derive-by-reference 统一要消掉的分叉(PD#12)。声明的措辞刻意收窄到渲染器真做的事 —— 它声明「渲染出新增 tab 的入口」,不承诺点击后能创建预设(objectui 那个按钮目前没有 click handler,已另行立案),因为承诺更多就是 PD#10 的「宣传运行时并不交付的能力」。

## BREAKING

**1. `userFilters` 上的未知键从静默丢弃变为拒绝。**

```diff
userFilters: {
element: 'tabs',
- allowAddTabs: true, // 拼错 → 以前静默消失,现在 422(并提示 → allowAddTab)
+ allowAddTab: true,
}
```

FROM → TO:未声明的键 → 删除它,或改成它想表达的那个已声明键。错误信息会点名该键并给出最近的候选。`allowAddTab` 本身**不需要迁移** —— 它现在是合法声明键,原有配置照常通过。

**2. 对象列表视图(`ObjectUserFiltersSchema`)拒绝 page-only 的三个键:`tabs` / `showAllRecords` / `allowAddTab`。**

这三个键在对象视图上一直是无效的(`ObjectUserFiltersSchema` 由 `UserFiltersSchema.omit()` 派生,而 `.omit()` 继承基类姿态),此前被静默丢弃 —— 与此同时 CLI lint(`packages/lint/src/validate-list-view-mode.ts`)早就在报同一个配置。两扇门从此一致。

```diff
// 对象视图:tab 栏的角色已被 ViewTabBar(已存视图切换器)占用
listViews: {
- // userFilters: { element: 'dropdown', tabs: [{ name: 'mine', label: '我的' }] }
+ mine: { label: '我的', filter: [['owner', '=', '{userId}']] }, // 每个具名视图渲染成一个分段 tab
}
```

FROM → TO:`userFilters.tabs` → 对象的 `listViews` 具名条目;`showAllRecords` → 默认列表视图本身就是「全部记录」入口;`allowAddTab` → 由 ViewTabBar 自带的新增控件承担。三条拒绝各自带 `guidance` 处方,不是裸的 "unrecognized key"。

派生变体同时改为携带**自己的**错误映射:`.omit()` 会连基类的 `knownKeys` 一起继承,而那份候选列表是从基类形状读的、仍然含被 omit 掉的键 —— 实测在对象视图上写 `tab` 会被答复 *"Did you mean `tab` → `tabs`?"*,把作者指向这个形状唯一拒绝的键。形状仍由 `.omit()` 派生(#2231 不变),候选池改为按 omit 后的形状构建。
2 changes: 1 addition & 1 deletion content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Interface-level page configuration (Airtable parity)
| **levels** | `integer` | optional | Number of hierarchy levels to display |
| **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. |
| **appearance** | `{ showDescription: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration |
| **userFilters** | `{ element: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: { field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]; tabs?: { name: string; label?: string; icon?: string; view?: string; … }[]; showAllRecords?: boolean }` | optional | End-user quick-filter bar for this page (overrides the source view's userFilters) |
| **userFilters** | `{ element: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: { field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]; tabs?: { name: string; label?: string; icon?: string; view?: string; … }[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar for this page (overrides the source view's userFilters) |
| **userActions** | `{ sort: boolean; search: boolean; filter: boolean; refresh: boolean; … }` | optional | User action toggles |
| **addRecord** | `{ enabled: boolean; position: Enum<'top' \| 'bottom' \| 'both'>; mode: Enum<'inline' \| 'form' \| 'modal'>; formView?: string }` | optional | Add record entry point configuration |
| **buttons** | `string[]` | optional | Toolbar buttons — names of the source object's actions to surface in the page toolbar |
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ List chart view configuration
| **sort** | `string \| { 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 |
| **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: { field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]; tabs?: { name: string; label?: string; icon?: string; view?: string; … }[]; 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 |
| **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: { field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]; tabs?: { name: string; label?: string; icon?: string; view?: string; … }[]; 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 |
| **striped** | `boolean` | optional | Striped row styling |
| **bordered** | `boolean` | optional | Show borders |
Expand Down Expand Up @@ -653,6 +653,7 @@ End-user quick-filter configuration (Airtable "User filters" parity)
| **fields** | `{ field: string; label?: string; type?: Enum<'select' \| 'multi-select' \| 'boolean' \| 'date-range' \| 'text'>; options?: { value: string \| number \| boolean; label: string; color?: string }[]; … }[]` | optional | Fields exposed as quick filters (dropdown/toggle elements) |
| **tabs** | `{ name: string; label?: string; icon?: string; view?: string; … }[]` | optional | Named filter presets rendered as tabs (tabs element). Reuses ViewTabSchema |
| **showAllRecords** | `boolean` | optional | Show an "All records" tab before the presets (tabs element) |
| **allowAddTab** | `boolean` | optional | Render an "add tab" affordance after the presets (tabs element). Page lists only — object views use `listViews` for named presets |


---
Expand Down
22 changes: 11 additions & 11 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 483 |
| Still-open (strip) sites | 235 |
| Object sites in them | 484 |
| Still-open (strip) sites | 234 |
| Files carrying at least one | 36 |

Remaining strip sites by class:

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 29 |
| authorable — the ruling's forced scope | 28 |
| unresolved — needs a per-schema verdict | 33 |
| wire / open — out of forced scope | 104 |
| no door — no carrier, ADR-0049 territory | 38 |
Expand All @@ -43,12 +43,12 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 199 | 118 | 5 | 0 | 76 |
| `ui/` | 200 | 120 | 5 | 0 | 75 |
| `data/` | 162 | 41 | 1 | 0 | 120 |
| `automation/` | 75 | 49 | 0 | 0 | 26 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **483** | **242** | **6** | **0** | **235** |
| **total** | **484** | **244** | **6** | **0** | **234** |

## File-level triage — site counts

Expand Down Expand Up @@ -79,9 +79,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `sharing.zod.ts` | 2 |
| `theme.zod.ts` | 14 |
| `touch.zod.ts` | 7 |
| `view.zod.ts` | 50 |
| `view.zod.ts` | 51 |
| `widget.zod.ts` | 9 |
| **total** | **199** |
| **total** | **200** |

### `data/` — sites

Expand Down Expand Up @@ -161,7 +161,7 @@ over it is here.

### `ui/` — open

**76 strip of 199**, in 13 file(s).
**75 strip of 200**, in 13 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All @@ -176,13 +176,13 @@ over it is here.
| `offline.zod.ts` | 3 | 3 |
| `sharing.zod.ts` | 1 | 2 |
| `touch.zod.ts` | 7 | 7 |
| `view.zod.ts` | 6 | 50 |
| `view.zod.ts` | 5 | 51 |
| `widget.zod.ts` | 9 | 9 |
| **total** | **76** | **199** |
| **total** | **75** | **200** |

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 7 |
| authorable — the ruling's forced scope | 6 |
| unresolved — needs a per-schema verdict | 0 |
| wire / open — out of forced scope | 0 |
| no door — no carrier, ADR-0049 territory | 38 |
Expand Down
2 changes: 1 addition & 1 deletion docs/audits/2026-07-unknown-key-strictness-ledger.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/spec/authorable-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -7964,6 +7964,7 @@
"ui/UserFilterField:options",
"ui/UserFilterField:showCount",
"ui/UserFilterField:type",
"ui/UserFilters:allowAddTab",
"ui/UserFilters:element",
"ui/UserFilters:fields",
"ui/UserFilters:showAllRecords",
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/scripts/strictness-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,12 @@ describe('posture reading, with a red control for each', () => {
// And the claim the ledger rests on: no STRIP site moved. Both postures
// involved are non-strip, so the remaining-strip map — the thing every batch
// is planned against — is untouched by this fix.
expect(countStripSites(at('ui/view.zod.ts'))).toBe(6);
//
// The number itself tracks real batches: 6 when #5072 was written, 5 since
// #5073 closed `UserFiltersSchema`. It is the file's live strip count, not a
// #5072 invariant — what #5072 pins is that ITS OWN change moved no strip
// site, and that still reads correctly against whatever the current count is.
expect(countStripSites(at('ui/view.zod.ts'))).toBe(5);
});

it('lets a chained posture override the idiom in either direction (#5072)', () => {
Expand Down
35 changes: 28 additions & 7 deletions packages/spec/src/ui/object-list-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,36 @@ describe('ObjectListViewSchema (ADR-0047 "views" mode)', () => {
expect((parsed as { userFilters?: unknown }).userFilters).toMatchObject(uf);
});

it('drops the page-only tabs/showAllRecords keys from a dropdown userFilters', () => {
const parsed = ObjectListViewSchema.parse({
it('REJECTS the page-only tabs/showAllRecords/allowAddTab keys on a dropdown userFilters', () => {
// Flipped from "drops" at #5073, and the flip is the point: until then the
// schema silently discarded these while the CLI lint
// (`packages/lint/src/validate-list-view-mode.ts`) reported them — two
// doors disagreeing about the same config. They now agree.
const r = ObjectListViewSchema.safeParse({
...base,
userFilters: { element: 'dropdown', tabs: [{ name: 'mine', label: 'Mine', filter: [] }], showAllRecords: true },
userFilters: {
element: 'dropdown',
tabs: [{ name: 'mine', label: 'Mine', filter: [] }],
showAllRecords: true,
allowAddTab: true,
},
} as never);
const parsedUf = (parsed as { userFilters?: Record<string, unknown> }).userFilters!;
expect(parsedUf).not.toHaveProperty('tabs');
expect(parsedUf).not.toHaveProperty('showAllRecords');
expect(parsedUf.element).toBe('dropdown');
expect(r.success).toBe(false);
const msg = JSON.stringify(r.error?.issues ?? []);
expect(msg).toContain('tabs');
expect(msg).toContain('showAllRecords');
expect(msg).toContain('allowAddTab');
});

it('…and the rejection prescribes `listViews`, the thing an object view actually uses', () => {
// A page-only key has a right answer on an object view, so a bare
// "unrecognized key" would be a correct refusal that still leaves the
// author guessing — the failure mode #5073 was filed to avoid.
const r = ObjectListViewSchema.safeParse({
...base,
userFilters: { element: 'dropdown', tabs: [{ name: 'mine', label: 'Mine', filter: [] }] },
} as never);
expect(JSON.stringify(r.error?.issues ?? [])).toContain('listViews');
});

it('rejects a tabs-element userFilters (page-only, would collide with ViewTabBar)', () => {
Expand Down
Loading
Loading