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
7 changes: 7 additions & 0 deletions .changeset/object-nav-filters.md
Original file line number Diff line number Diff line change
@@ -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<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.
11 changes: 11 additions & 0 deletions content/docs/ui/apps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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[<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.

```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:
Expand Down
22 changes: 22 additions & 0 deletions packages/spec/src/ui/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
19 changes: 19 additions & 0 deletions packages/spec/src/ui/app.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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[<field>]=<value>` 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[<field>]=<value> params instead of a saved view. Values support template vars {current_user_id}, {current_org_id}. Precedence: recordId → filters → viewName.',
),
}));

/**
Expand Down
55 changes: 36 additions & 19 deletions skills/objectstack-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
Expand All @@ -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' | ...`) |
Expand Down Expand Up @@ -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`,
Expand Down