diff --git a/.changeset/object-nav-filters.md b/.changeset/object-nav-filters.md new file mode 100644 index 0000000000..e1541cd791 --- /dev/null +++ b/.changeset/object-nav-filters.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): `ObjectNavItem.filters` — declarative URL filter conditions targeting the parameterized bare data surface (objectui ADR-0055, objectui#2251). + +An object nav item can now carry `filters: Record` (equality semantics). The shell resolves such an entry to `/:objectName/data?filter[]=` — 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. diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 32ee9cd3c8..39e1b1eba3 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -66,6 +66,17 @@ Links to an object's list view: { id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'account', icon: 'building', viewName: 'all_accounts' } ``` +Three optional target fields refine where the entry lands (precedence: `recordId` → `filters` → `viewName`): + +- `viewName` — anchor the entry to a named list view. +- `recordId` — deep-link straight to one record ("My Profile"); supports `{current_user_id}` / `{current_org_id}` template variables. +- `filters` — a one-off parameterized slice: the entry lands on the **bare data surface** (`/:objectName/data`) with each condition serialized as a removable `filter[]=` 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. + +```typescript +{ id: 'nav_my_open', type: 'object', label: 'My Open Deals', objectName: 'opportunity', + filters: { owner_id: '{current_user_id}', status: 'open' }, icon: 'user-check' } +``` + ### Dashboard Navigation Links to a dashboard: diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index ca2fa5e597..77cc53a352 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -83,6 +83,28 @@ describe('ObjectNavItemSchema', () => { }; expect(() => ObjectNavItemSchema.parse(navItem)).not.toThrow(); }); + + it('should accept filters targeting the bare data surface (ADR-0055)', () => { + const navItem = { + id: 'nav_my_open', + label: 'My Open Tickets', + type: 'object' as const, + objectName: 'ticket', + filters: { owner_id: '{current_user_id}', status: 'open' }, + }; + expect(() => ObjectNavItemSchema.parse(navItem)).not.toThrow(); + }); + + it('should reject non-string filter values', () => { + const navItem = { + id: 'nav_bad_filters', + label: 'Bad', + type: 'object' as const, + objectName: 'ticket', + filters: { status: 1 }, + }; + expect(() => ObjectNavItemSchema.parse(navItem)).toThrow(); + }); }); describe('DashboardNavItemSchema', () => { diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 0ce79fc453..df3b8054c6 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -104,6 +104,12 @@ const BaseNavItemSchema = z.object({ * { id: 'nav_profile', type: 'object', label: 'My Profile', * objectName: 'sys_user', recordId: '{current_user_id}' } * ``` + * + * @example Parameterized slice on the bare data surface (objectui ADR-0055) + * ```ts + * { id: 'nav_my_open', type: 'object', label: 'My Open Tickets', + * objectName: 'ticket', filters: { owner_id: '{current_user_id}', status: 'open' } } + * ``` */ export const ObjectNavItemSchema = lazySchema(() => BaseNavItemSchema.extend({ type: z.literal('object'), @@ -126,6 +132,19 @@ export const ObjectNavItemSchema = lazySchema(() => BaseNavItemSchema.extend({ recordMode: z.enum(['view', 'edit']).optional().describe( 'Open the record in view (default) or edit mode. Only meaningful when `recordId` is set.', ), + /** + * URL filter conditions — the entry targets the parameterized bare data + * surface (`/:objectName/data`, objectui ADR-0055) with each entry + * serialized as a `filter[]=` search param (equality + * semantics), instead of anchoring to a saved view. Use for one-off / + * parameterized slices (dashboard drill-throughs, "assigned to me" + * links); a slice worth curating and reusing belongs in a named view + * via `viewName`. Values support the same template variables as + * `recordId`. Precedence: `recordId` → `filters` → `viewName`. + */ + filters: z.record(z.string(), z.string()).optional().describe( + 'URL filter conditions — targets the /:objectName/data bare surface via filter[]= params instead of a saved view. Values support template vars {current_user_id}, {current_org_id}. Precedence: recordId → filters → viewName.', + ), })); /** diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index 756b3e6589..a442cbd254 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -434,6 +434,10 @@ export const CrmApp = App.create({ { id: 'nav_opportunity', type: 'object', objectName: 'opportunity', label: 'Opportunities', icon: 'target' }, // Open a specific named view instead of the object default: { id: 'nav_pipeline', type: 'object', objectName: 'opportunity', viewName: 'pipeline_kanban', label: 'Sales Pipeline', icon: 'columns-3' }, + // One-off parameterized slice — lands on the bare data surface + // (`/:objectName/data`, objectui ADR-0055) with removable URL filter + // chips, NOT anchored to a saved view. Don't author a view for these: + { id: 'nav_my_open', type: 'object', objectName: 'opportunity', filters: { owner_id: '{current_user_id}', status: 'open' }, label: 'My Open Deals', icon: 'user-check' }, { id: 'nav_dash', type: 'dashboard', dashboardName: 'sales_dashboard', label: 'Sales Dashboard', icon: 'chart-bar' }, { id: 'nav_report', type: 'report', reportName: 'opportunities_by_stage', label: 'Opps by Stage', icon: 'bar-chart-3' }, ], @@ -455,7 +459,7 @@ export const CrmApp = App.create({ | Type | Properties | Purpose | |:-----|:-----------|:--------| | `group` | `label`, `icon`, `expanded`, `children[]` | Collapsible group of items | -| `object` | `objectName`, `viewName?`, `label`, `icon` | Link to an object list (optionally a specific view) | +| `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` | | `dashboard` | `dashboardName`, `label`, `icon` | Link to a dashboard | | `report` | `reportName`, `label`, `icon` | Link to a report | | `page` | `pageName`, `label`, `icon` | Link to a custom Page (`type: 'home' | 'app_launcher' | ...`) | @@ -572,28 +576,41 @@ export const PipelineCoverageReport = defineReport({ --- -## Two Run Modes: Object Nav vs Interface Pages (ADR-0047) +## Three Run Modes: Object Nav vs Filters Slice vs Interface Pages (ADR-0047 / objectui ADR-0055) -Object list UI has **two run modes**, selected by the navigation item type: +Object list UI has **three run modes**, selected by the navigation item shape: -| | Data mode (`type: 'object'`) | Interface mode (`type: 'page'`) | -|:--|:--|:--| -| What renders | ALL list views as switcher tabs | One curated page referencing ONE view | -| User-created views | Allowed | Never | -| Quick filters | Auto-derived (or view `userFilters`) | Only what the author enabled | -| Visualization | Switchable (whitelist) | Locked unless whitelisted | +| | Data mode (`type: 'object'`) | Bare slice (`type: 'object'` + `filters`) | Interface mode (`type: 'page'`) | +|:--|:--|:--|:--| +| What renders | ALL list views as switcher tabs | The URL-defined slice, no saved-view tabs | One curated page referencing ONE view | +| Anchored to | Saved views | **The URL itself** (`/:objectName/data?filter[...]`) | Page config | +| User-created views | Allowed | "Save as view" exit only | Never | +| Quick filters | Auto-derived (or view `userFilters`) | Auto-derived + removable URL chips | Only what the author enabled | +| Visualization | Switchable (whitelist) | Switchable (URL filter state survives) | Locked unless whitelisted | **Decision rule — default to data mode.** Generate ONLY objects + list views + -navigation pointing at objects. Generate an interface page ONLY on explicit -signals in the requirement: - -- persona split ("sales reps see…", customer portal, 给业务部门的简化界面); -- capability narrowing ("users must not change views", "only filter by X"); -- curation language (workspace / 工作台 / "Airtable interface-like"). - -Ambiguity resolves to **no page** — data mode is a functional superset; a -missing page costs polish, a superfluous page is a permanently-maintained -duplicate asset. +navigation pointing at objects. Escalate only on explicit signals: + +- **`filters` slice** — the entry is a one-off / parameterized condition + (dashboard drill-through, "assigned to me" link, a shared URL). Don't + author a view for it; a slice graduates to a named view only when it is + curated and reused. Values support `{current_user_id}` / `{current_org_id}`. + Never treat it as security: the surface shows what row-level permissions + allow. (Canonical rules: objectui `skills/objectui/guides/app-composition.md` + + `docs/adr/0055-parameterized-bare-data-surface.md`.) +- **Interface page** — persona split ("sales reps see…", customer portal, + 给业务部门的简化界面); capability narrowing ("users must not change views", + "only filter by X"); curation language (workspace / 工作台 / "Airtable + interface-like"). + +Ambiguity resolves to **no page and no view** — data mode is a functional +superset; a missing page costs polish, a superfluous page (or a view authored +for a one-off slice) is a permanently-maintained duplicate asset. + +> One-sentence rule: prefer the object's default view over a pinned +> `viewName`; prefer URL `filters` over authoring a view for one-off slices; +> prefer a named view over a page; use a page only for composition a single +> object view cannot express. Every target appears exactly once. **The iron rule:** an interface page REFERENCES a view (`interfaceConfig.source` + `sourceView`) and adds presentation policy only (`userFilters`,