Skip to content

Commit 9462d8a

Browse files
refactor(ui-types): one source for the filter-builder field-type list; close the three K4 review should-fixes (#27400)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao93rRemsb94HTBb9ZVJHChk)_ Fixes #27369 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:filter-builder 的 14 种字段类型现在只在 `@object-ui/types` 的 `filter-field-types.ts` 声明一次,校验 schema、TS 接口、渲染器三处都从这里取;顺带处理 K4 评审的三条建议改项。 - 为什么改:以前三处手抄列表互相漂移(objectui#6939、#7562、#8774 修的都是这个),K4 之后还多了一份冻结列表。 - 风险与回滚:可接受的类型集合不变(仍 14 个,`type` 可选,缺省 `text`);渲染行为不变。回滚即 revert 本 PR。 - 先看:`ui/packages/types/src/filter-field-types.ts`、`ui/packages/components/src/custom/filter-builder.tsx` 的分组导入、`ui/scripts/check-doc-links.mjs` 新增的 skills 扫描行。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/types/src/__tests__/filter-builder-mirror-6939.test.ts examples/schema-catalog/test/filter-builder-mirror-6939.test.tsx`,预期全绿。 - 需要你做的:本 PR 触及 `.github/workflows/ci.yml`(Tier H),待分诊合约复审后由你合并。 ## What changes **One source for the field-type list (card items 1–2).** - New `ui/packages/types/src/filter-field-types.ts` declares the renderer's four behaviour buckets (`FILTER_NUMBER_LIKE_TYPES`, `FILTER_DATE_LIKE_TYPES`, `FILTER_SELECT_LIKE_TYPES`, `FILTER_LOOKUP_LIKE_TYPES`) and `FILTER_FIELD_TYPES` (`text`, the buckets, `boolean`, in the order the retired doc used), plus the member type `FilterFieldType`. All are exported from `@object-ui/types`. - `FilterFieldSchema.type` is `z.enum(FILTER_FIELD_TYPES).optional()`. Its `.describe()` no longer cites "the published doc", and its docblock says where the authority lives now. - `FilterField['type']` is `FilterFieldType`, not a restated union. - `filter-builder.tsx` imports the four buckets and declares no list of its own. They are rebound as `readonly string[]` locals so the existing `.includes(authoredType)` call sites compile unchanged. - The validator half of `filter-builder-mirror-6939` drops `LAST_PUBLISHED_TYPES` and its doc-reader leftovers. It now pins: the enum equals `FILTER_FIELD_TYPES` member for member; every member is a spec `FieldType` (`packages/spec/src/data/field.zod.ts`) except `SPEC_FIELD_TYPE_EXEMPTIONS`, which names `status` with its one-line reason; every exemption is still live (in the list, still outside the spec); and the seven bucketed members are each carried by their shared bucket, which the renderer imports. The header's objectstack#27145 section is rewritten to say the authority is `FILTER_FIELD_TYPES`. - The render half (`examples/schema-catalog/test/filter-builder-mirror-6939.test.tsx`) replaces the stale hand-kept seven ("…and the mirror refuses it") with one case per member of `FILTER_FIELD_TYPES` except `text`. Each case uses the existing `oneRow` measurement and asserts that the member draws a control signature (inputs plus trigger count) different from a nonsense spelling. `text` is the fallthrough target by construction; its by-name branch stays pinned in the validator half. **K4 should-fix (1): the skill doc URLs are now gated.** This took both halves. `check-skills-paths.mjs` judges only backtick paths, and it resolves `content/` against `ui/`, so it could not reach a site URL. I chose `check-doc-links.mjs` instead: - `SCAN_ROOTS` gains `{ path: '../skills/objectui', rule: 'disk', diskRoot: '../skills/objectui' }`. The bundle is read on GitHub and by agents, so its relative hrefs are disk paths. It ships to customer projects as one directory, so the new per-row `diskRoot` makes the bundle itself the boundary a relative href must stay inside. The default boundary, this tree's root, sits below the bundle and would have rejected all 34 intra-bundle links. - The two prose URLs (`app-composition.md:62`, `styling.md:318`) become markdown links. The gate already judges `objectstack.ai` links as site routes against `apps/docs`. - The new row also surfaced one gate gap: `guides/schema-expressions.md` has a heading inside a blockquote (`> ### …`). Both renderers anchor that heading, but the gate's ATX scan did not see it, and the corpus test went red. The heading scan now strips blockquote markers first. A unit pin covers this, and the setext-count test counts blockquoted ATX headings. - `check-skills-token-ratchet`: the Theming link cost `styling.md` 2 tokens. I paid them by deleting text in the same line ("the token list" became "tokens"). `app-composition.md` is 1 token lighter. **K4 should-fix (2).** `.github/workflows/ci.yml`: the ui paths-filter comment said "four roots outside ui/" above a list of five globs. It now says "five paths outside ui/". This is a comment-only change. **K4 should-fix (3).** `ui/AGENTS.md` rule #2 pointed at the retired `content/docs/guide/*.md`. It now names the matching page under `content/docs/frontend/`. **Changeset:** `.changeset/27369-filter-builder-field-types.md`, `@object-ui/types` patch and `@object-ui/components` patch, with both declaration lines. ### Skill bundle readings (`skills/**` touched) | | before | after | |:---|---:|---:| | `skills/objectui/guides/app-composition.md` | 63 lines · 1079 tokens | 63 lines · 1078 tokens | | `skills/objectui/rules/styling.md` | 320 lines · 2699 tokens | 320 lines · 2699 tokens | | all `SKILL.md` in `skills/` | 4614 lines | 4614 lines | | `skills/objectui/**/*.md` | 4825 lines | 4825 lines | ## Tests All runs are on head `796bf3a3` or on its parent `7dc5d3f6`, which differs only in `styling.md`. - Card acceptance: `pnpm --dir ui exec vitest run packages/types examples/schema-catalog` passed, with Test Files 429/429 and Tests 12850/12850. - `pnpm --dir ui exec vitest run scripts/__tests__/check-doc-links.test.ts`: 123/123. The whole `scripts/` suite also ran as part of the derived gates (exit 0). - Type-check UI: `@object-ui/types` `type-check` (src, examples and tests programs), `@object-ui/components` `type-check` and `@object-ui/example-schema-catalog` `type-check` all exit 0, built against a fresh build of their dependency closure. - The four `components-complex-filter-builder/*` catalog entries and the nested `search-interface` builder still validate (`the catalog entries the mirror refused` describe, green). - `git grep -n "LAST_PUBLISHED_TYPES\|published doc's fourteen"` prints nothing. <details><summary>One-time ablations (each restored from HEAD; git status clean after each)</summary> ui vitest resolves `@object-ui/types` to `src` through its alias, so these needed no dist rebuild. 1. **A fifteenth member with no renderer branch.** I added `'email'` to `FILTER_FIELD_TYPES` only. The render half went red on `` `email` draws a branch of its own, not the fallthrough `` (1 failed, 99 passed). The spec check stayed green, as it should, because `email` is a spec `FieldType`. 2. **A fifteenth member outside the spec.** I added `'zz_not_spec'` to `FILTER_SELECT_LIKE_TYPES`. The validator half went red on `every member is a spec FieldType, or a named exemption` (1 failed, 48 passed). 3. **Removing a member from a bucket narrows all three faces together.** I dropped `rating` from `FILTER_NUMBER_LIKE_TYPES`: - **Schema:** `accepts rating, and the renderer draws it from the shared bucket FILTER_NUMBER_LIKE_TYPES` went red. - **Interface:** a scratch `const f: FilterField = { …, type: 'rating' }` failed `tsc -p tsconfig.test.json` with TS2322. The control, run before the mutation, showed 0 errors. - **Renderer:** with `rating` forced into the render probe, `` `rating` draws a branch of its own `` went red. A `rating` column now draws the fallthrough. </details> ## Acceptance notes - `status` is the one member that is not a spec `FieldType`. It is exempted by name, not dropped, so the accepted set does not narrow. - `check-doc-links` gained a row field (`diskRoot`) and a heading-scan fix. Both are inside the claim's "only if item 3 needs them" surface: the row is what gates the URLs, and the heading fix is what the row needed to land green. ## Verification <details><summary>Derived gates: 88 derived, 87 run, 1 NOT MEASURED</summary> `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 88 commands. I ran all of them on `7dc5d3f6` and reran the one red family after the fix: - `node scripts/check-skills-token-ratchet.mjs`: exit 1 on `7dc5d3f6` (`styling.md` 2701/2699), then exit 0 on `796bf3a3`. - `pnpm --filter @objectstack/spec run check:skill-examples`: exit 3 until `client-react` was built, then exit 0 (270 prose examples type-check). - `pnpm check:dual-build-cjs-loads`: exit 3, PREREQUISITE NOT MET because it needs every published package's dist. **NOT MEASURED** locally. The diff touches no package it loads, and CI runs it. - The other 85 exited 0. Reconciliation: `dispatch-gates --ran` printed `88 derived famil(ies) accounted for — 87 run, 1 NOT-MEASURED`. Lint UI: `pnpm --dir ui run lint:root` reported 0 errors, and `turbo run lint` over the three touched ui packages reported 0 errors. Neither reported a warning on a changed line. </details> Tier H by path (`.github/workflows/ci.yml`). It stays a draft until green, then the triage seat writes the contract review and the maintainer merges. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibxvBS38kVpxSqdV8T4)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ebed73b commit 9462d8a

14 files changed

Lines changed: 368 additions & 249 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/types': patch
3+
'@object-ui/components': patch
4+
---
5+
6+
refactor(ui-types): one source for the filter-builder field-type list, read by the validator, the interface and the renderer
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The `filter-builder` field types used to be listed three times by hand: in `FilterFieldSchema.type`, in `FilterField['type']`, and in the renderer's behaviour buckets. Each copy could drift from the others, and past fixes repaired exactly that drift. There is now one declaration. The accepted set is unchanged: the same fourteen members, `type` still optional, and `text` still the default when it is absent.
12+
13+
- **`@object-ui/types`** exports `FILTER_FIELD_TYPES` and the four behaviour buckets it is built from: `FILTER_NUMBER_LIKE_TYPES`, `FILTER_DATE_LIKE_TYPES`, `FILTER_SELECT_LIKE_TYPES` and `FILTER_LOOKUP_LIKE_TYPES`. It also exports the member type `FilterFieldType`. `FilterFieldSchema.type` builds its enum from the list, and `FilterField['type']` is `FilterFieldType`.
14+
- **`@object-ui/components`**: `filter-builder` takes its number-, date-, select- and lookup-like buckets from `@object-ui/types` and no longer declares its own. What it draws for each type is unchanged.

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ jobs:
451451
# spelled it, exactly as the two narrower `skills/` entries were.
452452
- 'docs/**'
453453
# The objectui tree, the root files the ui jobs build from, and the
454-
# four roots outside ui/ that the ui jobs' gates and tests read
454+
# five paths outside ui/ that the ui jobs' gates and tests read
455455
# (objectstack#25712):
456456
# - every root workflow, not ci.yml alone: Lint UI's pre-install
457457
# import graph check scans all of `.github/workflows/` for ui

‎skills/objectui/guides/app-composition.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,5 @@ source of truth for that mapping.
5959
- Console routes: `packages/app-shell/src/console/AppContent.tsx`
6060
- Bare data surface decision record: `docs/adr/0055-parameterized-bare-data-surface.md`
6161
(amends ADR-0053's context table)
62-
- Human-facing version: https://objectstack.ai/docs/ui/apps
62+
- Human-facing version: [Apps](https://objectstack.ai/docs/ui/apps)
6363
(derived from this guide — update both together)

‎skills/objectui/rules/styling.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,6 @@ channel triples, not finished colours:
315315
}
316316
```
317317

318-
See https://objectstack.ai/docs/frontend/theming for the token list and `ThemeProvider`.
318+
See [Theming](https://objectstack.ai/docs/frontend/theming) for tokens and `ThemeProvider`.
319319

320320
**Without these imports, ObjectUI components will render but look completely unstyled.**

‎ui/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ interface BaseSchema {
103103
- **#0.1 — Fix the metadata, not the renderer (contract-first).** Corollary to #0. This is a metadata-driven system: `@objectstack/spec` is the contract between producers and this renderer. When a piece of metadata "doesn't render," ask **first**: *is it spec-compliant? is this the long-term-correct direction?* If the metadata is off-spec, fix it at the **producer** (and have it rejected at authoring/publish) — do **not** add a lenient fallback/alias in the renderer (reading both `columns` and `fields`, coercing a malformed shape, `??`-defaulting around bad input) to make non-compliant metadata "work." A tolerant fallback fossilizes the wrong convention into a second de-facto contract, dilutes the spec, and hides the producer's bug — one strict contract beats N dialects. We own both ends, so Postel's "be liberal in what you accept" does **not** apply (that's for untrusted boundaries). Change the **spec** only when it is genuinely wrong — deliberately, in `@objectstack/spec`, never by accreting renderer-side fallbacks.
104104
- **The TypeScript authoring face is part of the contract (objectui#7927 ruling, executed by objectui#8347).** `BaseSchema` declares no index signature, so a node literal typed with its node type refuses, at the authoring site, a key no declaration names — a misspelling included. When a key you need is refused, this rule applies unchanged: declare it on the node type that reads it, by reference to the spec row, or fix the producer; a key only the host writes at runtime is typed on a package-local hand-off type where its producer and consumer live, never on the authoring face (objectui#6356). ⛔ Never make it compile with `as any`, a cast to `Record<string, unknown>`, a re-added index signature or an open `type: string` arm. Tolerance stays where it is deliberate: renderer props (`ComponentRendererProps` keeps its signature) and the tolerant zod face (`.passthrough()`); the derived strict zod face, `StrictAnyComponentSchema`, refuses an unknown key in metadata that arrives as data. ⚠️ The bound, said per #9: TypeScript excess-checks only a FRESH object literal, so a value that reached its annotation through a wider variable is not re-checked. `packages/types/src/__tests__/base-schema-closed-face-8347.test.ts` pins both directions and that bound.
105105
- **#1 — Protocol-agnostic.** Never hardcode `objectql.find()`. Use the DataSource interface; inject `dataSource` via `<SchemaRendererProvider dataSource={...} />`.
106-
- **#2 — Docs-driven.** For every feature/refactor, update package `README.md` **and** `content/docs/guide/*.md`. Not done until docs reflect the code.
106+
- **#2 — Docs-driven.** For every feature/refactor, update package `README.md` **and** the matching page under `content/docs/frontend/` (the repo root's docs tree). Not done until docs reflect the code.
107107
- **#3 — "Shadcn-native" aesthetics.** We are "serializable Shadcn". Follow Shadcn's DOM structure (`CardHeader`/`CardTitle`/`CardContent`). Always expose `className` in schema props so users can override via JSON.
108108
- **#4 — Action system (objectui#7898, objectui#6497, objectui#11183).** Actions are **data, not functions**, and a control that RUNS something is its own NODE TYPE — `action:button` — never a handler key or an event bag on an ordinary node. The block's props live in the node's `properties` bag, which is the spec's `ComponentPropsMap` row for it: `actionType` names the executor the action runner dispatches to, and the row's other keys carry that executor's arguments. ⛔ Never write them flat on the node: the spec's `PageComponentSchema` is `.strict()` (ADR-0089 D3a) and refuses a node-level `actionType` / `target` as mis-layered, and objectui's strict authoring face refuses the same two keys. The runtime reads both spellings through the `SchemaRenderer` `properties` hoist, so the bag costs nothing at render:
109109
```json

‎ui/examples/schema-catalog/test/filter-builder-mirror-6939.test.tsx‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { render } from '@testing-library/react';
4747
import { createHash } from 'node:crypto';
4848
import '@object-ui/components';
4949
import { SchemaRenderer, toRenderableSchema } from '@object-ui/react';
50+
import { FILTER_FIELD_TYPES } from '@object-ui/types';
5051
import { getExample } from '../src/index.js';
5152

5253
const IDS = [
@@ -403,13 +404,30 @@ describe('objectui#6939 — the vocabulary the mirror now names is live', () =>
403404
expect(oneRow('string').inputs).not.toEqual(oneRow('number').inputs);
404405
});
405406

406-
it.each(['status', 'currency', 'percent', 'rating', 'lookup', 'master_detail', 'user'])(
407-
'`%s` is live too, and the mirror refuses it — a pre-existing gap, reported not repaired',
408-
(type) => {
409-
// These draw a REAL control (a number input, or the option-driven Select),
410-
// so they are not phantoms like `string`; the mirror refused them before
411-
// this change and still does. Recorded here so the gap is a decision.
412-
expect(oneRow(type).inputs).not.toEqual(oneRow('zzz-not-a-field-type').inputs);
413-
},
414-
);
407+
/**
408+
* objectstack#27369 — the ruling's precondition, held for EVERY member of the
409+
* shared list rather than a hand-kept seven: a member that `FilterFieldSchema`
410+
* accepts must draw something a nonsense spelling does not. A fifteenth
411+
* member added to `FILTER_FIELD_TYPES` without a bucket reddens here.
412+
*
413+
* `text` is the one member left out, and only because it IS the fallthrough
414+
* target: it draws what a nonsense spelling draws by construction, and its
415+
* branch is by NAME (`fieldType || "text"`), pinned in the validator half.
416+
*/
417+
const BRANCHED = FILTER_FIELD_TYPES.filter((type) => type !== 'text');
418+
419+
it('the branched population is the whole list but `text`', () => {
420+
expect(BRANCHED).toHaveLength(FILTER_FIELD_TYPES.length - 1);
421+
expect(BRANCHED.length).toBeGreaterThan(0);
422+
});
423+
424+
it.each(BRANCHED)('`%s` draws a branch of its own, not the fallthrough', (type) => {
425+
const nonsense = oneRow('zzz-not-a-field-type');
426+
const row = oneRow(type);
427+
expect(
428+
`${row.inputs.join(',')}|${row.triggers.length}`,
429+
`FILTER_FIELD_TYPES carries \`${type}\`, but the renderer draws it exactly as an unrecognised `
430+
+ 'spelling — give it a bucket in custom/filter-builder.tsx or take it out of the list',
431+
).not.toBe(`${nonsense.inputs.join(',')}|${nonsense.triggers.length}`);
432+
});
415433
});

‎ui/packages/components/src/custom/filter-builder.tsx‎

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@ import {
1818
} from "@objectstack/spec/ui"
1919
import { expandEmptyOperator } from "@objectstack/spec/data"
2020
import { SchemaRendererContext } from "@object-ui/react"
21-
import type {
22-
FilterBuilderCondition as AuthoredFilterBuilderCondition,
23-
FilterGroup as AuthoredFilterGroup,
21+
import {
22+
FILTER_DATE_LIKE_TYPES,
23+
FILTER_LOOKUP_LIKE_TYPES,
24+
FILTER_NUMBER_LIKE_TYPES,
25+
FILTER_SELECT_LIKE_TYPES,
26+
type FilterBuilderCondition as AuthoredFilterBuilderCondition,
27+
type FilterGroup as AuthoredFilterGroup,
2428
} from "@object-ui/types"
2529
// The retirement gate (objectui#4914, ruling B). Read from
2630
// `@object-ui/core` rather than from `@object-ui/fields` — which is where
@@ -1203,24 +1207,18 @@ const dateOperators: readonly FilterBuilderOperator[] = ["equals", "not_equals",
12031207
const selectOperators: readonly FilterBuilderOperator[] = ["equals", "not_equals", "in", "not_in", "is_empty", "is_not_empty", ...NULLNESS_OPERATORS]
12041208
const lookupOperators: readonly FilterBuilderOperator[] = ["equals", "not_equals", "in", "not_in", "is_empty", "is_not_empty", ...NULLNESS_OPERATORS]
12051209

1206-
/** Field types that share the same operator/input behavior as number (numeric comparison operators, number input) */
1207-
const numberLikeTypes = ["number", "currency", "percent", "rating"]
1208-
/** Field types that share the same operator/input behavior as date (before/after operators, date/datetime/time input) */
1209-
const dateLikeTypes = ["date", "datetime", "time"]
1210-
/** Field types that use select operators (equals/in/not_in) and render dropdown or checkbox list when options provided */
1211-
const selectLikeTypes = ["select", "status"]
1212-
/**
1213-
* Relational/reference field types that use lookup operators (equals/in/not_in)
1214-
* and render dropdown or checkbox list when options provided.
1215-
*
1216-
* `owner` left this list with objectui#4914: it is a RETIRED spelling, and
1217-
* the gate below answers for it before this membership test is ever reached.
1218-
* The deletion is lockstep hygiene rather than the behavioural half — a list
1219-
* that still named a retired spelling would be a second, contradicting
1220-
* authority on what is live — and it is exactly the shape PR #4932 used when
1221-
* it retired the same word from `INLINE_ROUTED_FIELD_TYPES`.
1222-
*/
1223-
const lookupLikeTypes = ["lookup", "master_detail", "user"]
1210+
// The four behaviour buckets are declared once, in `@object-ui/types`
1211+
// (`filter-field-types.ts`), where `FilterFieldSchema.type` and
1212+
// `FilterField['type']` take the same members — so a spelling cannot be
1213+
// validated without a branch here, or drawn here without being validated.
1214+
// Widened to `readonly string[]` because every test below asks about an
1215+
// authored, unchecked `type` string. `owner` is in none of them: it is a
1216+
// RETIRED spelling (objectui#4914), answered by the gate in
1217+
// `operatorsForFieldType` before any membership test is reached.
1218+
const numberLikeTypes: readonly string[] = FILTER_NUMBER_LIKE_TYPES
1219+
const dateLikeTypes: readonly string[] = FILTER_DATE_LIKE_TYPES
1220+
const selectLikeTypes: readonly string[] = FILTER_SELECT_LIKE_TYPES
1221+
const lookupLikeTypes: readonly string[] = FILTER_LOOKUP_LIKE_TYPES
12241222

12251223
/**
12261224
* The operators a row on a field of `fieldType` can HOLD, given the opt-in ids

0 commit comments

Comments
 (0)