@pilotiq/pilotiq 0.24.2 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/boost/guidelines.md +6 -1
- package/boost/skills/pilotiq-actions/SKILL.md +49 -0
- package/boost/skills/pilotiq-actions/rules/dispatch-modes.md +177 -0
- package/boost/skills/pilotiq-actions/rules/factories.md +130 -0
- package/boost/skills/pilotiq-actions/rules/visibility-and-authorization.md +125 -0
- package/dist/Pilotiq.d.ts +31 -0
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js +3 -1
- package/dist/Pilotiq.js.map +1 -1
- package/dist/PilotiqRegistry.d.ts +13 -0
- package/dist/PilotiqRegistry.d.ts.map +1 -1
- package/dist/PilotiqRegistry.js +15 -0
- package/dist/PilotiqRegistry.js.map +1 -1
- package/dist/pageData/forms.d.ts.map +1 -1
- package/dist/pageData/forms.js +5 -0
- package/dist/pageData/forms.js.map +1 -1
- package/dist/pageData/misc.d.ts.map +1 -1
- package/dist/pageData/misc.js +6 -0
- package/dist/pageData/misc.js.map +1 -1
- package/dist/pageData/navigation.d.ts +1 -0
- package/dist/pageData/navigation.d.ts.map +1 -1
- package/dist/pageData/navigation.js +3 -0
- package/dist/pageData/navigation.js.map +1 -1
- package/dist/pageData/relationPages.d.ts.map +1 -1
- package/dist/pageData/relationPages.js +3 -0
- package/dist/pageData/relationPages.js.map +1 -1
- package/dist/pageData/resourcePages.d.ts.map +1 -1
- package/dist/pageData/resourcePages.js +8 -0
- package/dist/pageData/resourcePages.js.map +1 -1
- package/dist/react/AppShell.d.ts +22 -0
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/NotificationBell.d.ts +41 -10
- package/dist/react/NotificationBell.d.ts.map +1 -1
- package/dist/react/NotificationBell.js +67 -47
- package/dist/react/NotificationBell.js.map +1 -1
- package/dist/react/SchemaRenderer.d.ts +11 -0
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +14 -1
- package/dist/react/SchemaRenderer.js.map +1 -1
- package/dist/react/UserMenu.d.ts +8 -2
- package/dist/react/UserMenu.d.ts.map +1 -1
- package/dist/react/UserMenu.js +33 -3
- package/dist/react/UserMenu.js.map +1 -1
- package/dist/react/breadcrumb-hoist.d.ts +3 -0
- package/dist/react/breadcrumb-hoist.d.ts.map +1 -0
- package/dist/react/breadcrumb-hoist.js +15 -0
- package/dist/react/breadcrumb-hoist.js.map +1 -0
- package/dist/react/fields/ColorInput.js +1 -1
- package/dist/react/fields/DateFieldInput.js +1 -1
- package/dist/react/fields/DateFieldInput.js.map +1 -1
- package/dist/react/fields/SelectFieldInput.d.ts.map +1 -1
- package/dist/react/fields/SelectFieldInput.js +3 -5
- package/dist/react/fields/SelectFieldInput.js.map +1 -1
- package/dist/react/fields/TagsInput.js +1 -1
- package/dist/react/fields/TextLikeInput.js +2 -2
- package/dist/react/fields/TextLikeInput.js.map +1 -1
- package/dist/react/layouts/SidebarLayout.d.ts +1 -1
- package/dist/react/layouts/SidebarLayout.d.ts.map +1 -1
- package/dist/react/layouts/SidebarLayout.js +21 -10
- package/dist/react/layouts/SidebarLayout.js.map +1 -1
- package/dist/react/layouts/TopbarLayout.d.ts.map +1 -1
- package/dist/react/layouts/TopbarLayout.js +1 -3
- package/dist/react/layouts/TopbarLayout.js.map +1 -1
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts.map +1 -1
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.js +4 -3
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.js.map +1 -1
- package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts.map +1 -1
- package/dist/react/schemaRenderer/action/ActionModalDialog.js +7 -5
- package/dist/react/schemaRenderer/action/ActionModalDialog.js.map +1 -1
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts.map +1 -1
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.js +7 -4
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.js.map +1 -1
- package/dist/react/schemaRenderer/action/buttons.d.ts.map +1 -1
- package/dist/react/schemaRenderer/action/buttons.js +10 -5
- package/dist/react/schemaRenderer/action/buttons.js.map +1 -1
- package/dist/react/schemaRenderer/table/TableRendererBody.js +1 -1
- package/dist/react/schemaRenderer/table/filters.d.ts.map +1 -1
- package/dist/react/schemaRenderer/table/filters.js +6 -4
- package/dist/react/schemaRenderer/table/filters.js.map +1 -1
- package/dist/react/ui/dropdown-menu.d.ts +4 -1
- package/dist/react/ui/dropdown-menu.d.ts.map +1 -1
- package/dist/react/ui/dropdown-menu.js +14 -2
- package/dist/react/ui/dropdown-menu.js.map +1 -1
- package/dist/react/ui/input.js +1 -1
- package/dist/react/ui/input.js.map +1 -1
- package/dist/react/ui/select.js +1 -1
- package/dist/react/ui/select.js.map +1 -1
- package/dist/react/ui/textarea.js +1 -1
- package/dist/react/ui/textarea.js.map +1 -1
- package/dist/react/widgets/StatsOverviewRenderer.d.ts.map +1 -1
- package/dist/react/widgets/StatsOverviewRenderer.js +32 -18
- package/dist/react/widgets/StatsOverviewRenderer.js.map +1 -1
- package/dist/routes/relations.d.ts.map +1 -1
- package/dist/routes/relations.js +25 -18
- package/dist/routes/relations.js.map +1 -1
- package/dist/routes/resources.js.map +1 -1
- package/dist/theme/presets.js +1 -1
- package/dist/theme/presets.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +5 -2
- package/dist/vite.js.map +1 -1
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# @pilotiq/pilotiq
|
|
2
2
|
|
|
3
|
+
## 0.25.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a7c0ffd: feat(pilotiq): align form controls to the shadcn input/button spec + tighter default spacing
|
|
8
|
+
|
|
9
|
+
Brings every text/control surface onto the shadcn component look for a more consistent, denser admin UI:
|
|
10
|
+
|
|
11
|
+
- **Inputs / Select / Textarea / field inputs** (`Input`, `SelectTrigger`, `Textarea`, plus the `DateField` trigger, `ColorPicker` swatch, `TagsInput`, and the Tiptap text chrome) → `h-8`, `rounded-lg`, `px-2.5`, `ring-3` focus, no drop shadow — matching the shadcn.com control set. The standalone `<Input>` was previously `h-9`.
|
|
12
|
+
- **Filters & Actions buttons** now use the shared shadcn button styling: the table toolbar's Filters triggers render via `buttonVariants({ variant: 'outline' })` (wrapped in `cn()` so `tailwind-merge` keeps the outline border), and `actionButtonClass` emits the `<Button>` chrome (`rounded-lg`, focus ring, `active:translate-y-px`, `h-8`/`h-7`/`h-9` sizes) while keeping the richer Action color palette (primary/destructive/success/warning/info + outlined).
|
|
13
|
+
- **Toolbar consistency**: the group-by / sort pickers drop `size="sm"` so they render at the default `h-8`, matching the search input.
|
|
14
|
+
- **Default spacing** density tightened — the default `vega` preset now resolves `--spacing` to `0.25rem` (Tailwind's stock unit) instead of `0.3rem`, so every `p-*`/`gap-*`/`m-*` tightens uniformly. Matches the theme-editor preview, which already used `0.25rem`.
|
|
15
|
+
|
|
16
|
+
No public API changes.
|
|
17
|
+
|
|
18
|
+
- e9e7dbb: feat(pilotiq): sidebar layout options + palette-driven stat sparklines
|
|
19
|
+
|
|
20
|
+
- `Pilotiq.layout('sidebar', opts?)` is now overloaded so the sidebar chrome options bind to the `'sidebar'` mode: `variant: 'sidebar' | 'floating' | 'inset'`, `collapsible: 'offcanvas' | 'icon' | 'none'`, `side: 'left' | 'right'` (defaults `inset` / `icon` / `left`). `.layout('topbar', {...})` is a compile error so sidebar-only config can't silently no-op under topbar. The sticky page header gains `border-b bg-background/95 backdrop-blur`; the `md:rounded-t-xl` float applies only to `variant: 'inset'`.
|
|
21
|
+
- `StatsOverview` sparklines render as soft area-fills and default to the theme chart palette.
|
|
22
|
+
|
|
23
|
+
- 8e4dc9f: feat(pilotiq): simplify the panel topbar — search right, breadcrumb in the header, theme + notifications in the user menu
|
|
24
|
+
|
|
25
|
+
The sticky header chrome is consolidated:
|
|
26
|
+
|
|
27
|
+
- **Search** moves to the right cluster (sidebar layout); the left now holds just the sidebar toggle.
|
|
28
|
+
- **Breadcrumb** is hoisted into the header next to the toggle (sidebar layout) and removed from the page body. Wired SSR-correctly — the auto-gen `+Layout` extracts the `breadcrumbs` element from `schemaData` and passes it to the header, so it paints on first load and updates on SPA nav. The topbar layout (and any custom header slot) keeps the breadcrumb in the body.
|
|
29
|
+
- **Theme toggle** moves into the user dropdown as a row (stays open on click).
|
|
30
|
+
- **Database notifications** fold into the user dropdown as a "Notifications" submenu carrying the full inbox list; an unread dot shows on the avatar. The standalone `<NotificationBell>` is retained for the `databaseNotificationsPosition('sidebar')` placement.
|
|
31
|
+
|
|
32
|
+
New: `DropdownMenuSub` / `DropdownMenuSubTrigger` / `DropdownMenuSubContent` primitives, a shared `useNotifications()` hook + `NotificationList` component (extracted from `NotificationBell`), an exported `BreadcrumbsView`, and a `breadcrumb` prop on `AppShell` / `UserMenu`'s new optional `notifications` prop. No breaking public API.
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- 184951e: refactor(pilotiq): route dialog / action-group / inline-create buttons through the shadcn `<Button>`
|
|
37
|
+
|
|
38
|
+
The remaining hand-rolled `h-9` buttons that bypassed the shared component now use `<Button>`, so they pick up the shadcn chrome (`h-8`, `rounded-lg`, focus ring, active-press) and stay consistent with the rest of the panel: the confirm/cancel buttons in `ActionModalDialog` and `ConfirmActionDialog`, the confirm dialog inside `ActionGroup`, and the `SelectField` inline-create trigger/cancel/submit. Modal confirm CTAs keep their intentional **solid-red** styling (a className override on the default variant) rather than the soft inline `destructive` variant.
|
|
39
|
+
|
|
40
|
+
- ac7a567: fix(pilotiq): resolve the live panel in the reactive form POST endpoints too — dev edits reflect without a restart
|
|
41
|
+
|
|
42
|
+
Follow-up to the SSR/render-data `livePanel()` fix. The four interactive form builders (`formStateData`, `formWizardData`, `formCreateOptionData`, `mentionResolveData`) still passed their registration-time `Pilotiq` closure straight through, so editing a `live()` field's `options(fn)`, an `afterStateUpdated` hook, a wizard step's validators, an inline-create form, or a mention resolver reflected on the initial SSR render but not on the subsequent partial-resolve / step-validate / create-option / mention roundtrip until a server restart. Each now re-resolves via `livePanel()` at request time, matching the chrome and render-data builders.
|
|
43
|
+
|
|
44
|
+
## 0.24.3
|
|
45
|
+
|
|
46
|
+
### Patch Changes
|
|
47
|
+
|
|
48
|
+
- 02d5793: fix(pilotiq): panel routes resolve the live panel at request time — dev edits reflect without a server restart
|
|
49
|
+
|
|
50
|
+
Panel route handlers closed over the `Pilotiq` instance captured when their routes were registered. In dev, editing the panel module (or a resource/page schema it imports) re-registers a fresh panel in `PilotiqRegistry`, but those already-registered handler closures kept pointing at the stale instance — so SSR-rendered chrome and schema lagged a reload behind (the panel only updated after editing some _other_ watched file, or a restart).
|
|
51
|
+
|
|
52
|
+
The render-data layer now re-resolves the panel from `PilotiqRegistry` by name at request time, via a new `livePanel()` helper, applied at the top of `panelInfo` (chrome) and every render-data builder (`resourcePages`, `misc`, `relationPages`). This mirrors what `dispatchPageData()` already did for the client-nav path; the SSR route path was the only outlier. `livePanel()` falls back to the passed instance when the registry has no entry (tests, teardown), so non-dev behavior is unchanged.
|
|
53
|
+
|
|
54
|
+
- 539c87a: feat(pilotiq): ship `pilotiq-actions` boost skill — Phase B residual #1
|
|
55
|
+
|
|
56
|
+
First of the four still-open Phase B skill candidates. `SKILL.md` declares `appliesTo: ['@pilotiq/pilotiq']` so `@rudderjs/boost`'s `boost:install` writes it under `.ai/skills/` only when the consumer has `@pilotiq/pilotiq` installed. Trigger scopes to specific action-authoring contexts — adding header/row/bulk buttons, wiring a modal-form action, customizing per-row visibility, reaching for a built-in factory.
|
|
57
|
+
|
|
58
|
+
Three rule files under `boost/skills/pilotiq-actions/rules/`:
|
|
59
|
+
|
|
60
|
+
- **`dispatch-modes.md`** — 4 mutually-exclusive modes (`href` / `method` / `handler` / `submit`), modal-form as a flavor of handler, return shape, `ctx` shape, 12 modal chrome setters, `.confirm()` + `.formField()` interactions.
|
|
61
|
+
- **`visibility-and-authorization.md`** — 4 conditional setters (`visible / hidden / disabled / authorize`), `ActionVisibilityContext`, fail-closed semantics (opposite of layout-visible), per-row gating cost model, composing with Resource policies.
|
|
62
|
+
- **`factories.md`** — 25 pre-built factories (`create / edit / view / delete / replicate / restore / forceDelete / markAsRead`, bulk variants, `import / export`, relation\* variants including M2M `attach / detach`), `ReplicateOptions` with `getCreatedNotificationTitle / getRedirectUrl` callbacks, when to skip factories for compound flows.
|
|
63
|
+
|
|
64
|
+
Mirrors the shape established by `pilotiq-resource` / `pilotiq-fields` / `pilotiq-relations` / `pilotiq-tiptap-blocks`.
|
|
65
|
+
|
|
66
|
+
Remaining Phase B residuals (lower priority — `guidelines.md` already covers most of what they'd add): `pilotiq-widgets`, `pilotiq-theme`, `pilotiq-vite-plugin`.
|
|
67
|
+
|
|
3
68
|
## 0.24.2
|
|
4
69
|
|
|
5
70
|
### Patch Changes
|
package/boost/guidelines.md
CHANGED
|
@@ -442,12 +442,17 @@ Client-only reactivity (no server round-trip): `afterStateUpdatedJs(`$set('slug'
|
|
|
442
442
|
Pilotiq.make('Admin')
|
|
443
443
|
.branding({ name: 'Acme', logo: '/logo.svg', primaryColor: '#3b82f6' })
|
|
444
444
|
.theme('nova') // built-in preset
|
|
445
|
-
.layout('sidebar'
|
|
445
|
+
.layout('sidebar', { variant: 'inset', collapsible: 'icon', side: 'left' }) // see below
|
|
446
446
|
.use(themeEditor()) // exposes /admin/theme runtime editor
|
|
447
447
|
```
|
|
448
448
|
|
|
449
449
|
Presets: `default`, `nova`, `maia`, `lyra`. Custom themes pass a `Theme` object to `.theme()`.
|
|
450
450
|
|
|
451
|
+
**`.layout(mode, opts?)`** — `mode` is `'sidebar' | 'topbar'`. The optional second arg configures the sidebar's chrome and is **bound to the `'sidebar'` mode**: `.layout('topbar', {...})` is a compile error (so sidebar-only config can't silently no-op under topbar). Sidebar keys (all optional):
|
|
452
|
+
- `variant`: `'inset'` (default — content floats in a rounded card) · `'floating'` (sidebar itself floats) · `'sidebar'` (classic full-height flush rail)
|
|
453
|
+
- `collapsible`: `'icon'` (default — collapses to an icon rail) · `'offcanvas'` (slides fully off-screen) · `'none'` (always expanded)
|
|
454
|
+
- `side`: `'left'` (default, RTL-aware) · `'right'`
|
|
455
|
+
|
|
451
456
|
Plugin-shaped extensions: `.plugins([tiptap(), codeEditor(), recharts()])` — register adapter packages in one call.
|
|
452
457
|
|
|
453
458
|
## Common Pitfalls
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pilotiq-actions
|
|
3
|
+
description: Buttons, modal-form actions, and built-in CRUD/import/export/relation factories in pilotiq — the 4 dispatch modes, visibility/authorize layers, and per-row gating
|
|
4
|
+
license: MIT
|
|
5
|
+
appliesTo:
|
|
6
|
+
- '@pilotiq/pilotiq'
|
|
7
|
+
trigger: adding header / row / bulk action buttons to a `Resource.table()` / page, wiring a modal-form action, customizing per-row visibility via `visible()` / `authorize()`, or using one of the `Action.*` factories (`create` / `edit` / `delete` / `replicate` / `import` / `export` / `relation*` / `bulk*`)
|
|
8
|
+
skip: customizing the form schema itself (use `pilotiq-fields`) or wiring a relation tab as a whole (use `pilotiq-relations`)
|
|
9
|
+
metadata:
|
|
10
|
+
author: pilotiq
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Pilotiq Actions
|
|
14
|
+
|
|
15
|
+
## When to use this skill
|
|
16
|
+
|
|
17
|
+
Load when you're:
|
|
18
|
+
|
|
19
|
+
- Adding action buttons to a page — header actions, row actions, bulk actions, inline actions
|
|
20
|
+
- Wiring a modal-form action (`Action.make('foo').schema([Field…]).handler(ctx => …)`)
|
|
21
|
+
- Customizing chrome (colors, sizes, icons, tooltips, outlined / icon-only / destructive)
|
|
22
|
+
- Setting visibility / disabled / authorize rules — including per-row gating that re-evaluates on every list row
|
|
23
|
+
- Reaching for a built-in factory: `Action.create / edit / view / delete / replicate / import / export / restore / forceDelete / markAsRead / bulkDelete / bulkRestore / bulkForceDelete / bulkReplicate / bulkExport / relation*`
|
|
24
|
+
|
|
25
|
+
For the form schema *inside* an action's modal, that's `pilotiq-fields`. For the resource-side `Resource.table().recordActions([…])` wiring, this skill covers the action surface itself; `pilotiq-resource` covers when to subclass `ListPage` to override `getHeaderActions()` etc.
|
|
26
|
+
|
|
27
|
+
## Quick Reference
|
|
28
|
+
|
|
29
|
+
| Task | Open |
|
|
30
|
+
|---|---|
|
|
31
|
+
| 4 dispatch modes — `href` / `method` / `handler` / `submit`; modal-form via `.schema([…]).handler()`; `formField` for submit-button pairs | `rules/dispatch-modes.md` |
|
|
32
|
+
| Visibility, disabled, authorize — `ActionVisibilityContext`, per-row gating, fail-closed semantics, async predicates | `rules/visibility-and-authorization.md` |
|
|
33
|
+
| Built-in factories — `Action.create / .edit / .delete / .replicate / .import / .export / .relation*` plus bulk variants; chrome + visibility defaults | `rules/factories.md` |
|
|
34
|
+
|
|
35
|
+
## Key concepts (load once)
|
|
36
|
+
|
|
37
|
+
- **Every action is `Action.make(name).<setters>`.** No subclassing. The `name` is the discriminator for visibility lookups + the dispatch URL slug.
|
|
38
|
+
- **Four dispatch modes are mutually exclusive.** `.href(url)` = link. `.method('POST').action(url)` = form-post. `.handler(ctx => …)` = JSON dispatch via the framework's `_action/:name` route. `.submit()` = trigger the enclosing form's submit.
|
|
39
|
+
- **Modal-form is a flavor of handler.** Calling `.schema([Field, …])` flips an action into modal-form mode: clicking the trigger opens a Dialog with the schema as a real pilotiq form; submit fetches the action's dispatch URL with the form body.
|
|
40
|
+
- **Placement determines where the button mounts.** `inline` (default, in-page) / `bulk` (toolbar when rows selected) / `row` (per-row) / `header` (page header). Placement is implicit from how you pass the action — `Resource.table().recordActions([…])` are row, `.bulkActions([…])` are bulk, etc.
|
|
41
|
+
- **Visibility is fail-closed.** `.visible(rule)` / `.hidden(rule)` / `.disabled(rule)` / `.authorize(rule)` accept `boolean | (ctx) => boolean | Promise<boolean>`. Throwing → visibility false. `ActionVisibilityContext` carries `{ record?, records?, user? }` depending on placement.
|
|
42
|
+
- **Row-placement actions evaluate per row.** The framework calls each row's predicates in `loadTableRecords` and stamps `row._visibleActions: name[]` / `row._disabledActions: name[]`. The renderer filters its action strip against that stamp.
|
|
43
|
+
- **All non-modal handler dispatches SPA-update.** Fetch with `Accept: application/json`, drain notifications via `useToast()`, then `useNavigate(redirect)`. No page reload. Only form-post `method` actions (e.g. `Action.delete`) still use the 303-redirect path for back-compat.
|
|
44
|
+
|
|
45
|
+
## Examples
|
|
46
|
+
|
|
47
|
+
- `playground/app/Pilotiq/Articles/ArticleResource.ts` — header/row actions with built-in factories.
|
|
48
|
+
- `playground/app/Pilotiq/Posts/PostResource.ts` — modal-form action (`Action.make('publish').schema([…]).handler(…)`).
|
|
49
|
+
- `playground/app/Pilotiq/Users/UserResource.ts` — `Action.replicate` with `beforeReplicaSaved` mutator.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Dispatch Modes
|
|
2
|
+
|
|
3
|
+
Every `Action` has exactly one of four dispatch modes. They're mutually exclusive — calling a setter from a different mode replaces the prior choice. Pick the mode by what the action does, not where it appears.
|
|
4
|
+
|
|
5
|
+
## 1. `href(url)` — link
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
Action.make('docs')
|
|
9
|
+
.label('Documentation')
|
|
10
|
+
.icon('book')
|
|
11
|
+
.href('https://docs.example.com')
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Renders as `<a href>`. No server round-trip. Cmd+click / right-click "open in new tab" works because it's a real anchor.
|
|
15
|
+
|
|
16
|
+
`.openInNewTab()` adds `target="_blank" rel="noopener"`. `.tooltip(text)` adds a hover hint.
|
|
17
|
+
|
|
18
|
+
Use for: external doc links, marketing CTAs, sibling-app deep links.
|
|
19
|
+
|
|
20
|
+
## 2. `method(verb).action(url)` — form-post
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
Action.make('archive')
|
|
24
|
+
.label('Archive')
|
|
25
|
+
.color('warning')
|
|
26
|
+
.method('POST')
|
|
27
|
+
.action(`${base}/articles/${row.id}/archive`)
|
|
28
|
+
.confirm('Archive this article? You can restore it later.')
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Renders as a hidden `<form>` with a submit button. The form body is empty (or carries `Action.formField(name, value)` pairs — see below). Server responds 303 → browser follows → page re-renders.
|
|
32
|
+
|
|
33
|
+
This is the **only mode that survives no-JavaScript clients** — useful for back-compat with progressively-enhanced flows or for actions that must remain accessible from raw HTML email links. The framework's `Action.delete` factory ships in this mode for that reason.
|
|
34
|
+
|
|
35
|
+
`.confirm(message)` wraps the submit in a confirmation Dialog. The submit doesn't fire until the user OKs.
|
|
36
|
+
|
|
37
|
+
## 3. `handler(ctx => …)` — JSON dispatch
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
Action.make('publish')
|
|
41
|
+
.label('Publish')
|
|
42
|
+
.color('primary')
|
|
43
|
+
.icon('send')
|
|
44
|
+
.handler(async (ctx) => {
|
|
45
|
+
const article = await ArticleModel.find(ctx.record.id)
|
|
46
|
+
article.publishedAt = new Date()
|
|
47
|
+
await article.save()
|
|
48
|
+
return {
|
|
49
|
+
notify: { title: 'Published', body: article.title, kind: 'success' },
|
|
50
|
+
redirect: `${ctx.basePath}/articles/${article.id}/edit`,
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Clicking POSTs `Accept: application/json` to `{basePath}/{slug}/_action/{name}`. The framework routes through `dispatchAction(action, body, ctx)`, calls the handler, normalizes the return shape, ships it back.
|
|
56
|
+
|
|
57
|
+
**Return shape** (all keys optional):
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
{
|
|
61
|
+
notify?: NotificationLike | NotificationLike[],
|
|
62
|
+
redirect?: string, // URL to navigate to after success
|
|
63
|
+
download?: { filename, contentType, body }, // triggers <a download> on the client
|
|
64
|
+
ok?: boolean, // false short-circuits to error toast
|
|
65
|
+
error?: string, // surfaced as toast when ok:false
|
|
66
|
+
// ...any extras get round-tripped via additional ActionResult slots
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Client-side: drains notifications via `useToast()`, then `useNavigate(redirect)`. **No page reload** — SPA-only flow. If the response carries a `download` payload, the framework synthesizes a temporary `<a download>` blob and clicks it.
|
|
71
|
+
|
|
72
|
+
**`ctx` shape:**
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
{
|
|
76
|
+
user: OpaqueUser | null, // from Pilotiq.user() resolver
|
|
77
|
+
record?: Record, // row placement — current row
|
|
78
|
+
records?: Record[], // bulk placement — selected rows
|
|
79
|
+
basePath: string, // panel base ("/admin")
|
|
80
|
+
resource?: ResourceLike, // when in a resource scope
|
|
81
|
+
relation?: { parent, parentId, relationship }, // when inside a relation manager
|
|
82
|
+
body?: unknown, // raw POST body (FormData-parsed) — modal-form values land here
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Use for: anything that needs server work but is conceptually a one-shot operation, not a page navigation.
|
|
87
|
+
|
|
88
|
+
## 4. `submit()` — trigger an enclosing form
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// inside CreatePage.getFormActions(R)
|
|
92
|
+
Action.make('createAnother')
|
|
93
|
+
.label('Create & create another')
|
|
94
|
+
.outlined()
|
|
95
|
+
.submit()
|
|
96
|
+
.formField('_continueCreate', '1') // rides the form body so the handler can branch
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Renders as `<button type="submit">`. No dispatch URL — it submits the enclosing `<form>`. Used in page headers / form footers where the form already wires its own POST.
|
|
100
|
+
|
|
101
|
+
`.form(formId)` targets a form outside the natural enclosing `<form>` (HTML `form=` attribute). Useful when the submit button lives in the page header but the form lives further down the tree.
|
|
102
|
+
|
|
103
|
+
`.formField(name, value='1')` attaches a hidden `name`/`value` pair to the form body — the click sets `event.submitter` so `new FormData(form, submitter)` picks it up. Confirm-gated submits intentionally **don't** honor `formField` (programmatic `requestSubmit()` has no submitter).
|
|
104
|
+
|
|
105
|
+
## Modal-form actions (flavor of handler)
|
|
106
|
+
|
|
107
|
+
Add `.schema([Field, …])` to a handler action and it switches to modal-form mode:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
Action.make('addNote')
|
|
111
|
+
.label('Add note')
|
|
112
|
+
.icon('plus')
|
|
113
|
+
.schema([
|
|
114
|
+
TextField.make('subject').required(),
|
|
115
|
+
TextareaField.make('body').required(),
|
|
116
|
+
SelectField.make('priority').options({ low: 'Low', med: 'Med', high: 'High' }).default('med'),
|
|
117
|
+
])
|
|
118
|
+
.handler(async (ctx) => {
|
|
119
|
+
// ctx.body has the parsed + validated form values
|
|
120
|
+
await Note.create({ ...ctx.body, articleId: ctx.record.id })
|
|
121
|
+
return { notify: { title: 'Note added', kind: 'success' } }
|
|
122
|
+
})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Clicking the trigger opens a Dialog with the schema mounted as a real pilotiq form. Submit fetches `Accept: application/json`. Server responses:
|
|
126
|
+
|
|
127
|
+
- **200 `{ ok: true, redirect, notifications }`** — drain notifications, SPA-navigate.
|
|
128
|
+
- **422 `{ ok: false, errors: { field: [msg, …] } }`** — stamp inline field errors.
|
|
129
|
+
- **5xx `{ ok: false, error }`** — error toast.
|
|
130
|
+
|
|
131
|
+
The form runs every `pilotiq-fields`-style validator (required / email / unique / distinct) before reaching the handler. Field-level `live()` works inside the modal.
|
|
132
|
+
|
|
133
|
+
### Modal chrome
|
|
134
|
+
|
|
135
|
+
12 setters customize the modal:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
Action.make('addNote')
|
|
139
|
+
.modalHeading('Add a note')
|
|
140
|
+
.modalDescription('Notes are visible to all editors.')
|
|
141
|
+
.modalIcon('sticky-note')
|
|
142
|
+
.modalIconColor('warning')
|
|
143
|
+
.modalWidth('lg') // 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | 'screen'
|
|
144
|
+
.modalSubmitActionLabel('Save note')
|
|
145
|
+
.modalCancelActionLabel('Discard')
|
|
146
|
+
.modalCloseable(false) // user can't close via Esc / outside-click
|
|
147
|
+
.modalContentFooter([ // Element[] rendered below the form body
|
|
148
|
+
Text.make('Notes auto-archive after 90 days.').size('xs').color('muted'),
|
|
149
|
+
])
|
|
150
|
+
// …
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Setters with no special chrome you want, just omit — sensible defaults ship.
|
|
154
|
+
|
|
155
|
+
## Confirm
|
|
156
|
+
|
|
157
|
+
Both handler and method modes accept `.confirm(message)`:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
Action.make('publish')
|
|
161
|
+
.handler(/* … */)
|
|
162
|
+
.confirm('Publish this article? It will be visible to everyone.')
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`.modalConfirmIcon` / `.modalConfirmIconColor` customize the chrome. Confirm dialogs share the same `<ActionModalDialog>` primitive as modal-form — the difference is content: confirm shows a message + Cancel/OK; modal-form mounts a `<FormFields>`.
|
|
166
|
+
|
|
167
|
+
## Pitfalls
|
|
168
|
+
|
|
169
|
+
- **`.handler()` on a method action.** Calling `.method('POST').action(...)` then `.handler(...)` will override the method, NOT compose — handler wins. Pick one mode.
|
|
170
|
+
- **`.submit().formField(...)` with `.confirm(...)`.** Confirm-gated submits use `form.requestSubmit()` programmatically, which doesn't carry a `submitter`. The `formField` pair will silently drop. If you need both, use `.handler()` + `.confirm()` instead.
|
|
171
|
+
- **Modal-form fields inside the modal can't access the outer page's form state.** The modal is a fresh form scope; `$get('outerFieldName')` won't see the page's form. If you need cross-form data, pass it via `ctx.record` instead.
|
|
172
|
+
- **Handler returns are async-aware but not stream-aware.** Don't expect to ship progressive output from a long-running handler. For batch operations that take >5s, return immediately with `{ notify: 'Queued', redirect }` and process out-of-band.
|
|
173
|
+
|
|
174
|
+
## See also
|
|
175
|
+
|
|
176
|
+
- `visibility-and-authorization.md` — gating which dispatch modes fire for which user / record.
|
|
177
|
+
- `factories.md` — pre-built factories you usually want instead of writing dispatch from scratch.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Built-in Action Factories
|
|
2
|
+
|
|
3
|
+
Pilotiq ships ~25 pre-built `Action.*` factories for the operations every admin panel needs. They handle dispatch wiring, visibility gating, chrome, and notification copy — most of the time you reach for one of these instead of `Action.make(...)`.
|
|
4
|
+
|
|
5
|
+
## CRUD basics (single row)
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { Action } from '@pilotiq/pilotiq'
|
|
9
|
+
|
|
10
|
+
Action.create(R, base) // header — "Create <Label>"; href = `${base}/${slug}/create`
|
|
11
|
+
Action.edit(R, base, recordId) // row — pencil icon; href = `${base}/${slug}/:id/edit`
|
|
12
|
+
Action.view(R, base, recordId) // row — eye icon; href = `${base}/${slug}/:id`
|
|
13
|
+
Action.delete(R, base, recordId) // row — trash icon; method POST → `${base}/${slug}/:id/delete` with .confirm()
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Each delegates visibility to the matching Resource policy (`canCreate` / `canEdit` / `canView` / `canDelete`) automatically — see `visibility-and-authorization.md` § Composing with Resource policies.
|
|
17
|
+
|
|
18
|
+
## Replicate
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
Action.replicate(R, base, recordId, {
|
|
22
|
+
excludeAttributes: ['name', 'slug'], // strip these in addition to PK + soft-delete column
|
|
23
|
+
beforeReplicaSaved: async (replica, source) => {
|
|
24
|
+
replica.name = `Copy of ${source.name}`
|
|
25
|
+
replica.slug = await generateSlug(replica.name)
|
|
26
|
+
},
|
|
27
|
+
getCreatedNotificationTitle: ({ replica, source }) => `Cloned "${source.title}"`,
|
|
28
|
+
getRedirectUrl: ({ replica }) => `${base}/articles/${replica.id}/edit`,
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Clones the source row via `R.model.create(...)`. Strips PK + soft-delete column + `opts.excludeAttributes`, runs `beforeReplicaSaved` to mutate, persists, redirects to the new record's edit page. Visibility delegates to `R.canCreate`.
|
|
33
|
+
|
|
34
|
+
`Action.bulkReplicate(R, base, opts?)` is the bulk variant — iterates `ctx.records`, applies the same strip-mutate-create pipeline per row, skips rows that throw or fail per-row `canCreate`, notifies with the count.
|
|
35
|
+
|
|
36
|
+
`opts.getCreatedNotificationTitle` and `opts.getRedirectUrl` are both sync-or-async; receive `{ replica, source }` (single) or `{ count, records }` (bulk). Returning `undefined` falls back to the default copy. Empty string is honored — won't be swallowed by `??`.
|
|
37
|
+
|
|
38
|
+
## Soft delete (when `Resource.softDeletes = true`)
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
Action.restore(R, base, recordId) // row — only visible on trashed rows
|
|
42
|
+
Action.forceDelete(R, base, recordId) // row — only visible on trashed rows; method POST with .confirm()
|
|
43
|
+
|
|
44
|
+
Action.bulkRestore(R, base) // bulk — restores selected trashed rows
|
|
45
|
+
Action.bulkForceDelete(R, base) // bulk
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`Action.delete` auto-hides on already-trashed rows (per Plan #13 soft-delete wiring); restore / forceDelete auto-hide on non-trashed rows. The toggling is structural, not optional — opt out by overriding `.visible()` after the factory.
|
|
49
|
+
|
|
50
|
+
## Import / Export
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
Action.export(R, base, {
|
|
54
|
+
format: 'csv', // 'csv' | 'json'
|
|
55
|
+
columns: ['id', 'title', 'publishedAt'], // omit → all columns from R.table()
|
|
56
|
+
filename: () => `articles-${new Date().toISOString().slice(0, 10)}.csv`,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
Action.bulkExport(R, base, opts?) // bulk — exports only selected rows
|
|
60
|
+
|
|
61
|
+
Action.import(R, base, {
|
|
62
|
+
format: 'csv', // omit → auto-detect from filename
|
|
63
|
+
upsertBy: ['email'], // turns it into an upsert action; mode-select appears in the modal
|
|
64
|
+
maxRows: 10_000, // default cap
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`Action.export` reads from the resource's `Table.records(ctx)` (so filters / search / sort flow through); writes the body to a `download` envelope that the client synthesizes as `<a download>`. CSV via the in-tree `src/io/csv.ts` (RFC 4180, in-memory only).
|
|
69
|
+
|
|
70
|
+
`Action.import` auto-builds a modal-form schema with a `FileUpload` field (and a `Mode` select when `upsertBy` is set). Handler reads `ctx.values.file`, fetches the URL the upload stamped, parses CSV/JSON, walks rows through `R.model.create` (or `R.model.update` for matched upserts). Per-row `validate / beforeCreate / beforeUpdate` lifecycle hooks fire from the resource's form config. Partial-failure-soft: rows that throw / fail validate accumulate in `summary.errors` and the import keeps going. v1 has **no transaction wrapper** — partial imports leave the DB in a partially-applied state.
|
|
71
|
+
|
|
72
|
+
## Relation factories (inside RelationManagers)
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// inside a RelationManager static table(table, ctx)
|
|
76
|
+
Action.relationCreate(M, ctx) // header — "Create <Label>"; opens create form in tab
|
|
77
|
+
Action.relationEdit(M, ctx, recordId) // row — pencil
|
|
78
|
+
Action.relationDelete(M, ctx, recordId) // row — trash
|
|
79
|
+
Action.relationRestore(M, ctx, recordId) // row — soft-delete trash only
|
|
80
|
+
Action.relationForceDelete(M, ctx, recordId) // row — soft-delete trash only
|
|
81
|
+
|
|
82
|
+
Action.relationReplicate(M, ctx, recordId, opts?)
|
|
83
|
+
Action.relationBulkReplicate(M, ctx, opts?)
|
|
84
|
+
|
|
85
|
+
// M2M only (auto-hide outside `belongsToMany / morphToMany / morphedByMany`)
|
|
86
|
+
Action.relationAttach(M, ctx) // header — modal-form picker
|
|
87
|
+
Action.relationDetach(M, ctx, recordId) // row
|
|
88
|
+
Action.relationBulkDetach(M, ctx) // bulk
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`ctx` is the `RelationManagerContext` injected into `M.table(table, ctx)` — it carries `basePath / parentSlug / parentId / relationship / parentRecord / related? / mode`. The factories thread URLs + parent attachment automatically.
|
|
92
|
+
|
|
93
|
+
`relationReplicate` force-pins the parent FK back onto the replica AFTER strip + BEFORE `beforeReplicaSaved`, so a tampered source row can't slip a different parent in by riding its own FK column. Auto-hides on M2M (replicate doesn't fit pivot semantics) and on `morphTo` (no single owner to pin to).
|
|
94
|
+
|
|
95
|
+
For the broader RelationManager surface (when to override `canDelete` vs `canDetach`, how `ctx.mode` derives), see [[pilotiq-relations]].
|
|
96
|
+
|
|
97
|
+
## Common chrome customizations
|
|
98
|
+
|
|
99
|
+
The factory result is just an `Action` instance — every chain method composes after the factory call:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
Action.delete(R, base, row.id)
|
|
103
|
+
.label('Move to trash') // override default copy
|
|
104
|
+
.color('warning') // override 'destructive'
|
|
105
|
+
.tooltip('Trashed items auto-purge after 30 days')
|
|
106
|
+
.confirm('Move this article to trash?') // override default confirm copy
|
|
107
|
+
.visible(({ user }) => user?.role === 'admin')
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The `.visible()` you set wins over the factory's auto-attached policy gate.
|
|
111
|
+
|
|
112
|
+
## When NOT to use a factory
|
|
113
|
+
|
|
114
|
+
- **Custom modal-form** — the import factory's modal schema is fixed (FileUpload + maybe Mode). For richer modals (multi-field policy decisions, conditional reactivity), use `Action.make('foo').schema([…]).handler(…)` directly.
|
|
115
|
+
- **Cross-record orchestration** — bulk factories iterate row-by-row. For "select 50 rows, run one SQL UPDATE" semantics, write a handler that consumes `ctx.records` and dispatches a single ORM call.
|
|
116
|
+
- **Compound flows** — anything that needs multi-step UX (confirm → modal-form → second confirm) doesn't compose from factories. Build it from `Action.make()` primitives.
|
|
117
|
+
|
|
118
|
+
## Pitfalls
|
|
119
|
+
|
|
120
|
+
- **`opts.beforeReplicaSaved` mutates a plain object, not a model instance.** It's `Record<string, unknown>`, pre-create. Don't expect `replica.save()` / lifecycle hooks — those are framework-internal post-mutation.
|
|
121
|
+
- **Bulk factories don't wrap in a transaction.** Partial failures leave the DB partially updated. The notification shows the count succeeded; the rest accumulate in `summary.errors`. If you need atomicity, write a handler that opens a transaction explicitly via your ORM.
|
|
122
|
+
- **`Action.import`'s `upsertBy` requires `R.model.update` to exist.** Without it, the import will throw on the first matching row. Boot-time guard catches the missing method.
|
|
123
|
+
- **Relation factories' `ctx.mode` lies about `morphOne` / `hasOne`.** Both collapse into `'hasMany'` for action dispatch — `morphOne / hasOne` semantically still allow one child, but the factory shape is the same.
|
|
124
|
+
|
|
125
|
+
## See also
|
|
126
|
+
|
|
127
|
+
- `dispatch-modes.md` — what each factory does under the hood.
|
|
128
|
+
- `visibility-and-authorization.md` — how factory auto-visibility composes with manual `.visible()`.
|
|
129
|
+
- [[pilotiq-relations]] — broader RelationManager + `Repeater.relationship` patterns.
|
|
130
|
+
- [[pilotiq-resource]] — the `Resource.softDeletes` + `can*` static surface that factories key off.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Visibility & Authorization
|
|
2
|
+
|
|
3
|
+
Every action exposes four conditional setters. They differ in *intent*; all use the same predicate shape.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
type VisibilityRule =
|
|
7
|
+
| boolean
|
|
8
|
+
| ((ctx: ActionVisibilityContext) => boolean | Promise<boolean>)
|
|
9
|
+
|
|
10
|
+
type ActionVisibilityContext = {
|
|
11
|
+
record?: Record // present on row-placement
|
|
12
|
+
records?: Record[] // present on bulk-placement
|
|
13
|
+
user?: OpaqueUser | null // from Pilotiq.user()
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## The four setters
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
Action.make('publish')
|
|
21
|
+
.visible(({ record, user }) => !record.publishedAt && user?.role === 'editor')
|
|
22
|
+
.hidden(({ record }) => record.archived)
|
|
23
|
+
.disabled(({ record }) => record.locked)
|
|
24
|
+
.authorize(async ({ user, record }) => await canPublish(user, record))
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- **`.visible(rule)`** — mounts the button only when rule resolves truthy. Default: visible.
|
|
28
|
+
- **`.hidden(rule)`** — sugar inverse of `visible`. Last write wins; if you set both, the more recent setter is the active one.
|
|
29
|
+
- **`.disabled(rule)`** — renders the button but as a non-interactive (greyed) chip. The user sees it exists but can't fire it.
|
|
30
|
+
- **`.authorize(rule)`** — semantically equivalent to `.visible()` but reads as a permission gate at call sites. Use when the predicate is policy-shaped (`canEdit`, `canDelete`).
|
|
31
|
+
|
|
32
|
+
`visible` + `authorize` both gate *presence*. Composing them is fine; both must resolve truthy. Default is `true` for both, so omitting them leaves the action always-visible.
|
|
33
|
+
|
|
34
|
+
## Fail-closed semantics
|
|
35
|
+
|
|
36
|
+
Predicates that throw or reject → button hides (or for `.disabled`, treats as disabled-true). This is the **opposite** of the layout `visible()` posture in `pilotiq-fields`, which fails *open* for in-progress data safety. Actions are operations, not data — silently hiding a bad button is safer than rendering one with broken logic.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
Action.make('publish')
|
|
40
|
+
.visible(async ({ record }) => {
|
|
41
|
+
if (!record) throw new Error('record missing') // silently hides; no toast, no log
|
|
42
|
+
return record.status === 'draft'
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If you want errors loud, log inside the predicate yourself. Don't expect a framework-level toast.
|
|
47
|
+
|
|
48
|
+
## Per-row gating on tables
|
|
49
|
+
|
|
50
|
+
Row-placement actions evaluate predicates **per row** during `loadTableRecords`. The framework:
|
|
51
|
+
|
|
52
|
+
1. Walks the registered row actions (from `Resource.table().recordActions([…])` or page-override `getRowActions()`).
|
|
53
|
+
2. Filters to actions with at least one conditional rule (`visible` / `hidden` / `disabled` / `authorize`).
|
|
54
|
+
3. Calls each rule in parallel via `Promise.all` for each row.
|
|
55
|
+
4. Stamps the row with `_visibleActions: name[]` (names that resolved truthy on visible/authorize) and `_disabledActions: name[]` (names that resolved truthy on disabled).
|
|
56
|
+
5. The renderer's `renderRowActions` filters its strip against `_visibleActions` and applies disabled styling per `_disabledActions`.
|
|
57
|
+
|
|
58
|
+
**Performance** — every conditional rule × every row × every page load. Heavy predicates (DB queries, network calls) inside `visible()` will dominate list-page latency. Prefer reading from the row record itself when possible:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// Cheap — reads from the record server-side stamped fields
|
|
62
|
+
.visible(({ record }) => record.status === 'draft')
|
|
63
|
+
|
|
64
|
+
// Expensive — DB query per row
|
|
65
|
+
.visible(async ({ record }) => await UserPolicy.canEdit(user, record))
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If you need expensive checks, stamp the result on the row via `Column.formatStateUsing` upstream and read from `record._formatted[col]` instead.
|
|
69
|
+
|
|
70
|
+
## Bulk-placement gating
|
|
71
|
+
|
|
72
|
+
Bulk-action predicates receive `records: Record[]` instead of `record`. They evaluate ONCE per page render (against the rendered row set), not per row:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
Action.make('bulkDelete')
|
|
76
|
+
.label('Delete selected')
|
|
77
|
+
.color('destructive')
|
|
78
|
+
.handler(async ({ records, user }) => {
|
|
79
|
+
for (const r of records) {
|
|
80
|
+
if (!await canDelete(user, r)) continue
|
|
81
|
+
await Article.delete(r.id)
|
|
82
|
+
}
|
|
83
|
+
return { notify: { title: `Deleted ${records.length}`, kind: 'success' } }
|
|
84
|
+
})
|
|
85
|
+
.visible(({ user }) => user?.role === 'editor') // gates the bulk button
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
For *per-row* exclusion inside a bulk handler, the convention is: iterate `records` in the handler, run the per-row predicate yourself, skip / accumulate errors. The framework doesn't pre-filter `records` against row-side predicates — the bulk button either shows or doesn't, and the handler owns row-level safety.
|
|
89
|
+
|
|
90
|
+
## Composing with Resource policies
|
|
91
|
+
|
|
92
|
+
Resource statics (`canView` / `canEdit` / `canDelete` / `canCreate` etc.) return `boolean | Promise<boolean>` against `(user, record?)`. Built-in factories (`Action.delete`, `Action.edit`, `Action.replicate`) auto-attach a `.visible()` rule that consults the matching policy:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// Resource
|
|
96
|
+
class ArticleResource extends Resource {
|
|
97
|
+
static async canDelete(user, record) {
|
|
98
|
+
return user?.role === 'editor' && !record.locked
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// page-override row actions
|
|
103
|
+
Action.delete(ArticleResource, base, row.id)
|
|
104
|
+
// equivalent to: Action.make('delete').method('POST').action(...).visible(({ record, user }) => ArticleResource.canDelete(user, record))
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Override the auto-attach by setting `.visible(...)` explicitly — your setter wins.
|
|
108
|
+
|
|
109
|
+
## Async + parallel
|
|
110
|
+
|
|
111
|
+
All four predicates may be `async`. Per-row eval runs every row's predicates in parallel via `Promise.all`. Don't fan-out further inside a predicate — each row already pays one round-trip if you go async. Batch reads upstream if you need them.
|
|
112
|
+
|
|
113
|
+
## Pitfalls
|
|
114
|
+
|
|
115
|
+
- **`hidden(true)` does NOT permanently hide.** It's just the inverse of `visible(true)`. To permanently disable an action conditionally on config rather than data, hide it at the schema-builder level (don't include it in `recordActions([…])` at all).
|
|
116
|
+
- **`disabled(true)` still POSTS if you mount it elsewhere.** The disabled flag is purely chrome. The action's dispatch URL is still live server-side — if a user crafts the POST manually, the handler still fires. Always gate inside the handler too for security.
|
|
117
|
+
- **Predicates can see `record` as `undefined`** when an action is placed in a context that doesn't have a record (e.g. inline placement on a non-row page). Guard with `if (!record) return false` or use `?.` chaining.
|
|
118
|
+
- **`user` may be `null`** when no user resolver runs or the resolver returns null. Predicates that need a user must guard.
|
|
119
|
+
- **Don't read mutable state in predicates** (counter increments, request-scoped caches). They run multiple times per render in parallel — the same predicate may fire 5× concurrently for 5 rows.
|
|
120
|
+
|
|
121
|
+
## See also
|
|
122
|
+
|
|
123
|
+
- `dispatch-modes.md` — what the button does when allowed to fire.
|
|
124
|
+
- `factories.md` — built-in factories auto-attach visibility rules; this rule covers the manual setter form.
|
|
125
|
+
- `pilotiq-resource/rules/authorization.md` — Resource-level `can*` policies that feed factory auto-visibility.
|
package/dist/Pilotiq.d.ts
CHANGED
|
@@ -15,6 +15,23 @@ import type { NotificationActionHandler } from './notifications/types.js';
|
|
|
15
15
|
import { type RightPanelContribution } from './RightPanel.js';
|
|
16
16
|
import type { NavComponentProps, HeaderComponentProps, FooterComponentProps } from './react/component-slots.js';
|
|
17
17
|
export type PilotiqLayout = 'sidebar' | 'topbar';
|
|
18
|
+
/**
|
|
19
|
+
* Sidebar chrome options for the `'sidebar'` layout. Maps 1:1 onto the
|
|
20
|
+
* underlying shadcn `<Sidebar>` primitive; ignored under the `'topbar'`
|
|
21
|
+
* layout. All keys are optional — unset falls back to the panel default
|
|
22
|
+
* (`inset` / `icon` / `left`).
|
|
23
|
+
*/
|
|
24
|
+
export interface PilotiqSidebarOptions {
|
|
25
|
+
/** Surface treatment. `inset` floats the content in a rounded card
|
|
26
|
+
* (the default); `floating` floats the sidebar itself; `sidebar` is
|
|
27
|
+
* the classic full-height flush rail. */
|
|
28
|
+
variant?: 'sidebar' | 'floating' | 'inset';
|
|
29
|
+
/** Collapse behavior. `icon` collapses to an icon rail (default);
|
|
30
|
+
* `offcanvas` slides fully off-screen; `none` disables collapsing. */
|
|
31
|
+
collapsible?: 'offcanvas' | 'icon' | 'none';
|
|
32
|
+
/** Which edge the rail docks to. `left` (default) is RTL-aware. */
|
|
33
|
+
side?: 'left' | 'right';
|
|
34
|
+
}
|
|
18
35
|
/** Plugin interface for extending Pilotiq panels. */
|
|
19
36
|
export interface PilotiqPlugin {
|
|
20
37
|
name: string;
|
|
@@ -181,6 +198,9 @@ export interface PilotiqConfig {
|
|
|
181
198
|
name: string;
|
|
182
199
|
path: string;
|
|
183
200
|
layout: PilotiqLayout;
|
|
201
|
+
/** Sidebar chrome options — honored only under the `'sidebar'` layout.
|
|
202
|
+
* Absent → primitive defaults (`inset` / `icon` / `left`). */
|
|
203
|
+
sidebar?: PilotiqSidebarOptions;
|
|
184
204
|
resources: ResourceClass[];
|
|
185
205
|
globals: GlobalClass[];
|
|
186
206
|
pages: (typeof Page)[];
|
|
@@ -439,6 +459,17 @@ export declare class Pilotiq {
|
|
|
439
459
|
* panel.profile(ProfilePage)
|
|
440
460
|
*/
|
|
441
461
|
profile(P: typeof Page): this;
|
|
462
|
+
/**
|
|
463
|
+
* Pick the panel layout, and (for `'sidebar'`) its chrome options.
|
|
464
|
+
* The options are bound to the layout that uses them: passing a second
|
|
465
|
+
* argument with `'topbar'` is a compile error, so sidebar-only config
|
|
466
|
+
* can't silently no-op under a topbar panel.
|
|
467
|
+
*
|
|
468
|
+
* @example
|
|
469
|
+
* Pilotiq.make('Admin').layout('sidebar', { variant: 'floating', side: 'right' })
|
|
470
|
+
* Pilotiq.make('Admin').layout('topbar')
|
|
471
|
+
*/
|
|
472
|
+
layout(l: 'sidebar', opts?: PilotiqSidebarOptions): this;
|
|
442
473
|
layout(l: PilotiqLayout): this;
|
|
443
474
|
theme(config: ThemeConfig): this;
|
|
444
475
|
guard(fn: (req: unknown) => boolean | Promise<boolean>): this;
|