Skip to content

Commit 970bb4c

Browse files
docs(skills): objectstack-ui 补 searchableFields 章节 —— 工具栏搜索的收窄语义与失败边界 (#6675) (#6898)
`skills/objectstack-ui/SKILL.md` 此前对 `searchableFields` 零覆盖:该键唯一出 现的位置是生成的 `references/react-blocks.md`(由 packages/spec 生成,手改会顶掉 `check:skill-refs`)。而列表视图工具栏的搜索框正是 view 级收窄(ADR-0061)和 #4254 点号路径 400 真正打到 UI 作者的地方。PR #6670 已补上 docs 侧 (`content/docs/ui/views.mdx` 的表格行),skill 侧仍是空的 —— 而 skill 才是 AI 作者在授权时真正加载的东西。 新增 "Toolbar Search (`searchableFields`, ADR-0061)" 一节,落在 "Configuring a List View" 既有结构里(End-User Quick Filters 之后、Sorting 之前), 两个 `os:check` 例子 + 边界表 + 逐字错误文案。 不只教 happy path —— 每条边界都实测过,分两层: - **允许集由对象决定**:对象声明了 `searchableFields` 就是那份清单本身 (按存在性过滤,**不看类型**);没声明才走 auto-default(name 字段 + 文本类列)。 因此在声明了 `['subject','account_id']` 的对象上,view 收窄到 `['account_id']` 这个 lookup 是**放行**的,而收窄到对象没列进去的 `text` 列反而被**拒绝** —— 判据是集合成员,不是字段类型。 - **一条坏 entry 会让该列表的每一次搜索 400**:客户端把这份声明逐字回显为 `$searchFields`,入口门在引擎之前就拒,炸的是整个搜索框而不是变窄的结果。 - **`searchableFields: []` 不是"关掉搜索"**:三层都把空数组当作缺省 —— 客户端 整个不发这个 key,入口门把零长度覆盖当没覆盖,引擎回落到对象的允许集。写 `[]` 比写 `['subject']` 搜得**更宽**,与字面直觉相反。真要去掉搜索框是 `userActions: { search: false }`,另一个键。 - 相关记录标题只能靠**存储镜像字段**,点号路径永远被拒;完整处方(镜像字段、两条 维护 hook、为什么不能用 formula)指向 objectstack-data,不在此重述。 验证:两个 `os:check` 块除了过 `check:skill-examples` 的 tsc,还逐字抽出来跑过 真正解析它们的运行时 schema —— `defineView` → `ViewSchema` → `ObjectListViewSchema`(`packages/spec/src/ui/view.zod.ts`),确认 `searchableFields` 与 `userActions.search` 都能穿过 parse 存活。 `packages/lint/src/validate-searchable-fields.test.ts` 新增一组 skill-parity 用例, 把本节逐字引用的两条诊断和三条边界钉住(skill 经 `npx skills add` 发给第三方, 措辞漂了而没人回看 skill,发出去的就是平台已经没有的规则)。`[]` 那条的承重断言 刻意放在 `resolveSearchFields` 上而不是 lint 上 —— lint 对空数组是"什么都没产出" 式的绿,不可能因回归转红。 Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2934761 commit 970bb4c

2 files changed

Lines changed: 281 additions & 0 deletions

File tree

packages/lint/src/validate-searchable-fields.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { resolveSearchFields } from '@objectstack/spec/data';
45
import {
56
validateSearchableFields,
67
SEARCHABLE_FIELD_UNKNOWN,
@@ -475,3 +476,167 @@ describe('validateSearchableFields — list views that narrow the set', () => {
475476
expect(findings).toEqual([]);
476477
});
477478
});
479+
480+
/**
481+
* [#6675] Skill-parity — `skills/objectstack-ui/SKILL.md` › "Toolbar Search
482+
* (`searchableFields`, ADR-0061)" quotes this rule's two diagnostics verbatim
483+
* and states three boundaries as fact. The skill ships to third parties via
484+
* `npx skills add`, so a reader who follows it is following THIS code; if the
485+
* wording or a verdict moves and nobody re-reads the skill, the published text
486+
* teaches a rule the platform no longer has.
487+
*
488+
* The same reason `validate-rls-predicate-enforceability.test.ts` pins the RLS
489+
* predicates the data skill prints. Change any assertion here and the skill
490+
* section is what needs editing, not the assertion.
491+
*/
492+
describe('validateSearchableFields — objectstack-ui SKILL.md parity (#6675)', () => {
493+
/** The object the skill's examples and quoted error texts are written against. */
494+
const supportCase = {
495+
name: 'support_case',
496+
nameField: 'subject',
497+
searchableFields: ['subject', 'case_number', 'description'],
498+
fields: {
499+
subject: { type: 'text' },
500+
case_number: { type: 'autonumber' },
501+
description: { type: 'textarea' },
502+
status: { type: 'select' },
503+
account_id: { type: 'lookup', reference: 'crm_account' },
504+
account_name: { type: 'text' },
505+
},
506+
};
507+
508+
/** A `defineView` container whose `triage` list narrows the object's set. */
509+
const viewStack = (searchableFields: unknown, objectOverrides: Record<string, unknown> = {}) => ({
510+
objects: [{ ...supportCase, ...objectOverrides }],
511+
views: [
512+
{
513+
name: 'support_case',
514+
objectName: 'support_case',
515+
list: {
516+
label: 'All Cases',
517+
type: 'grid',
518+
data: { provider: 'object', object: 'support_case' },
519+
columns: ['subject', 'status'],
520+
},
521+
listViews: {
522+
triage: {
523+
label: 'Triage',
524+
type: 'grid',
525+
data: { provider: 'object', object: 'support_case' },
526+
columns: ['case_number', 'subject', 'status'],
527+
...(searchableFields === undefined ? {} : { searchableFields }),
528+
},
529+
},
530+
},
531+
],
532+
});
533+
534+
it('the skill\'s `os:check` example lints clean — a subset of the allowed set', () => {
535+
// SKILL.md: `listViews.triage.searchableFields: ['case_number', 'subject']`.
536+
expect(validateSearchableFields(viewStack(['case_number', 'subject']))).toEqual([]);
537+
});
538+
539+
it('omitting the key lints clean (row 2 of the skill\'s boundary table)', () => {
540+
expect(validateSearchableFields(viewStack(undefined))).toEqual([]);
541+
});
542+
543+
/**
544+
* The skill states an empty array is identical to omitting the key — the
545+
* claim an author most needs, because the spelling suggests the opposite.
546+
*
547+
* The lint half of it is deliberately NOT the assertion that carries this
548+
* test. `checkSearchableFieldList` returns early on a zero-length array, and
549+
* even without that early return the entry loop has nothing to iterate — so
550+
* "lints clean" is green because nothing was produced, not because the
551+
* verdict is right, and it cannot go red on a regression. It is asserted
552+
* below only to pin that no finding appears; the load-bearing assertion is
553+
* the next one.
554+
*
555+
* `resolveSearchFields` is where `[]` acquires meaning: it is the ONE
556+
* resolution the ingress gate (`assertSearchFieldsAreSearchable`) and the
557+
* engine (`expandSearchToFilter`) share, so an empty request resolving to
558+
* the full allowed set IS the runtime behaviour the skill describes. Narrow
559+
* the fall-through and this goes red.
560+
*/
561+
it('`searchableFields: []` is ABSENT, not "search off" — it resolves to the FULL allowed set', () => {
562+
expect(validateSearchableFields(viewStack([]))).toEqual([]);
563+
564+
const resolutionArgs = {
565+
fields: supportCase.fields,
566+
searchableFields: supportCase.searchableFields,
567+
displayField: supportCase.nameField,
568+
};
569+
// An empty narrowing scans every column the object allows …
570+
expect(resolveSearchFields({ ...resolutionArgs, requestedFields: [] }))
571+
.toEqual(['subject', 'case_number', 'description']);
572+
// … which is exactly what omitting the key does …
573+
expect(resolveSearchFields(resolutionArgs))
574+
.toEqual(['subject', 'case_number', 'description']);
575+
// … and strictly MORE than a one-entry narrowing, the inversion the skill
576+
// calls out: `[]` searches wider than `['subject']`.
577+
expect(resolveSearchFields({ ...resolutionArgs, requestedFields: ['subject'] }))
578+
.toEqual(['subject']);
579+
});
580+
581+
it('quotes the dotted-path diagnostic exactly as the skill prints it', () => {
582+
const findings = validateSearchableFields(viewStack(['subject', 'account_id.name']));
583+
584+
expect(findings).toHaveLength(1);
585+
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNKNOWN);
586+
expect(findings[0].severity).toBe('error');
587+
expect(findings[0].message).toBe(
588+
'list-view searchableFields entry "account_id.name" is not a field on object '
589+
+ '"support_case". The declaration is stale: searching it can never match, and the '
590+
+ 'engine silently drops it — leaving a narrower search than declared, or the '
591+
+ 'auto-default set once every entry is dropped.',
592+
);
593+
});
594+
595+
it('quotes the outside-the-declared-set diagnostic exactly as the skill prints it', () => {
596+
const findings = validateSearchableFields(viewStack(['subject', 'status']));
597+
598+
expect(findings).toHaveLength(1);
599+
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
600+
expect(findings[0].severity).toBe('error');
601+
expect(findings[0].message).toBe(
602+
'list-view searchableFields entry "status" is outside object "support_case"\'s '
603+
+ 'declared searchableFields (subject, case_number, description) — the set \'search\' '
604+
+ 'scans. Clients echo this declaration verbatim as the \'$searchFields\' override, '
605+
+ 'and the runtime refuses an entry outside the allowed set: every toolbar search on '
606+
+ 'this list returns 400 INVALID_FIELD (#4254).',
607+
);
608+
});
609+
610+
/**
611+
* The correction the skill makes to a type-first reading: on an object that
612+
* DECLARES its set, the declaration is the boundary and the field's type is
613+
* not consulted — a lookup inside it is scanned, a text column outside it is
614+
* refused. Both directions, because either alone reads as a coincidence.
615+
*/
616+
it('a lookup INSIDE the object\'s declared set is accepted; a text column OUTSIDE it is not', () => {
617+
const declaresLookup = { searchableFields: ['subject', 'account_id'] };
618+
619+
expect(validateSearchableFields(viewStack(['account_id'], declaresLookup))).toEqual([]);
620+
621+
const refused = validateSearchableFields(viewStack(['account_name'], declaresLookup));
622+
expect(refused).toHaveLength(1);
623+
expect(refused[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
624+
expect(refused[0].message).toContain('"account_name"');
625+
});
626+
627+
/**
628+
* …and the mirror image: with NO declaration on the object, the auto-default
629+
* is the boundary, so type is exactly what decides. `select` is in the
630+
* text-like set the skill lists; `lookup` is not.
631+
*/
632+
it('with no object declaration, the auto-default type list decides', () => {
633+
const noDeclaration = { searchableFields: undefined };
634+
635+
expect(validateSearchableFields(viewStack(['subject', 'status'], noDeclaration))).toEqual([]);
636+
637+
const refused = validateSearchableFields(viewStack(['account_id'], noDeclaration));
638+
expect(refused).toHaveLength(1);
639+
expect(refused[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
640+
expect(refused[0].message).toContain("of type 'lookup', which 'search' cannot scan");
641+
});
642+
});

skills/objectstack-ui/SKILL.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,122 @@ Rules:
342342
right cluster. Authors only control the `allowedVisualizations` whitelist;
343343
a single-entry whitelist locks the visualization (no switcher).
344344

345+
### Toolbar Search (`searchableFields`, ADR-0061)
346+
347+
The toolbar's search box scans a set the **object** owns. A list view's
348+
`searchableFields` **narrows** that set for this one list — it can never widen
349+
it, and the runtime enforces that by **refusing the request**, not by quietly
350+
dropping the extra name.
351+
352+
<!-- os:check -->
353+
```typescript
354+
import { defineView } from '@objectstack/spec';
355+
356+
const data = { provider: 'object' as const, object: 'support_case' };
357+
358+
export const CaseViews = defineView({
359+
// No `searchableFields` → the toolbar searches everything the object allows.
360+
list: { label: 'All Cases', type: 'grid', data, columns: ['subject', 'status'] },
361+
listViews: {
362+
// This list only: search the reference number and the subject line.
363+
triage: {
364+
label: 'Triage', type: 'grid', data,
365+
columns: ['case_number', 'subject', 'status'],
366+
searchableFields: ['case_number', 'subject'],
367+
},
368+
},
369+
});
370+
```
371+
372+
**What the object allows** is resolved server-side, and it is the whole rule:
373+
374+
| The object … | The allowed set is |
375+
|:-------------|:-------------------|
376+
| declares `searchableFields` | **that list, verbatim** — whatever the field types are |
377+
| declares nothing | the auto-default: the name field + the text-like columns (`text` / `email` / `phone` / `url` / `autonumber` / `textarea` / `markdown` / `select` / `status`) |
378+
379+
So field **type** decides only in the second row. On an object that declares
380+
`searchableFields: ['subject', 'account_id']`, a view narrowing to
381+
`['account_id']` — a lookup — is **accepted** and scanned; on the same object,
382+
narrowing to a `text` column the object left out is **refused**. Judge every
383+
entry against the object's allowed set, never against the type list.
384+
385+
Modelling side — the object's own set, and the stored-mirror prescription for
386+
searching by a related record's title: **objectstack-data → Search Fields
387+
(`searchableFields`)**. Query side (`search.fields` over the API):
388+
**objectstack-query → Full-Text Search**.
389+
390+
#### ⛔ One bad entry 400s EVERY search on that list
391+
392+
The client echoes this declaration verbatim as the `$searchFields` override —
393+
the active view's list wins over the object's — and the ingress gate refuses any
394+
entry outside the allowed set before the engine ever runs. The blast radius is
395+
the list's whole search box, for every user and every term: not a narrower
396+
result, no result at all.
397+
398+
| What you write on the view | `os validate` | Toolbar search at runtime |
399+
|:---------------------------|:--------------|:--------------------------|
400+
| a subset of the allowed set | clean | scans exactly those columns |
401+
| key omitted | clean | scans the object's full allowed set |
402+
| `searchableFields: []` | clean | **identical to omitting it** — see below |
403+
| a renamed / mistyped column | `searchable-field-unknown` | `400 INVALID_FIELD` |
404+
| a dotted path (`account_id.name`) | `searchable-field-unknown` | `400 INVALID_FIELD` |
405+
| a real column outside the allowed set | `searchable-field-unsearchable` | `400 INVALID_FIELD` |
406+
407+
Both diagnostics are **errors**, not warnings — `os validate` fails the build.
408+
The two you will actually hit, verbatim:
409+
410+
```text
411+
list-view searchableFields entry "account_id.name" is not a field on object
412+
"support_case". The declaration is stale: searching it can never match, and the
413+
engine silently drops it — leaving a narrower search than declared, or the
414+
auto-default set once every entry is dropped.
415+
416+
list-view searchableFields entry "status" is outside object "support_case"'s
417+
declared searchableFields (subject, case_number, description) — the set 'search'
418+
scans. Clients echo this declaration verbatim as the '$searchFields' override,
419+
and the runtime refuses an entry outside the allowed set: every toolbar search
420+
on this list returns 400 INVALID_FIELD (#4254).
421+
```
422+
423+
#### `searchableFields: []` does NOT turn search off
424+
425+
An empty array is **absent**, at all three layers: the client omits the
426+
`$searchFields` key entirely, the ingress gate treats a zero-length override as
427+
no override, and the engine falls through to the object's allowed set. A view
428+
written `searchableFields: []` searches **more** columns than one written
429+
`searchableFields: ['subject']`, which is the opposite of what the spelling
430+
suggests.
431+
432+
To actually remove the search box from the toolbar, toggle the affordance —
433+
a different key, on the same view:
434+
435+
<!-- os:check -->
436+
```typescript
437+
import { defineView } from '@objectstack/spec';
438+
439+
const data = { provider: 'object' as const, object: 'support_case' };
440+
441+
export const AuditViews = defineView({
442+
list: {
443+
label: 'Audit Log', type: 'grid', data,
444+
columns: ['case_number', 'status'],
445+
userActions: { search: false }, // ← no search box; `searchableFields: []` would NOT do this
446+
},
447+
});
448+
```
449+
450+
#### Searching by a related record's title
451+
452+
Never reach for a dotted path. `search` scans the queried object's **own**
453+
columns — unlike `columns` / `sort` / `filter`, the search axis resolves no
454+
traversal, so `account_id.name` is refused rather than silently dropped. Copy
455+
the parent's title into a **stored** field on this object and put that field in
456+
the object's `searchableFields`; the view then narrows to it like any other
457+
column. The full prescription — the mirror field, the two hooks that maintain
458+
it, and why a `formula` field cannot be the mirror — lives in
459+
**objectstack-data → Search Fields (`searchableFields`)**.
460+
345461
### Sorting
346462

347463
```typescript

0 commit comments

Comments
 (0)