Skip to content

Commit 2bb193d

Browse files
os-zhuangclaude
andauthored
feat(spec): ObjectNavItem.filters — declarative slices on the bare data surface (#2626)
Sync the framework half of objectui#2251 / objectui ADR-0055 (the /:objectName/data parameterized bare data surface shipped in objectui#2255): - spec: ObjectNavItemSchema gains optional filters (Record<string,string>, equality semantics) with JSDoc + .describe() mirroring @object-ui/types verbatim; example added; 2 new schema tests (accept filters + reject non-string values) - skills/objectstack-ui: the 'Two Run Modes' section becomes three — data mode / bare filters slice / interface page — with the decision signals, the one-sentence generation rule, and a pointer to the canonical objectui app-composition guide instead of a forked copy; nav example + item-type table updated (target precedence recordId -> filters -> viewName) - content/docs/ui/apps.mdx: Object Navigation section documents the three target fields and the filters example - changeset: minor for @objectstack/spec api-surface.json unchanged (field-level addition, no new exports). Spec suite: 6692/6692 pass. Claude-Session: https://claude.ai/code/session_018m3GX7EMKNPDZuee152EKK Co-authored-by: Claude <noreply@anthropic.com>
1 parent df11373 commit 2bb193d

5 files changed

Lines changed: 95 additions & 19 deletions

File tree

.changeset/object-nav-filters.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): `ObjectNavItem.filters` — declarative URL filter conditions targeting the parameterized bare data surface (objectui ADR-0055, objectui#2251).
6+
7+
An object nav item can now carry `filters: Record<string, string>` (equality semantics). The shell resolves such an entry to `/:objectName/data?filter[<field>]=<value>` — an unanchored data surface with removable filter chips — instead of a saved list view. Use it for one-off / parameterized slices (dashboard drill-throughs, "assigned to me" links); slices worth curating stay on `viewName`. Values support the same `{current_user_id}` / `{current_org_id}` template variables as `recordId`. Target precedence within `type: 'object'`: `recordId``filters``viewName`. Purely additive — items without `filters` are unaffected.

content/docs/ui/apps.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ Links to an object's list view:
6666
{ id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'account', icon: 'building', viewName: 'all_accounts' }
6767
```
6868

69+
Three optional target fields refine where the entry lands (precedence: `recordId``filters``viewName`):
70+
71+
- `viewName` — anchor the entry to a named list view.
72+
- `recordId` — deep-link straight to one record ("My Profile"); supports `{current_user_id}` / `{current_org_id}` template variables.
73+
- `filters` — a one-off parameterized slice: the entry lands on the **bare data surface** (`/:objectName/data`) with each condition serialized as a removable `filter[<field>]=<value>` URL chip, not anchored to any saved view. Use it for drill-throughs and "assigned to me"-style links instead of authoring a view; values support the same template variables. The surface shows what row-level permissions allow — it is not a security feature.
74+
75+
```typescript
76+
{ id: 'nav_my_open', type: 'object', label: 'My Open Deals', objectName: 'opportunity',
77+
filters: { owner_id: '{current_user_id}', status: 'open' }, icon: 'user-check' }
78+
```
79+
6980
### Dashboard Navigation
7081

7182
Links to a dashboard:

packages/spec/src/ui/app.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,28 @@ describe('ObjectNavItemSchema', () => {
8383
};
8484
expect(() => ObjectNavItemSchema.parse(navItem)).not.toThrow();
8585
});
86+
87+
it('should accept filters targeting the bare data surface (ADR-0055)', () => {
88+
const navItem = {
89+
id: 'nav_my_open',
90+
label: 'My Open Tickets',
91+
type: 'object' as const,
92+
objectName: 'ticket',
93+
filters: { owner_id: '{current_user_id}', status: 'open' },
94+
};
95+
expect(() => ObjectNavItemSchema.parse(navItem)).not.toThrow();
96+
});
97+
98+
it('should reject non-string filter values', () => {
99+
const navItem = {
100+
id: 'nav_bad_filters',
101+
label: 'Bad',
102+
type: 'object' as const,
103+
objectName: 'ticket',
104+
filters: { status: 1 },
105+
};
106+
expect(() => ObjectNavItemSchema.parse(navItem)).toThrow();
107+
});
86108
});
87109

88110
describe('DashboardNavItemSchema', () => {

packages/spec/src/ui/app.zod.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ const BaseNavItemSchema = z.object({
104104
* { id: 'nav_profile', type: 'object', label: 'My Profile',
105105
* objectName: 'sys_user', recordId: '{current_user_id}' }
106106
* ```
107+
*
108+
* @example Parameterized slice on the bare data surface (objectui ADR-0055)
109+
* ```ts
110+
* { id: 'nav_my_open', type: 'object', label: 'My Open Tickets',
111+
* objectName: 'ticket', filters: { owner_id: '{current_user_id}', status: 'open' } }
112+
* ```
107113
*/
108114
export const ObjectNavItemSchema = lazySchema(() => BaseNavItemSchema.extend({
109115
type: z.literal('object'),
@@ -126,6 +132,19 @@ export const ObjectNavItemSchema = lazySchema(() => BaseNavItemSchema.extend({
126132
recordMode: z.enum(['view', 'edit']).optional().describe(
127133
'Open the record in view (default) or edit mode. Only meaningful when `recordId` is set.',
128134
),
135+
/**
136+
* URL filter conditions — the entry targets the parameterized bare data
137+
* surface (`/:objectName/data`, objectui ADR-0055) with each entry
138+
* serialized as a `filter[<field>]=<value>` search param (equality
139+
* semantics), instead of anchoring to a saved view. Use for one-off /
140+
* parameterized slices (dashboard drill-throughs, "assigned to me"
141+
* links); a slice worth curating and reusing belongs in a named view
142+
* via `viewName`. Values support the same template variables as
143+
* `recordId`. Precedence: `recordId` → `filters` → `viewName`.
144+
*/
145+
filters: z.record(z.string(), z.string()).optional().describe(
146+
'URL filter conditions — targets the /:objectName/data bare surface via filter[<field>]=<value> params instead of a saved view. Values support template vars {current_user_id}, {current_org_id}. Precedence: recordId → filters → viewName.',
147+
),
129148
}));
130149

131150
/**

skills/objectstack-ui/SKILL.md

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,10 @@ export const CrmApp = App.create({
434434
{ id: 'nav_opportunity', type: 'object', objectName: 'opportunity', label: 'Opportunities', icon: 'target' },
435435
// Open a specific named view instead of the object default:
436436
{ id: 'nav_pipeline', type: 'object', objectName: 'opportunity', viewName: 'pipeline_kanban', label: 'Sales Pipeline', icon: 'columns-3' },
437+
// One-off parameterized slice — lands on the bare data surface
438+
// (`/:objectName/data`, objectui ADR-0055) with removable URL filter
439+
// chips, NOT anchored to a saved view. Don't author a view for these:
440+
{ id: 'nav_my_open', type: 'object', objectName: 'opportunity', filters: { owner_id: '{current_user_id}', status: 'open' }, label: 'My Open Deals', icon: 'user-check' },
437441
{ id: 'nav_dash', type: 'dashboard', dashboardName: 'sales_dashboard', label: 'Sales Dashboard', icon: 'chart-bar' },
438442
{ id: 'nav_report', type: 'report', reportName: 'opportunities_by_stage', label: 'Opps by Stage', icon: 'bar-chart-3' },
439443
],
@@ -455,7 +459,7 @@ export const CrmApp = App.create({
455459
| Type | Properties | Purpose |
456460
|:-----|:-----------|:--------|
457461
| `group` | `label`, `icon`, `expanded`, `children[]` | Collapsible group of items |
458-
| `object` | `objectName`, `viewName?`, `label`, `icon` | Link to an object list (optionally a specific view) |
462+
| `object` | `objectName`, `viewName?`, `recordId?`, `filters?`, `label`, `icon` | Link to an object list, a named view, a record deep-link, or a `filters` slice on the bare data surface. Target precedence: `recordId``filters``viewName` |
459463
| `dashboard` | `dashboardName`, `label`, `icon` | Link to a dashboard |
460464
| `report` | `reportName`, `label`, `icon` | Link to a report |
461465
| `page` | `pageName`, `label`, `icon` | Link to a custom Page (`type: 'home' | 'app_launcher' | ...`) |
@@ -572,28 +576,41 @@ export const PipelineCoverageReport = defineReport({
572576
573577
---
574578

575-
## Two Run Modes: Object Nav vs Interface Pages (ADR-0047)
579+
## Three Run Modes: Object Nav vs Filters Slice vs Interface Pages (ADR-0047 / objectui ADR-0055)
576580

577-
Object list UI has **two run modes**, selected by the navigation item type:
581+
Object list UI has **three run modes**, selected by the navigation item shape:
578582

579-
| | Data mode (`type: 'object'`) | Interface mode (`type: 'page'`) |
580-
|:--|:--|:--|
581-
| What renders | ALL list views as switcher tabs | One curated page referencing ONE view |
582-
| User-created views | Allowed | Never |
583-
| Quick filters | Auto-derived (or view `userFilters`) | Only what the author enabled |
584-
| Visualization | Switchable (whitelist) | Locked unless whitelisted |
583+
| | Data mode (`type: 'object'`) | Bare slice (`type: 'object'` + `filters`) | Interface mode (`type: 'page'`) |
584+
|:--|:--|:--|:--|
585+
| What renders | ALL list views as switcher tabs | The URL-defined slice, no saved-view tabs | One curated page referencing ONE view |
586+
| Anchored to | Saved views | **The URL itself** (`/:objectName/data?filter[...]`) | Page config |
587+
| User-created views | Allowed | "Save as view" exit only | Never |
588+
| Quick filters | Auto-derived (or view `userFilters`) | Auto-derived + removable URL chips | Only what the author enabled |
589+
| Visualization | Switchable (whitelist) | Switchable (URL filter state survives) | Locked unless whitelisted |
585590

586591
**Decision rule — default to data mode.** Generate ONLY objects + list views +
587-
navigation pointing at objects. Generate an interface page ONLY on explicit
588-
signals in the requirement:
589-
590-
- persona split ("sales reps see…", customer portal, 给业务部门的简化界面);
591-
- capability narrowing ("users must not change views", "only filter by X");
592-
- curation language (workspace / 工作台 / "Airtable interface-like").
593-
594-
Ambiguity resolves to **no page** — data mode is a functional superset; a
595-
missing page costs polish, a superfluous page is a permanently-maintained
596-
duplicate asset.
592+
navigation pointing at objects. Escalate only on explicit signals:
593+
594+
- **`filters` slice** — the entry is a one-off / parameterized condition
595+
(dashboard drill-through, "assigned to me" link, a shared URL). Don't
596+
author a view for it; a slice graduates to a named view only when it is
597+
curated and reused. Values support `{current_user_id}` / `{current_org_id}`.
598+
Never treat it as security: the surface shows what row-level permissions
599+
allow. (Canonical rules: objectui `skills/objectui/guides/app-composition.md`
600+
+ `docs/adr/0055-parameterized-bare-data-surface.md`.)
601+
- **Interface page** — persona split ("sales reps see…", customer portal,
602+
给业务部门的简化界面); capability narrowing ("users must not change views",
603+
"only filter by X"); curation language (workspace / 工作台 / "Airtable
604+
interface-like").
605+
606+
Ambiguity resolves to **no page and no view** — data mode is a functional
607+
superset; a missing page costs polish, a superfluous page (or a view authored
608+
for a one-off slice) is a permanently-maintained duplicate asset.
609+
610+
> One-sentence rule: prefer the object's default view over a pinned
611+
> `viewName`; prefer URL `filters` over authoring a view for one-off slices;
612+
> prefer a named view over a page; use a page only for composition a single
613+
> object view cannot express. Every target appears exactly once.
597614
598615
**The iron rule:** an interface page REFERENCES a view (`interfaceConfig.source`
599616
+ `sourceView`) and adds presentation policy only (`userFilters`,

0 commit comments

Comments
 (0)