diff --git a/.changeset/bulk-delete-server-action.md b/.changeset/bulk-delete-server-action.md deleted file mode 100644 index b4986432..00000000 --- a/.changeset/bulk-delete-server-action.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@opensaas/stack-core': minor ---- - -Add a `bulkDelete` server action for list-level bulk deletion - -`context.serverAction` now accepts `{ listKey, action: 'bulkDelete', ids }`. It -deletes each id row-by-row through the secured context, honouring Silent failure -(a denied or missing row returns `null` and is not counted; one row's error does -not abort the rest), and returns `{ deleted, total }`. - -The result is deliberately a count shape rather than the single-op `{ success }` -shape, so a UI `serverAction` wrapper that redirects on a single-item success -(the item-form pattern) does not hijack a list-level bulk operation. - -```ts -const result = await context.serverAction({ - listKey: 'Post', - action: 'bulkDelete', - ids: ['a', 'b', 'c'], -}) -// result: { deleted: 2, total: 3 } // one row was denied/missing -``` diff --git a/.changeset/chrome-polish-nav-counts-avatars.md b/.changeset/chrome-polish-nav-counts-avatars.md deleted file mode 100644 index 6149aa75..00000000 --- a/.changeset/chrome-polish-nav-counts-avatars.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@opensaas/stack-core': minor -'@opensaas/stack-ui': minor ---- - -Admin chrome polish: opt-in nav counts and avatar label cells (#735) - -Two per-list opt-ins for the admin UI, both off by default. - -**Nav counts** — set `ui.navCount: true` on a list to show an access-scoped -record count next to its nav item. The count is fetched through the secured -context, so it only ever reflects what the current session may see; no count -query runs for lists that don't opt in, and a list whose query access is -statically denied renders no count rather than a misleading zero. - -```typescript -lists: { - Post: list({ - fields: { - /* ... */ - }, - ui: { navCount: true }, - }), -} -``` - -**Avatar label cells** — set `ui.avatar: true` to render a list's label column -with a deterministic initials bubble ahead of the emphasized Item label. The -initials and colour derive from the row; the palette is Theme-token-derived (no -raw hex). A per-field cell override (`ui.cell`) on the label field still wins. - -```typescript -lists: { - User: list({ - fields: { - /* ... */ - }, - ui: { avatar: true }, - }), -} -``` - -New exports: - -- `@opensaas/stack-core`: `resolveNavCounts`, `isListQueryStaticallyDenied` -- `@opensaas/stack-ui`: `Avatar` primitive, `AvatarLabelCell`, and the - `getInitials`, `getAvatarTone`, `AVATAR_TONES` helpers. New Slots: - `avatar`, `cell-avatar-label`, `nav-count`. diff --git a/.changeset/custom-bulk-actions.md b/.changeset/custom-bulk-actions.md deleted file mode 100644 index aecd7ec5..00000000 --- a/.changeset/custom-bulk-actions.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@opensaas/stack-core': minor -'@opensaas/stack-ui': minor ---- - -Add custom Bulk actions from list config (admin list view) - -A list can now declare list-specific Bulk actions under `ui.listView.bulkActions`. Each action's button renders in the list view's selection bar (in declaration order) alongside the built-in Delete. The action's server-side `handler` receives the selected ids and the secured context, so all its work runs through access control and hooks — a denied row is a Silent failure absorbed into the outcome, never leaked. - -```typescript -Post: list({ - fields: { title: text(), status: select({ options: [/* ... */] }) }, - ui: { - listView: { - bulkActions: [ - { - key: 'publish', - label: 'Publish', - // Optional: `variant`, `destructive` (confirm first), - // `hasAccess` (server-side visibility gate). - handler: async ({ ids, context }) => { - let n = 0 - for (const id of ids) { - const updated = await context.db.post.update({ - where: { id }, - data: { status: 'published' }, - }) - if (updated) n++ - } - return { message: `Published ${n} of ${ids.length}` } - }, - }, - ], - }, - }, -}) -``` - -Only serialisable metadata (`key`/`label`/`variant`/`destructive`) crosses to the client; the `handler`/`hasAccess` functions stay on the server. Clicking the button sends the `key` and selected ids back through the generic server action, which looks the handler up and runs it with a freshly-rebuilt secured context. Selection is enabled for a list that has custom actions even when Delete is denied. CSV export is documented as a recipe using this surface rather than shipping as a built-in. diff --git a/.changeset/filter-builder-input-ui.md b/.changeset/filter-builder-input-ui.md deleted file mode 100644 index dc954706..00000000 --- a/.changeset/filter-builder-input-ui.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@opensaas/stack-core': minor -'@opensaas/stack-ui': minor ---- - -Add the Filter builder input UI for the admin list view (#731) - -The admin list view now ships a `FilterBuilder` that constructs the `?search=` -filter query the filter engine already consumes (ADR-0017) — a free-text search -box plus structured field / operator / value rows. Available fields, operators, -and value suggestions are derived entirely from each field's self-contained -`getFilterSpec` (via the serializable `collectFilterSuggestions` metadata), so -there is no field-type `switch` and no functions cross the server/client -boundary. Applied filters flow through the same secured `context.db`, so -filtering can only ever narrow what a session may see. - -`@opensaas/stack-core` gains `serializeFilterQuery(tokens)` — the exact inverse -of `parseFilterQuery` — so the builder produces the grammar the engine parses -with the quoting and operator-prefix rules kept next to the parser. - -The `FilterBuilder` is composable (exported from `@opensaas/stack-ui` and -`@opensaas/stack-ui/standalone`) with theme-token styling and `data-slot` parts -for extension: - -```tsx -import { FilterBuilder } from '@opensaas/stack-ui/standalone' -import { collectFilterSuggestions } from '@opensaas/stack-core' - -// Server component: collect serializable suggestion metadata for the list. -const suggestions = collectFilterSuggestions(listConfig, 'Post', config) - -// Client: build and apply a `?search=` query. - router.push(`/admin/post?search=${encodeURIComponent(query)}`)} -/> -``` - -The list view wires this in automatically; existing `?search=` URLs keep -working unchanged. diff --git a/.changeset/filter-engine-filter-spec.md b/.changeset/filter-engine-filter-spec.md deleted file mode 100644 index 1d65db59..00000000 --- a/.changeset/filter-engine-filter-spec.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@opensaas/stack-core': minor -'@opensaas/stack-ui': minor ---- - -Add the admin UI filter engine: a Filter spec field-builder contract and URL-driven server-side list filtering (ADR-0017). - -Fields now declare their filtering capability through a new optional `getFilterSpec` method — a peer of `getPrismaType`/`getTypeScriptType` on the field-builder contract. It reports the operators a field supports, a pure token→condition mapper, and serializable suggestion metadata. Core field types implement it (text contains + free text, integer/decimal/timestamp/calendarDay comparisons, select/checkbox equality against enumerated values, relationship by label lookup). A field without a spec — `password`, `json`, `virtual`, or any third-party field that hasn't adopted one — is simply not filterable, so the addition degrades gracefully everywhere. - -The admin list view now parses the URL filter query (the list's `search` param) through the engine and merges the result into the access-controlled query via the secured context, so filtering runs server-side and can only ever narrow — never widen — what a session may see. This replaces the previous hard-coded `type === 'text'` search; free-text behavior is now driven by each text field's Filter spec. - -Grammar (ADR-0017): implicit-AND tokens, quoted multi-word values, `>`/`>=`/`<`/`<=` comparisons on numeric/date fields, and bare words as free text. Unknown syntax degrades to free text, never errors. - -Multi-word free-text UX shift (intentional, per ADR-0017): bare words now combine with AND, so `hello world` requires each word to match separately (not the literal substring `hello world`). To match a contiguous phrase, quote it: `"hello world"`. A pasted URL such as `http://x` is treated as a single free-text token and searched verbatim — the `http:` prefix is not parsed as a field. - -New exports from `@opensaas/stack-core`: - -```typescript -import { - parseFilterQuery, // (query) => FilterToken[] — pure - buildFilterWhere, // (tokens, specs) => where — pure - collectFilterSpecs, // (listConfig, listKey, config) => specs - buildListFilterWhere, // (query, listConfig, listKey, config) => where - collectFilterSuggestions, // serializable autocomplete metadata -} from '@opensaas/stack-core' - -// e.g. "status:Published views:>10 author:\"Ada Lovelace\" beta" -const where = buildListFilterWhere(query, listConfig, listKey, config) -const rows = await context.db.post.findMany({ where }) // ANDed with the access filter -``` - -Third-party field authors can implement `FilterSpec` (exported from `@opensaas/stack-core/extend`) to make their field filterable. diff --git a/.changeset/fix-vercel-blob-get-url.md b/.changeset/fix-vercel-blob-get-url.md deleted file mode 100644 index 85e4d204..00000000 --- a/.changeset/fix-vercel-blob-get-url.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-storage-vercel': patch ---- - -Fix `getUrl()` returning a fabricated host that never resolves. It now derives the store ID from the configured token/store ID (the same way the SDK does internally) and embeds it along with the access mode, matching the real URL the SDK returns at upload time. A token that doesn't yield a store ID now throws a descriptive error instead of producing a silently wrong URL. diff --git a/.changeset/inline-cell-editing-relationship-tables.md b/.changeset/inline-cell-editing-relationship-tables.md deleted file mode 100644 index 0f9dfbb9..00000000 --- a/.changeset/inline-cell-editing-relationship-tables.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@opensaas/stack-core': minor -'@opensaas/stack-ui': minor ---- - -Add inline cell editing to admin Relationship tables - -Cells in a to-many Relationship table on the item view are now editable in place. -Click a cell to edit it, commit with Enter or blur, cancel with Escape. Each -commit is a single-field update on the **related** row through the secured -context, so the related list's own operation- and field-level update access plus -its hooks/validation apply — never the parent's. The update is optimistic and -reverts, with a visible reason, on a Silent failure (access denied / row gone) or -a validation error (inline field errors surface too). Committed values re-render -through the Cell registry, so select cells stay coloured badges. - -A field the session cannot write — or a table whose related-list update access is -statically denied — renders read-only with no edit affordance; row-level -(filter-scoped) denials surface at commit as a revert. Non-editable cells keep -click-to-navigate; main list tables are unchanged (this is Relationship-table -only). - -- `@opensaas/stack-core`: the generic server action gains a distinct - `updateRelated` result shape (`{ updated, error?, fieldErrors? }`), and - `checkFieldAccess` is exposed on `@opensaas/stack-core/internal` so the UI can - decide the edit affordance without a parallel field-access evaluator. -- `@opensaas/stack-ui`: `RelationshipTableClient` accepts `editableColumns`; the - editable cell reuses the field-component registry for its editor and the Cell - registry for its display (new Slots: `relationship-table-cell-display`, - `relationship-table-cell-editor`, `relationship-table-cell-edit-trigger`, - `relationship-table-cell-error`). diff --git a/.changeset/prelinked-create-drawer.md b/.changeset/prelinked-create-drawer.md deleted file mode 100644 index 27fa9beb..00000000 --- a/.changeset/prelinked-create-drawer.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@opensaas/stack-core': minor -'@opensaas/stack-ui': minor ---- - -Add a pre-linked create drawer to read-only Relationship tables (issue #738) - -The item view's read-only Relationship tables now offer a "+ Add" control that -opens a drawer hosting the related list's create form, with the back-reference to -the current record preset and hidden. On submit the new row is created through -the secured context already linked to the parent, then the drawer closes and the -table refreshes. - -Create-and-link semantics (ADR-0018): the create runs on the RELATED list, so -the related list's own `create` access control, hooks, and field-level access -apply — never the parent's. The back-reference is set on the server from the -field/parent id (never trusted from the client payload). The "+ Add" is shown -only when a back-reference exists to preset the link and the related list's -`create` access is not statically denied; a filter/function-scoped denial -surfaces at commit time as a generic error (no denied-vs-absent leak). - -New generic server action (`@opensaas/stack-core`): - -```ts -await context.serverAction({ - listKey: 'Post', // the RELATED list - action: 'createRelated', - data: { title: 'Hello', slug: 'hello' }, - field: 'author', // the back-reference field on Post - parentId: user.id, // the record being edited -}) -// → { created: true, id } | { created: false, error?, fieldErrors? } -``` - -The drawer (`RelationshipCreateDrawer` from `@opensaas/stack-ui`) mounts on the -existing `relationship-table-toolbar` seam and reuses the shared item-form engine -and field-component registry, so the related list's full validation and required -fields are enforced even when a required field is not one of the table's columns. diff --git a/.changeset/private-vercel-blobs.md b/.changeset/private-vercel-blobs.md deleted file mode 100644 index 6a4e846f..00000000 --- a/.changeset/private-vercel-blobs.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@opensaas/stack-storage-vercel': minor ---- - -Honour `public: false` with real private-blob support. Uploads now map `public: false` to `access: 'private'` (previously ignored); `download()` reads both public and private blobs through `@vercel/blob`'s authorized `get()` path instead of a plain `fetch(url)`, and rejects with a descriptive "File not found" error for a missing blob instead of an unrelated failure; a new `getSignedUrl(filename, expiresIn?)` returns a time-limited signed URL for serving private files through developer-controlled routes. - -```typescript -storage: { - documents: vercelBlobStorage({ - token: process.env.BLOB_READ_WRITE_TOKEN, - public: false, - }), -} - -const provider = createStorageProvider(config, 'documents') -const signedUrl = await provider.getSignedUrl('report.pdf', 3600) -``` - -This also bumps the `@vercel/blob` dependency from `^2.3.1` to `^2.6.1`, which adds the `issueSignedToken`/`presignUrl` APIs `getSignedUrl()` relies on. diff --git a/.changeset/quick-owls-guard.md b/.changeset/quick-owls-guard.md deleted file mode 100644 index 0ad28a1d..00000000 --- a/.changeset/quick-owls-guard.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-core': patch ---- - -Harden `createRelated` server action: reject malformed calls that supply only one of `field`/`parentId`, and validate that the back-reference names a relationship field before injecting the parent connect. diff --git a/.changeset/quiet-foxes-run.md b/.changeset/quiet-foxes-run.md deleted file mode 100644 index c1b06645..00000000 --- a/.changeset/quiet-foxes-run.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-core': patch ---- - -Return a generic "Action failed" message (and log the real error server-side) when a custom bulk-action handler throws an unexpected non-Prisma error, instead of surfacing its internal message to the client diff --git a/.changeset/quiet-otters-hum.md b/.changeset/quiet-otters-hum.md deleted file mode 100644 index 6aa538c2..00000000 --- a/.changeset/quiet-otters-hum.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-core': patch ---- - -Harden relationship count-filter resolution: preserve any sibling conditions co-present in a `_countFilter` marker's AND-member instead of replacing it wholesale, and document why the secured count read intentionally keeps its full projection (`context.db` does not honour `select`). diff --git a/.changeset/quiet-otters-jump.md b/.changeset/quiet-otters-jump.md deleted file mode 100644 index dbedffb6..00000000 --- a/.changeset/quiet-otters-jump.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-storage-vercel': patch ---- - -Fix unique filename generation to use `path.extname` semantics (matching the local provider) so extension-less originals no longer get the whole name appended, and pass through an explicit `cacheControlMaxAge: 0` instead of dropping it. diff --git a/.changeset/quiet-otters-validate.md b/.changeset/quiet-otters-validate.md deleted file mode 100644 index 70f6a7a8..00000000 --- a/.changeset/quiet-otters-validate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-storage': patch ---- - -Add integration-style tests proving image()/file() reject unrecognised write value shapes end-to-end in both `db.columns: 'keystone'` (multi-column) and default single-column (JSON) modes, pinning the #789 fix as a regression guard. diff --git a/.changeset/relationship-row-removal.md b/.changeset/relationship-row-removal.md deleted file mode 100644 index 64008019..00000000 --- a/.changeset/relationship-row-removal.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@opensaas/stack-core': minor -'@opensaas/stack-ui': minor ---- - -Add relationship-table row removal to the admin item view (ADR-0018) - -Each read-only Relationship table row now has a ✕ removal control. By default it -**disconnects** the related row from the current record (non-destructive — the -row survives and still appears on its own list), gated on the related list's -update access. A per-relationship opt-in truly deletes the related row (behind a -confirmation, gated on the related list's delete access), or hides the control -entirely. Where the schema makes disconnect impossible (a required foreign key on -the related side) the control is hidden unless delete is opted in. Removals run -through the secured context, so an access-denied removal is a Silent failure: the -row stays with a visible reason. - -Configure per relationship via `ui.itemView.removeAction`: - -```typescript -User: list({ - fields: { - // Default: ✕ disconnects the post (it still exists). - posts: relationship({ ref: 'Post.author', many: true }), - // Opt in to destructive delete (confirmed). - notes: relationship({ - ref: 'Note.owner', - many: true, - ui: { itemView: { removeAction: 'delete' } }, // 'disconnect' (default) | 'delete' | 'none' - }), - }, -}) -``` - -`@opensaas/stack-core` adds a `removeRelated` server action (distinct -`{ removed }` result shape, like `bulkDelete`, so a redirect-on-success wrapper -never hijacks an in-place removal) and the `RelationshipItemViewConfig.removeAction` -option. diff --git a/.changeset/rich-cells-registry.md b/.changeset/rich-cells-registry.md deleted file mode 100644 index 097909db..00000000 --- a/.changeset/rich-cells-registry.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@opensaas/stack-core': minor -'@opensaas/stack-ui': minor ---- - -Add a Cell registry with default cells for core field types - -List tables now render every value through a **Cell** resolved by a -cell-component registry that mirrors the form-field registry's priority chain: -per-field override → custom type registry → field-type registry → plain-text -fallback. Each core field type ships a default Cell — text (plain), integer -(tabular figures), select (coloured Badge), timestamp (formatted date), checkbox -(mark), and to-one relationship (Item label link). Unknown/third-party types -without a registered Cell fall back to plain text. - -Select options gain optional, additive per-option UI metadata mapping a value to -a badge variant. Existing options keep working unchanged; unmapped options render -the neutral badge. - -```typescript -// opensaas.config.ts — colour a status value in list-table cells -status: select({ - options: [ - { label: 'Draft', value: 'draft', ui: { variant: 'secondary' } }, - { label: 'Published', value: 'published', ui: { variant: 'success' } }, - ], -}) -``` - -Register a Cell for a custom/third-party field exactly as you register its form -component, or override a single field's Cell: - -```typescript -'use client' -import { registerCellComponent } from '@opensaas/stack-ui' -registerCellComponent('myField', MyCell) - -// or per-field override (highest priority) -price: integer({ ui: { cell: CurrencyCell } }) -``` diff --git a/.changeset/row-selection-bulk-delete.md b/.changeset/row-selection-bulk-delete.md deleted file mode 100644 index 8973b165..00000000 --- a/.changeset/row-selection-bulk-delete.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@opensaas/stack-ui': minor ---- - -Add row selection and a built-in Bulk action Delete to the admin list view - -The list table now renders a selection checkbox column when the list's delete -access is not statically false. The header checkbox toggles the visible page, -per-row checkboxes accumulate an explicit id set across pages, and the selection -clears when the filter changes. A selection bar shows the count, a Clear action, -a named `data-slot="selection-actions"` seam for future custom bulk actions, and -— only when delete access allows — a Delete that confirms first, deletes each -selected row through the secured context honouring Silent failure, and reports -"N of M deleted" (partial access denials are visible without revealing which or -why). - -The admin list view also honours an optional `?pageSize=` URL param, preserved -across sorting, searching and paging. - -New exports: `RowSelectionBar` (with `RowSelectionBarProps` / -`RowSelectionBarClassNames`) and the `useRowSelection` hook plus the pure -`isPageFullySelected` / `getPageCheckboxState` helpers. - -```tsx -import { RowSelectionBar, useRowSelection } from '@opensaas/stack-ui' - -const selection = useRowSelection('Post', filterKey) - { - /* delete the selected ids through the secured context */ - }} -/> -``` diff --git a/.changeset/sharp-otters-drift.md b/.changeset/sharp-otters-drift.md deleted file mode 100644 index ee26066b..00000000 --- a/.changeset/sharp-otters-drift.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@opensaas/stack-ui': minor -'@opensaas/stack-core': minor ---- - -Derive the admin item view from the list shape, with read-only Relationship tables and a totals footer (#734) - -A record's edit page now derives its layout from the list's shape. Scalar and -to-one fields stay in a details card (whole-form Save/Cancel, unchanged), and -each to-many relationship renders as a read-only **Relationship table**: one -to-many relationship gives a two-column split, none gives a single centered -card, several stack. Table columns default to the related list's own column -curation minus the back-reference to the parent, cells come from the cell -registry, and a totals footer always shows the row count plus sums for any -explicitly-configured numeric columns (each formatted by that column's Cell). -Rows are fetched through the secured context, so only access-visible data shows. -Rows are read-only here — a row click navigates to the related record. - -`@opensaas/stack-core` gains additive item-view config (no breaking changes): - -```typescript -lists: { - User: list({ - fields: { - posts: relationship({ - ref: 'Post.author', - many: true, - ui: { - itemView: { - // Override the Relationship table's columns… - columns: ['title', 'status', 'viewCount'], - // …and sum numeric columns in the totals footer. - sum: ['viewCount'], - // Or demote it back to the compact picker in the details card: - // displayMode: 'picker', - }, - }, - }), - }, - // Reorder the Relationship-table sections: - ui: { itemView: { order: ['posts'] } }, - }), -} -``` - -New `@opensaas/stack-ui` exports: `RelationshipTable`, `RelationshipTableClient`, -and the pure `deriveItemViewLayout` helper (with `ItemViewLayout`, -`ItemViewArrangement`, `RelationshipTableSection`). The Relationship table ships -named Slots (`relationship-table`, `relationship-table-toolbar`, -`relationship-table-row`, `relationship-table-cell`, `relationship-table-footer`) -as extension seams for the follow-up inline-edit, create-drawer, and row-removal -work. diff --git a/.changeset/silent-owls-guard.md b/.changeset/silent-owls-guard.md deleted file mode 100644 index 29be09ec..00000000 --- a/.changeset/silent-owls-guard.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-auth': patch ---- - -Add a regression test locking the generated Session/Account user FK shape (no `@@index([userId])`, `onDelete: Cascade`) so future drift from better-auth parity is caught. diff --git a/.changeset/spicy-otters-kneel.md b/.changeset/spicy-otters-kneel.md deleted file mode 100644 index 734525f2..00000000 --- a/.changeset/spicy-otters-kneel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-storage': patch ---- - -`StorageProvider.delete()` is now documented as idempotent; the local provider swallows `ENOENT` on delete instead of throwing. diff --git a/.changeset/spicy-rivers-bound.md b/.changeset/spicy-rivers-bound.md deleted file mode 100644 index 3caa2cd7..00000000 --- a/.changeset/spicy-rivers-bound.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@opensaas/stack-core': minor -'@opensaas/stack-ui': minor ---- - -Bound the admin item-view Relationship tables with a `take` and a "showing N of M" footer - -The read-only Relationship tables on a record's edit page (issue #734) previously -fetched every related row unbounded. They now fetch a bounded page of related rows -and surface the full access-scoped total in the footer. - -- **Bounded fetch:** each to-many Relationship table fetches at most a default cap - of related rows (`DEFAULT_ITEM_VIEW_TAKE`, 10), overridable per relationship via - `ui.itemView.take`. Rows are still fetched through the secured context, so only - access-visible rows come back. -- **"Showing N of M" footer:** the totals footer now reads `Showing N of M rows`, - where N is the rendered (bounded) count and M is the full access-scoped total, - fetched via a filtered `_count` that folds the related list's own `query` access - in (mirroring the list view's count columns). A fully-denied related list reads - `Showing 0 of 0` and never leaks a true total. The row count is always shown, - including the zero-column footer path. - -```typescript -sessions: relationship({ - ref: 'Session.user', - many: true, - // Cap this table at 5 rows; the footer still shows the full access-scoped total. - ui: { itemView: { take: 5 } }, -}) -``` - -Core: `mergeIncludeWithAccessControl` now preserves a caller-supplied `take` on a -to-many relation include (it only narrows the fetch, never widening past the access -`where`), so the secured `findUnique`/`findMany` include can bound related-row reads. diff --git a/.changeset/tall-otters-count.md b/.changeset/tall-otters-count.md deleted file mode 100644 index e4b9ca83..00000000 --- a/.changeset/tall-otters-count.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@opensaas/stack-core': minor -'@opensaas/stack-ui': minor ---- - -Admin list view: to-many relationship columns render an access-visible count, sort by relation count, and filter by numeric count comparisons (issue #732). Virtual fields render via their Cell but are excluded from sorting and filtering. - -A to-many relationship used as a list column now shows the count of the related rows the session may see — fetched in the SAME query via a filtered Prisma `_count`, with the related list's `query` access folded into the count's `where`, so it never counts rows the session cannot read and issues no per-row query. Clicking the column header sorts by relation `_count`, and its Filter spec offers numeric comparisons on the count (`posts:>5`) in the filter builder and in shared URLs. - -Because Prisma cannot compare a relation count in a `where`, a to-many relationship's Filter spec emits a structured count marker that is resolved to an access-scoped `{ id: { in } }` before the query runs, through the secured context. - -New `@opensaas/stack-core` exports: `buildRelationshipCountSelect`, `resolveRelationshipCountFilters`, `isToManyRelationshipField`, and `RELATIONSHIP_COUNT_FILTER_KEY` (with the `RelationshipCountFilterMarker` type). - -```ts -// A to-many relationship column now shows an access-scoped count and is -// sortable / filterable by that count — zero config: -User: list({ - fields: { - name: text(), - posts: relationship({ ref: 'Post.author', many: true }), - }, -}) -// List view: the `posts` column renders the count; its header sorts by count; -// `posts:>5` filters by count in the builder and in a shared URL. -``` diff --git a/.changeset/tame-crabs-jump.md b/.changeset/tame-crabs-jump.md deleted file mode 100644 index 117afe47..00000000 --- a/.changeset/tame-crabs-jump.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@opensaas/stack-ui': minor ---- - -Standalone `ListTable` now routes every cell through the shared cell registry (`CellRenderer`), matching `ListView`. The bespoke relationship renderer and `fieldTypes[column] === 'relationship'` branch are gone in favour of `RelationshipCell`, which already handles link navigation and `stopPropagation`. - -A new optional `fieldOptions` prop lets `select` columns resolve label mapping and `ui.variant` badge colour, exactly like `ListView`: - -```tsx - -``` - -Existing `ListTable` call sites keep working unchanged. diff --git a/.changeset/tame-goats-whistle.md b/.changeset/tame-goats-whistle.md deleted file mode 100644 index 28edc227..00000000 --- a/.changeset/tame-goats-whistle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-storage-vercel': patch ---- - -Fix `delete()`/`download()` for `@vercel/blob` v2, which rejects `head()` with `BlobNotFoundError` for missing blobs instead of resolving `null`. `delete()` of a missing blob is now a no-op and no longer round-trips through `head()`. diff --git a/.changeset/tame-otters-overwrite.md b/.changeset/tame-otters-overwrite.md deleted file mode 100644 index 410329c5..00000000 --- a/.changeset/tame-otters-overwrite.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@opensaas/stack-storage-vercel': minor ---- - -Add `allowOverwrite` config option to `vercelBlobStorage`. The Vercel Blob API rejects uploads to an existing pathname unless this is sent, which made stable-filename replace workflows (`generateUniqueFilenames: false`, e.g. a field with `cleanupOnReplace`) throw "blob already exists". `allowOverwrite` now defaults to `true` when `generateUniqueFilenames` is `false`, and `false` otherwise; an explicit setting always wins. - -```typescript -vercelBlobStorage({ - token: process.env.BLOB_READ_WRITE_TOKEN, - generateUniqueFilenames: false, - allowOverwrite: false, // opt back into reject-on-overwrite with stable names -}) -``` diff --git a/.changeset/thin-otters-scope.md b/.changeset/thin-otters-scope.md deleted file mode 100644 index 0e4a329e..00000000 --- a/.changeset/thin-otters-scope.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@opensaas/stack-core': patch -'@opensaas/stack-ui': patch ---- - -Fix a to-one relationship filter token (e.g. `author:Ada`) leaking related-list data by ANDing the related list's `query` access filter into the nested condition instead of running it unscoped. diff --git a/.changeset/tidy-otters-validate.md b/.changeset/tidy-otters-validate.md deleted file mode 100644 index f311ff66..00000000 --- a/.changeset/tidy-otters-validate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-core': patch ---- - -Fix multi-column fields (e.g. storage `image()`/`file()` in Keystone-parity mode) writing an unrecognised value silently instead of failing validation. The column split now runs after `validateFieldRules`, not before, at the top-level and nested write paths. diff --git a/.changeset/tidy-owls-hide.md b/.changeset/tidy-owls-hide.md deleted file mode 100644 index 2518ac7f..00000000 --- a/.changeset/tidy-owls-hide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-ui': patch ---- - -Exclude json columns from relationship-table inline editing so an unchanged json cell no longer wastes a secured update round-trip diff --git a/.changeset/tiny-plums-fly.md b/.changeset/tiny-plums-fly.md deleted file mode 100644 index 0691d366..00000000 --- a/.changeset/tiny-plums-fly.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@opensaas/stack-tiptap': patch ---- - -Bump `@tiptap/pm` and `@tiptap/extension-placeholder` to `^3.28.0` to match `@tiptap/starter-kit`, fixing a TypeScript build failure caused by duplicate `@tiptap/pm` versions in the dependency tree. diff --git a/.changeset/witty-otters-listen.md b/.changeset/witty-otters-listen.md deleted file mode 100644 index d5910bfa..00000000 --- a/.changeset/witty-otters-listen.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@opensaas/stack-storage-vercel': minor ---- - -Add Vercel OIDC authentication support to the Vercel Blob storage provider - -The provider no longer requires a static read-write token. New `storeId` and `oidcToken` config options enable Vercel OIDC auth — the `@vercel/blob` SDK exchanges the deployment's `VERCEL_OIDC_TOKEN` for blob credentials automatically: - -```typescript -storage: { - uploads: vercelBlobStorage({ - storeId: process.env.BLOB_STORE_ID, // or omit and just set BLOB_STORE_ID - pathPrefix: 'uploads', - }), -} -``` - -Credential precedence (resolved by the SDK on every call): explicit `token` → OIDC token (`oidcToken` or `VERCEL_OIDC_TOKEN`) plus store id (`storeId` or `BLOB_STORE_ID`) → `BLOB_READ_WRITE_TOKEN` environment variable. - -The constructor no longer throws when no static token is configured — previously this blocked OIDC-authenticated deployments before the SDK's credential resolution could run. When no credentials are available at all, the SDK now throws a descriptive error on the first storage operation instead. Requires `@vercel/blob` 2.4.1+ (dependency floor bumped from 2.3.1). diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md index de93cd95..0bf85f9a 100644 --- a/packages/auth/CHANGELOG.md +++ b/packages/auth/CHANGELOG.md @@ -1,5 +1,11 @@ # @opensaas/stack-auth +## 0.31.0 + +### Patch Changes + +- [#772](https://github.com/OpenSaasAU/stack/pull/772) [`be5772b`](https://github.com/OpenSaasAU/stack/commit/be5772be231d5be6a77d80c4f7eff5adc15da2fa) Thanks [@borisno2](https://github.com/borisno2)! - Add a regression test locking the generated Session/Account user FK shape (no `@@index([userId])`, `onDelete: Cascade`) so future drift from better-auth parity is caught. + ## 0.30.0 ### Patch Changes diff --git a/packages/auth/package.json b/packages/auth/package.json index 4438d734..c5495800 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-auth", - "version": "0.30.0", + "version": "0.31.0", "description": "Better-auth integration for OpenSaas Stack", "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 777cad68..1e40d1dc 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,12 @@ # @opensaas/stack-cli +## 0.31.0 + +### Patch Changes + +- Updated dependencies [[`047487a`](https://github.com/OpenSaasAU/stack/commit/047487adf502f10f7f6774ff52c38c70d465f533), [`9cd06dd`](https://github.com/OpenSaasAU/stack/commit/9cd06dddb45512966affc3a6b3455e97595c0de2), [`b190813`](https://github.com/OpenSaasAU/stack/commit/b190813a4531bd01b3206845b2c531099e0a204a), [`f67cd79`](https://github.com/OpenSaasAU/stack/commit/f67cd798724712a90d7ada8f28202d3d6371693f), [`dcb10e2`](https://github.com/OpenSaasAU/stack/commit/dcb10e27c28a8a8f9a5e625f550ac5c750436eb6), [`f8b6f02`](https://github.com/OpenSaasAU/stack/commit/f8b6f02c18322d0d04a7c3cc82e579d0ba9a2da9), [`c05701e`](https://github.com/OpenSaasAU/stack/commit/c05701e523815b8f411a6d39e57bbb9317dc2a9d), [`5a60291`](https://github.com/OpenSaasAU/stack/commit/5a602916f30535604b590b875c363f21930a109f), [`2fcb582`](https://github.com/OpenSaasAU/stack/commit/2fcb5820bc00d9d432265d1ba01404097e296e8e), [`85c7fc3`](https://github.com/OpenSaasAU/stack/commit/85c7fc3b3a0090a986cafa0e46b1798f237264da), [`8199238`](https://github.com/OpenSaasAU/stack/commit/81992382290f356071955f16efd14f7771045a16), [`4d99e91`](https://github.com/OpenSaasAU/stack/commit/4d99e910b61c6196564a7248abf3d32b1d6be883), [`20459b5`](https://github.com/OpenSaasAU/stack/commit/20459b5a7f8b2578342509442d36017cfa2f08f6), [`62a1612`](https://github.com/OpenSaasAU/stack/commit/62a16127c7b6610a35fb239911eff3486de585be), [`c210319`](https://github.com/OpenSaasAU/stack/commit/c210319c3b25ff74d832d3c2ec5d3253d5d8b832), [`55d55e0`](https://github.com/OpenSaasAU/stack/commit/55d55e0a1ed9521b6e31283524d9194a9420059a), [`96e1067`](https://github.com/OpenSaasAU/stack/commit/96e1067661c7ebc8e23896086fec7428e475dd03)]: + - @opensaas/stack-core@0.31.0 + ## 0.30.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index ea93b3f4..087c02a3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-cli", - "version": "0.30.0", + "version": "0.31.0", "description": "CLI tools for OpenSaas Stack", "type": "module", "main": "./dist/index.js", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index e201b16e..3d7def7c 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,415 @@ # @opensaas/stack-core +## 0.31.0 + +### Minor Changes + +- [#750](https://github.com/OpenSaasAU/stack/pull/750) [`047487a`](https://github.com/OpenSaasAU/stack/commit/047487adf502f10f7f6774ff52c38c70d465f533) Thanks [@borisno2](https://github.com/borisno2)! - Add a `bulkDelete` server action for list-level bulk deletion + + `context.serverAction` now accepts `{ listKey, action: 'bulkDelete', ids }`. It + deletes each id row-by-row through the secured context, honouring Silent failure + (a denied or missing row returns `null` and is not counted; one row's error does + not abort the rest), and returns `{ deleted, total }`. + + The result is deliberately a count shape rather than the single-op `{ success }` + shape, so a UI `serverAction` wrapper that redirects on a single-item success + (the item-form pattern) does not hijack a list-level bulk operation. + + ```ts + const result = await context.serverAction({ + listKey: 'Post', + action: 'bulkDelete', + ids: ['a', 'b', 'c'], + }) + // result: { deleted: 2, total: 3 } // one row was denied/missing + ``` + +- [#755](https://github.com/OpenSaasAU/stack/pull/755) [`9cd06dd`](https://github.com/OpenSaasAU/stack/commit/9cd06dddb45512966affc3a6b3455e97595c0de2) Thanks [@list({](https://github.com/list({)! - Admin chrome polish: opt-in nav counts and avatar label cells ([#735](https://github.com/OpenSaasAU/stack/issues/735)) + + Two per-list opt-ins for the admin UI, both off by default. + + **Nav counts** — set `ui.navCount: true` on a list to show an access-scoped + record count next to its nav item. The count is fetched through the secured + context, so it only ever reflects what the current session may see; no count + query runs for lists that don't opt in, and a list whose query access is + statically denied renders no count rather than a misleading zero. + + ```typescript + lists: { + Post: list({ + fields: { + /* ... */ + }, + ui: { navCount: true }, + }), + } + ``` + + **Avatar label cells** — set `ui.avatar: true` to render a list's label column + with a deterministic initials bubble ahead of the emphasized Item label. The + initials and colour derive from the row; the palette is Theme-token-derived (no + raw hex). A per-field cell override (`ui.cell`) on the label field still wins. + + ```typescript + lists: { + + fields: { + /* ... */ + }, + ui: { avatar: true }, + }), + } + ``` + + New exports: + + - `@opensaas/stack-core`: `resolveNavCounts`, `isListQueryStaticallyDenied` + - `@opensaas/stack-ui`: `Avatar` primitive, `AvatarLabelCell`, and the + `getInitials`, `getAvatarTone`, `AVATAR_TONES` helpers. New Slots: + `avatar`, `cell-avatar-label`, `nav-count`. + +- [#759](https://github.com/OpenSaasAU/stack/pull/759) [`b190813`](https://github.com/OpenSaasAU/stack/commit/b190813a4531bd01b3206845b2c531099e0a204a) Thanks [@borisno2](https://github.com/borisno2)! - Add custom Bulk actions from list config (admin list view) + + A list can now declare list-specific Bulk actions under `ui.listView.bulkActions`. Each action's button renders in the list view's selection bar (in declaration order) alongside the built-in Delete. The action's server-side `handler` receives the selected ids and the secured context, so all its work runs through access control and hooks — a denied row is a Silent failure absorbed into the outcome, never leaked. + + ```typescript + Post: list({ + fields: { title: text(), status: select({ options: [/* ... */] }) }, + ui: { + listView: { + bulkActions: [ + { + key: 'publish', + label: 'Publish', + // Optional: `variant`, `destructive` (confirm first), + // `hasAccess` (server-side visibility gate). + handler: async ({ ids, context }) => { + let n = 0 + for (const id of ids) { + const updated = await context.db.post.update({ + where: { id }, + data: { status: 'published' }, + }) + if (updated) n++ + } + return { message: `Published ${n} of ${ids.length}` } + }, + }, + ], + }, + }, + }) + ``` + + Only serialisable metadata (`key`/`label`/`variant`/`destructive`) crosses to the client; the `handler`/`hasAccess` functions stay on the server. Clicking the button sends the `key` and selected ids back through the generic server action, which looks the handler up and runs it with a freshly-rebuilt secured context. Selection is enabled for a list that has custom actions even when Delete is denied. CSV export is documented as a recipe using this surface rather than shipping as a built-in. + +- [#754](https://github.com/OpenSaasAU/stack/pull/754) [`f67cd79`](https://github.com/OpenSaasAU/stack/commit/f67cd798724712a90d7ada8f28202d3d6371693f) Thanks [@borisno2](https://github.com/borisno2)! - Add the Filter builder input UI for the admin list view ([#731](https://github.com/OpenSaasAU/stack/issues/731)) + + The admin list view now ships a `FilterBuilder` that constructs the `?search=` + filter query the filter engine already consumes (ADR-0017) — a free-text search + box plus structured field / operator / value rows. Available fields, operators, + and value suggestions are derived entirely from each field's self-contained + `getFilterSpec` (via the serializable `collectFilterSuggestions` metadata), so + there is no field-type `switch` and no functions cross the server/client + boundary. Applied filters flow through the same secured `context.db`, so + filtering can only ever narrow what a session may see. + + `@opensaas/stack-core` gains `serializeFilterQuery(tokens)` — the exact inverse + of `parseFilterQuery` — so the builder produces the grammar the engine parses + with the quoting and operator-prefix rules kept next to the parser. + + The `FilterBuilder` is composable (exported from `@opensaas/stack-ui` and + `@opensaas/stack-ui/standalone`) with theme-token styling and `data-slot` parts + for extension: + + ```tsx + import { FilterBuilder } from '@opensaas/stack-ui/standalone' + import { collectFilterSuggestions } from '@opensaas/stack-core' + + // Server component: collect serializable suggestion metadata for the list. + const suggestions = collectFilterSuggestions(listConfig, 'Post', config) + + // Client: build and apply a `?search=` query. + router.push(`/admin/post?search=${encodeURIComponent(query)}`)} + /> + ``` + + The list view wires this in automatically; existing `?search=` URLs keep + working unchanged. + +- [#746](https://github.com/OpenSaasAU/stack/pull/746) [`dcb10e2`](https://github.com/OpenSaasAU/stack/commit/dcb10e27c28a8a8f9a5e625f550ac5c750436eb6) Thanks [@borisno2](https://github.com/borisno2)! - Add the admin UI filter engine: a Filter spec field-builder contract and URL-driven server-side list filtering (ADR-0017). + + Fields now declare their filtering capability through a new optional `getFilterSpec` method — a peer of `getPrismaType`/`getTypeScriptType` on the field-builder contract. It reports the operators a field supports, a pure token→condition mapper, and serializable suggestion metadata. Core field types implement it (text contains + free text, integer/decimal/timestamp/calendarDay comparisons, select/checkbox equality against enumerated values, relationship by label lookup). A field without a spec — `password`, `json`, `virtual`, or any third-party field that hasn't adopted one — is simply not filterable, so the addition degrades gracefully everywhere. + + The admin list view now parses the URL filter query (the list's `search` param) through the engine and merges the result into the access-controlled query via the secured context, so filtering runs server-side and can only ever narrow — never widen — what a session may see. This replaces the previous hard-coded `type === 'text'` search; free-text behavior is now driven by each text field's Filter spec. + + Grammar (ADR-0017): implicit-AND tokens, quoted multi-word values, `>`/`>=`/`<`/`<=` comparisons on numeric/date fields, and bare words as free text. Unknown syntax degrades to free text, never errors. + + Multi-word free-text UX shift (intentional, per ADR-0017): bare words now combine with AND, so `hello world` requires each word to match separately (not the literal substring `hello world`). To match a contiguous phrase, quote it: `"hello world"`. A pasted URL such as `http://x` is treated as a single free-text token and searched verbatim — the `http:` prefix is not parsed as a field. + + New exports from `@opensaas/stack-core`: + + ```typescript + import { + parseFilterQuery, // (query) => FilterToken[] — pure + buildFilterWhere, // (tokens, specs) => where — pure + collectFilterSpecs, // (listConfig, listKey, config) => specs + buildListFilterWhere, // (query, listConfig, listKey, config) => where + collectFilterSuggestions, // serializable autocomplete metadata + } from '@opensaas/stack-core' + + // e.g. "status:Published views:>10 author:\"Ada Lovelace\" beta" + const where = buildListFilterWhere(query, listConfig, listKey, config) + const rows = await context.db.post.findMany({ where }) // ANDed with the access filter + ``` + + Third-party field authors can implement `FilterSpec` (exported from `@opensaas/stack-core/extend`) to make their field filterable. + +- [#760](https://github.com/OpenSaasAU/stack/pull/760) [`f8b6f02`](https://github.com/OpenSaasAU/stack/commit/f8b6f02c18322d0d04a7c3cc82e579d0ba9a2da9) Thanks [@borisno2](https://github.com/borisno2)! - Add inline cell editing to admin Relationship tables + + Cells in a to-many Relationship table on the item view are now editable in place. + Click a cell to edit it, commit with Enter or blur, cancel with Escape. Each + commit is a single-field update on the **related** row through the secured + context, so the related list's own operation- and field-level update access plus + its hooks/validation apply — never the parent's. The update is optimistic and + reverts, with a visible reason, on a Silent failure (access denied / row gone) or + a validation error (inline field errors surface too). Committed values re-render + through the Cell registry, so select cells stay coloured badges. + + A field the session cannot write — or a table whose related-list update access is + statically denied — renders read-only with no edit affordance; row-level + (filter-scoped) denials surface at commit as a revert. Non-editable cells keep + click-to-navigate; main list tables are unchanged (this is Relationship-table + only). + + - `@opensaas/stack-core`: the generic server action gains a distinct + `updateRelated` result shape (`{ updated, error?, fieldErrors? }`), and + `checkFieldAccess` is exposed on `@opensaas/stack-core/internal` so the UI can + decide the edit affordance without a parallel field-access evaluator. + - `@opensaas/stack-ui`: `RelationshipTableClient` accepts `editableColumns`; the + editable cell reuses the field-component registry for its editor and the Cell + registry for its display (new Slots: `relationship-table-cell-display`, + `relationship-table-cell-editor`, `relationship-table-cell-edit-trigger`, + `relationship-table-cell-error`). + +- [#757](https://github.com/OpenSaasAU/stack/pull/757) [`c05701e`](https://github.com/OpenSaasAU/stack/commit/c05701e523815b8f411a6d39e57bbb9317dc2a9d) Thanks [@borisno2](https://github.com/borisno2)! - Add a pre-linked create drawer to read-only Relationship tables (issue [#738](https://github.com/OpenSaasAU/stack/issues/738)) + + The item view's read-only Relationship tables now offer a "+ Add" control that + opens a drawer hosting the related list's create form, with the back-reference to + the current record preset and hidden. On submit the new row is created through + the secured context already linked to the parent, then the drawer closes and the + table refreshes. + + Create-and-link semantics (ADR-0018): the create runs on the RELATED list, so + the related list's own `create` access control, hooks, and field-level access + apply — never the parent's. The back-reference is set on the server from the + field/parent id (never trusted from the client payload). The "+ Add" is shown + only when a back-reference exists to preset the link and the related list's + `create` access is not statically denied; a filter/function-scoped denial + surfaces at commit time as a generic error (no denied-vs-absent leak). + + New generic server action (`@opensaas/stack-core`): + + ```ts + await context.serverAction({ + listKey: 'Post', // the RELATED list + action: 'createRelated', + data: { title: 'Hello', slug: 'hello' }, + field: 'author', // the back-reference field on Post + parentId: user.id, // the record being edited + }) + // → { created: true, id } | { created: false, error?, fieldErrors? } + ``` + + The drawer (`RelationshipCreateDrawer` from `@opensaas/stack-ui`) mounts on the + existing `relationship-table-toolbar` seam and reuses the shared item-form engine + and field-component registry, so the related list's full validation and required + fields are enforced even when a required field is not one of the table's columns. + +- [#756](https://github.com/OpenSaasAU/stack/pull/756) [`8199238`](https://github.com/OpenSaasAU/stack/commit/81992382290f356071955f16efd14f7771045a16) Thanks [@list({](https://github.com/list({)! - Add relationship-table row removal to the admin item view (ADR-0018) + + Each read-only Relationship table row now has a ✕ removal control. By default it + **disconnects** the related row from the current record (non-destructive — the + row survives and still appears on its own list), gated on the related list's + update access. A per-relationship opt-in truly deletes the related row (behind a + confirmation, gated on the related list's delete access), or hides the control + entirely. Where the schema makes disconnect impossible (a required foreign key on + the related side) the control is hidden unless delete is opted in. Removals run + through the secured context, so an access-denied removal is a Silent failure: the + row stays with a visible reason. + + Configure per relationship via `ui.itemView.removeAction`: + + ```typescript + + fields: { + // Default: ✕ disconnects the post (it still exists). + posts: relationship({ ref: 'Post.author', many: true }), + // Opt in to destructive delete (confirmed). + notes: relationship({ + ref: 'Note.owner', + many: true, + ui: { itemView: { removeAction: 'delete' } }, // 'disconnect' (default) | 'delete' | 'none' + }), + }, + }) + ``` + + `@opensaas/stack-core` adds a `removeRelated` server action (distinct + `{ removed }` result shape, like `bulkDelete`, so a redirect-on-success wrapper + never hijacks an in-place removal) and the `RelationshipItemViewConfig.removeAction` + option. + +- [#745](https://github.com/OpenSaasAU/stack/pull/745) [`4d99e91`](https://github.com/OpenSaasAU/stack/commit/4d99e910b61c6196564a7248abf3d32b1d6be883) Thanks [@borisno2](https://github.com/borisno2)! - Add a Cell registry with default cells for core field types + + List tables now render every value through a **Cell** resolved by a + cell-component registry that mirrors the form-field registry's priority chain: + per-field override → custom type registry → field-type registry → plain-text + fallback. Each core field type ships a default Cell — text (plain), integer + (tabular figures), select (coloured Badge), timestamp (formatted date), checkbox + (mark), and to-one relationship (Item label link). Unknown/third-party types + without a registered Cell fall back to plain text. + + Select options gain optional, additive per-option UI metadata mapping a value to + a badge variant. Existing options keep working unchanged; unmapped options render + the neutral badge. + + ```typescript + // opensaas.config.ts — colour a status value in list-table cells + status: select({ + options: [ + { label: 'Draft', value: 'draft', ui: { variant: 'secondary' } }, + { label: 'Published', value: 'published', ui: { variant: 'success' } }, + ], + }) + ``` + + Register a Cell for a custom/third-party field exactly as you register its form + component, or override a single field's Cell: + + ```typescript + 'use client' + import { registerCellComponent } from '@opensaas/stack-ui' + registerCellComponent('myField', MyCell) + + // or per-field override (highest priority) + price: integer({ ui: { cell: CurrencyCell } }) + ``` + +- [#751](https://github.com/OpenSaasAU/stack/pull/751) [`20459b5`](https://github.com/OpenSaasAU/stack/commit/20459b5a7f8b2578342509442d36017cfa2f08f6) Thanks [@list({](https://github.com/list({)! - Derive the admin item view from the list shape, with read-only Relationship tables and a totals footer ([#734](https://github.com/OpenSaasAU/stack/issues/734)) + + A record's edit page now derives its layout from the list's shape. Scalar and + to-one fields stay in a details card (whole-form Save/Cancel, unchanged), and + each to-many relationship renders as a read-only **Relationship table**: one + to-many relationship gives a two-column split, none gives a single centered + card, several stack. Table columns default to the related list's own column + curation minus the back-reference to the parent, cells come from the cell + registry, and a totals footer always shows the row count plus sums for any + explicitly-configured numeric columns (each formatted by that column's Cell). + Rows are fetched through the secured context, so only access-visible data shows. + Rows are read-only here — a row click navigates to the related record. + + `@opensaas/stack-core` gains additive item-view config (no breaking changes): + + ```typescript + lists: { + + fields: { + posts: relationship({ + ref: 'Post.author', + many: true, + ui: { + itemView: { + // Override the Relationship table's columns… + columns: ['title', 'status', 'viewCount'], + // …and sum numeric columns in the totals footer. + sum: ['viewCount'], + // Or demote it back to the compact picker in the details card: + // displayMode: 'picker', + }, + }, + }), + }, + // Reorder the Relationship-table sections: + ui: { itemView: { order: ['posts'] } }, + }), + } + ``` + + New `@opensaas/stack-ui` exports: `RelationshipTable`, `RelationshipTableClient`, + and the pure `deriveItemViewLayout` helper (with `ItemViewLayout`, + `ItemViewArrangement`, `RelationshipTableSection`). The Relationship table ships + named Slots (`relationship-table`, `relationship-table-toolbar`, + `relationship-table-row`, `relationship-table-cell`, `relationship-table-footer`) + as extension seams for the follow-up inline-edit, create-drawer, and row-removal + work. + +- [#774](https://github.com/OpenSaasAU/stack/pull/774) [`62a1612`](https://github.com/OpenSaasAU/stack/commit/62a16127c7b6610a35fb239911eff3486de585be) Thanks [@borisno2](https://github.com/borisno2)! - Bound the admin item-view Relationship tables with a `take` and a "showing N of M" footer + + The read-only Relationship tables on a record's edit page (issue [#734](https://github.com/OpenSaasAU/stack/issues/734)) previously + fetched every related row unbounded. They now fetch a bounded page of related rows + and surface the full access-scoped total in the footer. + + - **Bounded fetch:** each to-many Relationship table fetches at most a default cap + of related rows (`DEFAULT_ITEM_VIEW_TAKE`, 10), overridable per relationship via + `ui.itemView.take`. Rows are still fetched through the secured context, so only + access-visible rows come back. + - **"Showing N of M" footer:** the totals footer now reads `Showing N of M rows`, + where N is the rendered (bounded) count and M is the full access-scoped total, + fetched via a filtered `_count` that folds the related list's own `query` access + in (mirroring the list view's count columns). A fully-denied related list reads + `Showing 0 of 0` and never leaks a true total. The row count is always shown, + including the zero-column footer path. + + ```typescript + sessions: relationship({ + ref: 'Session.user', + many: true, + // Cap this table at 5 rows; the footer still shows the full access-scoped total. + ui: { itemView: { take: 5 } }, + }) + ``` + + Core: `mergeIncludeWithAccessControl` now preserves a caller-supplied `take` on a + to-many relation include (it only narrows the fetch, never widening past the access + `where`), so the secured `findUnique`/`findMany` include can bound related-row reads. + +- [#764](https://github.com/OpenSaasAU/stack/pull/764) [`c210319`](https://github.com/OpenSaasAU/stack/commit/c210319c3b25ff74d832d3c2ec5d3253d5d8b832) Thanks [@list({](https://github.com/list({)! - Admin list view: to-many relationship columns render an access-visible count, sort by relation count, and filter by numeric count comparisons (issue [#732](https://github.com/OpenSaasAU/stack/issues/732)). Virtual fields render via their Cell but are excluded from sorting and filtering. + + A to-many relationship used as a list column now shows the count of the related rows the session may see — fetched in the SAME query via a filtered Prisma `_count`, with the related list's `query` access folded into the count's `where`, so it never counts rows the session cannot read and issues no per-row query. Clicking the column header sorts by relation `_count`, and its Filter spec offers numeric comparisons on the count (`posts:>5`) in the filter builder and in shared URLs. + + Because Prisma cannot compare a relation count in a `where`, a to-many relationship's Filter spec emits a structured count marker that is resolved to an access-scoped `{ id: { in } }` before the query runs, through the secured context. + + New `@opensaas/stack-core` exports: `buildRelationshipCountSelect`, `resolveRelationshipCountFilters`, `isToManyRelationshipField`, and `RELATIONSHIP_COUNT_FILTER_KEY` (with the `RelationshipCountFilterMarker` type). + + ```ts + // A to-many relationship column now shows an access-scoped count and is + // sortable / filterable by that count — zero config: + + fields: { + name: text(), + posts: relationship({ ref: 'Post.author', many: true }), + }, + }) + // List view: the `posts` column renders the count; its header sorts by count; + // `posts:>5` filters by count in the builder and in a shared URL. + ``` + +### Patch Changes + +- [#773](https://github.com/OpenSaasAU/stack/pull/773) [`5a60291`](https://github.com/OpenSaasAU/stack/commit/5a602916f30535604b590b875c363f21930a109f) Thanks [@borisno2](https://github.com/borisno2)! - Harden `createRelated` server action: reject malformed calls that supply only one of `field`/`parentId`, and validate that the back-reference names a relationship field before injecting the parent connect. + +- [#775](https://github.com/OpenSaasAU/stack/pull/775) [`2fcb582`](https://github.com/OpenSaasAU/stack/commit/2fcb5820bc00d9d432265d1ba01404097e296e8e) Thanks [@borisno2](https://github.com/borisno2)! - Return a generic "Action failed" message (and log the real error server-side) when a custom bulk-action handler throws an unexpected non-Prisma error, instead of surfacing its internal message to the client + +- [#771](https://github.com/OpenSaasAU/stack/pull/771) [`85c7fc3`](https://github.com/OpenSaasAU/stack/commit/85c7fc3b3a0090a986cafa0e46b1798f237264da) Thanks [@borisno2](https://github.com/borisno2)! - Harden relationship count-filter resolution: preserve any sibling conditions co-present in a `_countFilter` marker's AND-member instead of replacing it wholesale, and document why the secured count read intentionally keeps its full projection (`context.db` does not honour `select`). + +- [#780](https://github.com/OpenSaasAU/stack/pull/780) [`55d55e0`](https://github.com/OpenSaasAU/stack/commit/55d55e0a1ed9521b6e31283524d9194a9420059a) Thanks [@borisno2](https://github.com/borisno2)! - Fix a to-one relationship filter token (e.g. `author:Ada`) leaking related-list data by ANDing the related list's `query` access filter into the nested condition instead of running it unscoped. + +- [#794](https://github.com/OpenSaasAU/stack/pull/794) [`96e1067`](https://github.com/OpenSaasAU/stack/commit/96e1067661c7ebc8e23896086fec7428e475dd03) Thanks [@borisno2](https://github.com/borisno2)! - Fix multi-column fields (e.g. storage `image()`/`file()` in Keystone-parity mode) writing an unrecognised value silently instead of failing validation. The column split now runs after `validateFieldRules`, not before, at the top-level and nested write paths. + ## 0.30.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 30db20d4..ca5aaa4c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-core", - "version": "0.30.0", + "version": "0.31.0", "description": "Core stack for OpenSaas - schema definition, access control, and runtime utilities", "type": "module", "main": "./dist/index.js", diff --git a/packages/rag/CHANGELOG.md b/packages/rag/CHANGELOG.md index 97404dff..9935dc14 100644 --- a/packages/rag/CHANGELOG.md +++ b/packages/rag/CHANGELOG.md @@ -1,5 +1,7 @@ # @opensaas/stack-rag +## 0.31.0 + ## 0.30.0 ## 0.29.0 diff --git a/packages/rag/package.json b/packages/rag/package.json index a1abc289..85371fc5 100644 --- a/packages/rag/package.json +++ b/packages/rag/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-rag", - "version": "0.30.0", + "version": "0.31.0", "description": "RAG and AI embeddings integration for OpenSaas Stack", "type": "module", "main": "./dist/index.js", diff --git a/packages/storage-s3/CHANGELOG.md b/packages/storage-s3/CHANGELOG.md index 6ae9db0c..23e5e33f 100644 --- a/packages/storage-s3/CHANGELOG.md +++ b/packages/storage-s3/CHANGELOG.md @@ -1,5 +1,7 @@ # @opensaas/stack-storage-s3 +## 0.31.0 + ## 0.30.0 ## 0.29.0 diff --git a/packages/storage-s3/package.json b/packages/storage-s3/package.json index 09779101..8075f646 100644 --- a/packages/storage-s3/package.json +++ b/packages/storage-s3/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-storage-s3", - "version": "0.30.0", + "version": "0.31.0", "description": "AWS S3 storage provider for OpenSaas Stack file uploads", "type": "module", "exports": { diff --git a/packages/storage-vercel/CHANGELOG.md b/packages/storage-vercel/CHANGELOG.md index 4daade3c..cb6f0a19 100644 --- a/packages/storage-vercel/CHANGELOG.md +++ b/packages/storage-vercel/CHANGELOG.md @@ -1,5 +1,60 @@ # @opensaas/stack-storage-vercel +## 0.31.0 + +### Minor Changes + +- [#777](https://github.com/OpenSaasAU/stack/pull/777) [`7098878`](https://github.com/OpenSaasAU/stack/commit/70988788afc311906d7541c23f68e3b04e472b63) Thanks [@borisno2](https://github.com/borisno2)! - Honour `public: false` with real private-blob support. Uploads now map `public: false` to `access: 'private'` (previously ignored); `download()` reads both public and private blobs through `@vercel/blob`'s authorized `get()` path instead of a plain `fetch(url)`, and rejects with a descriptive "File not found" error for a missing blob instead of an unrelated failure; a new `getSignedUrl(filename, expiresIn?)` returns a time-limited signed URL for serving private files through developer-controlled routes. + + ```typescript + storage: { + documents: vercelBlobStorage({ + token: process.env.BLOB_READ_WRITE_TOKEN, + public: false, + }), + } + + const provider = createStorageProvider(config, 'documents') + const signedUrl = await provider.getSignedUrl('report.pdf', 3600) + ``` + + This also bumps the `@vercel/blob` dependency from `^2.3.1` to `^2.6.1`, which adds the `issueSignedToken`/`presignUrl` APIs `getSignedUrl()` relies on. + +- [#786](https://github.com/OpenSaasAU/stack/pull/786) [`7cd3eb4`](https://github.com/OpenSaasAU/stack/commit/7cd3eb49d0f9515a26984e096c1d3bc4b9dab530) Thanks [@borisno2](https://github.com/borisno2)! - Add `allowOverwrite` config option to `vercelBlobStorage`. The Vercel Blob API rejects uploads to an existing pathname unless this is sent, which made stable-filename replace workflows (`generateUniqueFilenames: false`, e.g. a field with `cleanupOnReplace`) throw "blob already exists". `allowOverwrite` now defaults to `true` when `generateUniqueFilenames` is `false`, and `false` otherwise; an explicit setting always wins. + + ```typescript + vercelBlobStorage({ + token: process.env.BLOB_READ_WRITE_TOKEN, + generateUniqueFilenames: false, + allowOverwrite: false, // opt back into reject-on-overwrite with stable names + }) + ``` + +- [#778](https://github.com/OpenSaasAU/stack/pull/778) [`5f8fd73`](https://github.com/OpenSaasAU/stack/commit/5f8fd731d30d040438d4447c54052d97d0ec2f73) Thanks [@borisno2](https://github.com/borisno2)! - Add Vercel OIDC authentication support to the Vercel Blob storage provider + + The provider no longer requires a static read-write token. New `storeId` and `oidcToken` config options enable Vercel OIDC auth — the `@vercel/blob` SDK exchanges the deployment's `VERCEL_OIDC_TOKEN` for blob credentials automatically: + + ```typescript + storage: { + uploads: vercelBlobStorage({ + storeId: process.env.BLOB_STORE_ID, // or omit and just set BLOB_STORE_ID + pathPrefix: 'uploads', + }), + } + ``` + + Credential precedence (resolved by the SDK on every call): explicit `token` → OIDC token (`oidcToken` or `VERCEL_OIDC_TOKEN`) plus store id (`storeId` or `BLOB_STORE_ID`) → `BLOB_READ_WRITE_TOKEN` environment variable. + + The constructor no longer throws when no static token is configured — previously this blocked OIDC-authenticated deployments before the SDK's credential resolution could run. When no credentials are available at all, the SDK now throws a descriptive error on the first storage operation instead. Requires `@vercel/blob` 2.4.1+ (dependency floor bumped from 2.3.1). + +### Patch Changes + +- [#779](https://github.com/OpenSaasAU/stack/pull/779) [`4a1d9be`](https://github.com/OpenSaasAU/stack/commit/4a1d9be5aee15691e0d8a7c5b3d16c802e62bc84) Thanks [@borisno2](https://github.com/borisno2)! - Fix `getUrl()` returning a fabricated host that never resolves. It now derives the store ID from the configured token/store ID (the same way the SDK does internally) and embeds it along with the access mode, matching the real URL the SDK returns at upload time. A token that doesn't yield a store ID now throws a descriptive error instead of producing a silently wrong URL. + +- [#792](https://github.com/OpenSaasAU/stack/pull/792) [`fe1ed5c`](https://github.com/OpenSaasAU/stack/commit/fe1ed5cd2226ea59b2b0b15e4c5f89379c98df2e) Thanks [@borisno2](https://github.com/borisno2)! - Fix unique filename generation to use `path.extname` semantics (matching the local provider) so extension-less originals no longer get the whole name appended, and pass through an explicit `cacheControlMaxAge: 0` instead of dropping it. + +- [#776](https://github.com/OpenSaasAU/stack/pull/776) [`030d540`](https://github.com/OpenSaasAU/stack/commit/030d540cfb1fc4495da5177cda0bc1915c0cb354) Thanks [@borisno2](https://github.com/borisno2)! - Fix `delete()`/`download()` for `@vercel/blob` v2, which rejects `head()` with `BlobNotFoundError` for missing blobs instead of resolving `null`. `delete()` of a missing blob is now a no-op and no longer round-trips through `head()`. + ## 0.30.0 ## 0.29.0 diff --git a/packages/storage-vercel/package.json b/packages/storage-vercel/package.json index 63002a0e..22169e2a 100644 --- a/packages/storage-vercel/package.json +++ b/packages/storage-vercel/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-storage-vercel", - "version": "0.30.0", + "version": "0.31.0", "description": "Vercel Blob storage provider for OpenSaas Stack file uploads", "type": "module", "exports": { diff --git a/packages/storage/CHANGELOG.md b/packages/storage/CHANGELOG.md index ede1e87a..b89ac356 100644 --- a/packages/storage/CHANGELOG.md +++ b/packages/storage/CHANGELOG.md @@ -1,5 +1,13 @@ # @opensaas/stack-storage +## 0.31.0 + +### Patch Changes + +- [#795](https://github.com/OpenSaasAU/stack/pull/795) [`0e603b9`](https://github.com/OpenSaasAU/stack/commit/0e603b92fd93611c3e7e614c4bc213d1eae1f926) Thanks [@borisno2](https://github.com/borisno2)! - Add integration-style tests proving image()/file() reject unrecognised write value shapes end-to-end in both `db.columns: 'keystone'` (multi-column) and default single-column (JSON) modes, pinning the [#789](https://github.com/OpenSaasAU/stack/issues/789) fix as a regression guard. + +- [#776](https://github.com/OpenSaasAU/stack/pull/776) [`030d540`](https://github.com/OpenSaasAU/stack/commit/030d540cfb1fc4495da5177cda0bc1915c0cb354) Thanks [@borisno2](https://github.com/borisno2)! - `StorageProvider.delete()` is now documented as idempotent; the local provider swallows `ENOENT` on delete instead of throwing. + ## 0.30.0 ## 0.29.0 diff --git a/packages/storage/package.json b/packages/storage/package.json index af0885ae..2d8b6537 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-storage", - "version": "0.30.0", + "version": "0.31.0", "description": "File and image upload field types with pluggable storage providers for OpenSaas Stack", "type": "module", "exports": { diff --git a/packages/tiptap/CHANGELOG.md b/packages/tiptap/CHANGELOG.md index bfe4a43c..468484f5 100644 --- a/packages/tiptap/CHANGELOG.md +++ b/packages/tiptap/CHANGELOG.md @@ -1,5 +1,11 @@ # @opensaas/stack-tiptap +## 0.31.0 + +### Patch Changes + +- [#781](https://github.com/OpenSaasAU/stack/pull/781) [`db7079a`](https://github.com/OpenSaasAU/stack/commit/db7079a0f379cf395174a33422e1cacda1fc075c) Thanks [@borisno2](https://github.com/borisno2)! - Bump `@tiptap/pm` and `@tiptap/extension-placeholder` to `^3.28.0` to match `@tiptap/starter-kit`, fixing a TypeScript build failure caused by duplicate `@tiptap/pm` versions in the dependency tree. + ## 0.30.0 ## 0.29.0 diff --git a/packages/tiptap/package.json b/packages/tiptap/package.json index a8f1158f..5523ee0c 100644 --- a/packages/tiptap/package.json +++ b/packages/tiptap/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-tiptap", - "version": "0.30.0", + "version": "0.31.0", "description": "Tiptap rich text editor integration for OpenSaas Stack", "type": "module", "main": "./dist/index.js", diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 459eb35e..822986bc 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,5 +1,441 @@ # @opensaas/stack-ui +## 0.31.0 + +### Minor Changes + +- [#755](https://github.com/OpenSaasAU/stack/pull/755) [`9cd06dd`](https://github.com/OpenSaasAU/stack/commit/9cd06dddb45512966affc3a6b3455e97595c0de2) Thanks [@list({](https://github.com/list({)! - Admin chrome polish: opt-in nav counts and avatar label cells ([#735](https://github.com/OpenSaasAU/stack/issues/735)) + + Two per-list opt-ins for the admin UI, both off by default. + + **Nav counts** — set `ui.navCount: true` on a list to show an access-scoped + record count next to its nav item. The count is fetched through the secured + context, so it only ever reflects what the current session may see; no count + query runs for lists that don't opt in, and a list whose query access is + statically denied renders no count rather than a misleading zero. + + ```typescript + lists: { + Post: list({ + fields: { + /* ... */ + }, + ui: { navCount: true }, + }), + } + ``` + + **Avatar label cells** — set `ui.avatar: true` to render a list's label column + with a deterministic initials bubble ahead of the emphasized Item label. The + initials and colour derive from the row; the palette is Theme-token-derived (no + raw hex). A per-field cell override (`ui.cell`) on the label field still wins. + + ```typescript + lists: { + + fields: { + /* ... */ + }, + ui: { avatar: true }, + }), + } + ``` + + New exports: + + - `@opensaas/stack-core`: `resolveNavCounts`, `isListQueryStaticallyDenied` + - `@opensaas/stack-ui`: `Avatar` primitive, `AvatarLabelCell`, and the + `getInitials`, `getAvatarTone`, `AVATAR_TONES` helpers. New Slots: + `avatar`, `cell-avatar-label`, `nav-count`. + +- [#759](https://github.com/OpenSaasAU/stack/pull/759) [`b190813`](https://github.com/OpenSaasAU/stack/commit/b190813a4531bd01b3206845b2c531099e0a204a) Thanks [@borisno2](https://github.com/borisno2)! - Add custom Bulk actions from list config (admin list view) + + A list can now declare list-specific Bulk actions under `ui.listView.bulkActions`. Each action's button renders in the list view's selection bar (in declaration order) alongside the built-in Delete. The action's server-side `handler` receives the selected ids and the secured context, so all its work runs through access control and hooks — a denied row is a Silent failure absorbed into the outcome, never leaked. + + ```typescript + Post: list({ + fields: { title: text(), status: select({ options: [/* ... */] }) }, + ui: { + listView: { + bulkActions: [ + { + key: 'publish', + label: 'Publish', + // Optional: `variant`, `destructive` (confirm first), + // `hasAccess` (server-side visibility gate). + handler: async ({ ids, context }) => { + let n = 0 + for (const id of ids) { + const updated = await context.db.post.update({ + where: { id }, + data: { status: 'published' }, + }) + if (updated) n++ + } + return { message: `Published ${n} of ${ids.length}` } + }, + }, + ], + }, + }, + }) + ``` + + Only serialisable metadata (`key`/`label`/`variant`/`destructive`) crosses to the client; the `handler`/`hasAccess` functions stay on the server. Clicking the button sends the `key` and selected ids back through the generic server action, which looks the handler up and runs it with a freshly-rebuilt secured context. Selection is enabled for a list that has custom actions even when Delete is denied. CSV export is documented as a recipe using this surface rather than shipping as a built-in. + +- [#754](https://github.com/OpenSaasAU/stack/pull/754) [`f67cd79`](https://github.com/OpenSaasAU/stack/commit/f67cd798724712a90d7ada8f28202d3d6371693f) Thanks [@borisno2](https://github.com/borisno2)! - Add the Filter builder input UI for the admin list view ([#731](https://github.com/OpenSaasAU/stack/issues/731)) + + The admin list view now ships a `FilterBuilder` that constructs the `?search=` + filter query the filter engine already consumes (ADR-0017) — a free-text search + box plus structured field / operator / value rows. Available fields, operators, + and value suggestions are derived entirely from each field's self-contained + `getFilterSpec` (via the serializable `collectFilterSuggestions` metadata), so + there is no field-type `switch` and no functions cross the server/client + boundary. Applied filters flow through the same secured `context.db`, so + filtering can only ever narrow what a session may see. + + `@opensaas/stack-core` gains `serializeFilterQuery(tokens)` — the exact inverse + of `parseFilterQuery` — so the builder produces the grammar the engine parses + with the quoting and operator-prefix rules kept next to the parser. + + The `FilterBuilder` is composable (exported from `@opensaas/stack-ui` and + `@opensaas/stack-ui/standalone`) with theme-token styling and `data-slot` parts + for extension: + + ```tsx + import { FilterBuilder } from '@opensaas/stack-ui/standalone' + import { collectFilterSuggestions } from '@opensaas/stack-core' + + // Server component: collect serializable suggestion metadata for the list. + const suggestions = collectFilterSuggestions(listConfig, 'Post', config) + + // Client: build and apply a `?search=` query. + router.push(`/admin/post?search=${encodeURIComponent(query)}`)} + /> + ``` + + The list view wires this in automatically; existing `?search=` URLs keep + working unchanged. + +- [#746](https://github.com/OpenSaasAU/stack/pull/746) [`dcb10e2`](https://github.com/OpenSaasAU/stack/commit/dcb10e27c28a8a8f9a5e625f550ac5c750436eb6) Thanks [@borisno2](https://github.com/borisno2)! - Add the admin UI filter engine: a Filter spec field-builder contract and URL-driven server-side list filtering (ADR-0017). + + Fields now declare their filtering capability through a new optional `getFilterSpec` method — a peer of `getPrismaType`/`getTypeScriptType` on the field-builder contract. It reports the operators a field supports, a pure token→condition mapper, and serializable suggestion metadata. Core field types implement it (text contains + free text, integer/decimal/timestamp/calendarDay comparisons, select/checkbox equality against enumerated values, relationship by label lookup). A field without a spec — `password`, `json`, `virtual`, or any third-party field that hasn't adopted one — is simply not filterable, so the addition degrades gracefully everywhere. + + The admin list view now parses the URL filter query (the list's `search` param) through the engine and merges the result into the access-controlled query via the secured context, so filtering runs server-side and can only ever narrow — never widen — what a session may see. This replaces the previous hard-coded `type === 'text'` search; free-text behavior is now driven by each text field's Filter spec. + + Grammar (ADR-0017): implicit-AND tokens, quoted multi-word values, `>`/`>=`/`<`/`<=` comparisons on numeric/date fields, and bare words as free text. Unknown syntax degrades to free text, never errors. + + Multi-word free-text UX shift (intentional, per ADR-0017): bare words now combine with AND, so `hello world` requires each word to match separately (not the literal substring `hello world`). To match a contiguous phrase, quote it: `"hello world"`. A pasted URL such as `http://x` is treated as a single free-text token and searched verbatim — the `http:` prefix is not parsed as a field. + + New exports from `@opensaas/stack-core`: + + ```typescript + import { + parseFilterQuery, // (query) => FilterToken[] — pure + buildFilterWhere, // (tokens, specs) => where — pure + collectFilterSpecs, // (listConfig, listKey, config) => specs + buildListFilterWhere, // (query, listConfig, listKey, config) => where + collectFilterSuggestions, // serializable autocomplete metadata + } from '@opensaas/stack-core' + + // e.g. "status:Published views:>10 author:\"Ada Lovelace\" beta" + const where = buildListFilterWhere(query, listConfig, listKey, config) + const rows = await context.db.post.findMany({ where }) // ANDed with the access filter + ``` + + Third-party field authors can implement `FilterSpec` (exported from `@opensaas/stack-core/extend`) to make their field filterable. + +- [#760](https://github.com/OpenSaasAU/stack/pull/760) [`f8b6f02`](https://github.com/OpenSaasAU/stack/commit/f8b6f02c18322d0d04a7c3cc82e579d0ba9a2da9) Thanks [@borisno2](https://github.com/borisno2)! - Add inline cell editing to admin Relationship tables + + Cells in a to-many Relationship table on the item view are now editable in place. + Click a cell to edit it, commit with Enter or blur, cancel with Escape. Each + commit is a single-field update on the **related** row through the secured + context, so the related list's own operation- and field-level update access plus + its hooks/validation apply — never the parent's. The update is optimistic and + reverts, with a visible reason, on a Silent failure (access denied / row gone) or + a validation error (inline field errors surface too). Committed values re-render + through the Cell registry, so select cells stay coloured badges. + + A field the session cannot write — or a table whose related-list update access is + statically denied — renders read-only with no edit affordance; row-level + (filter-scoped) denials surface at commit as a revert. Non-editable cells keep + click-to-navigate; main list tables are unchanged (this is Relationship-table + only). + + - `@opensaas/stack-core`: the generic server action gains a distinct + `updateRelated` result shape (`{ updated, error?, fieldErrors? }`), and + `checkFieldAccess` is exposed on `@opensaas/stack-core/internal` so the UI can + decide the edit affordance without a parallel field-access evaluator. + - `@opensaas/stack-ui`: `RelationshipTableClient` accepts `editableColumns`; the + editable cell reuses the field-component registry for its editor and the Cell + registry for its display (new Slots: `relationship-table-cell-display`, + `relationship-table-cell-editor`, `relationship-table-cell-edit-trigger`, + `relationship-table-cell-error`). + +- [#757](https://github.com/OpenSaasAU/stack/pull/757) [`c05701e`](https://github.com/OpenSaasAU/stack/commit/c05701e523815b8f411a6d39e57bbb9317dc2a9d) Thanks [@borisno2](https://github.com/borisno2)! - Add a pre-linked create drawer to read-only Relationship tables (issue [#738](https://github.com/OpenSaasAU/stack/issues/738)) + + The item view's read-only Relationship tables now offer a "+ Add" control that + opens a drawer hosting the related list's create form, with the back-reference to + the current record preset and hidden. On submit the new row is created through + the secured context already linked to the parent, then the drawer closes and the + table refreshes. + + Create-and-link semantics (ADR-0018): the create runs on the RELATED list, so + the related list's own `create` access control, hooks, and field-level access + apply — never the parent's. The back-reference is set on the server from the + field/parent id (never trusted from the client payload). The "+ Add" is shown + only when a back-reference exists to preset the link and the related list's + `create` access is not statically denied; a filter/function-scoped denial + surfaces at commit time as a generic error (no denied-vs-absent leak). + + New generic server action (`@opensaas/stack-core`): + + ```ts + await context.serverAction({ + listKey: 'Post', // the RELATED list + action: 'createRelated', + data: { title: 'Hello', slug: 'hello' }, + field: 'author', // the back-reference field on Post + parentId: user.id, // the record being edited + }) + // → { created: true, id } | { created: false, error?, fieldErrors? } + ``` + + The drawer (`RelationshipCreateDrawer` from `@opensaas/stack-ui`) mounts on the + existing `relationship-table-toolbar` seam and reuses the shared item-form engine + and field-component registry, so the related list's full validation and required + fields are enforced even when a required field is not one of the table's columns. + +- [#756](https://github.com/OpenSaasAU/stack/pull/756) [`8199238`](https://github.com/OpenSaasAU/stack/commit/81992382290f356071955f16efd14f7771045a16) Thanks [@list({](https://github.com/list({)! - Add relationship-table row removal to the admin item view (ADR-0018) + + Each read-only Relationship table row now has a ✕ removal control. By default it + **disconnects** the related row from the current record (non-destructive — the + row survives and still appears on its own list), gated on the related list's + update access. A per-relationship opt-in truly deletes the related row (behind a + confirmation, gated on the related list's delete access), or hides the control + entirely. Where the schema makes disconnect impossible (a required foreign key on + the related side) the control is hidden unless delete is opted in. Removals run + through the secured context, so an access-denied removal is a Silent failure: the + row stays with a visible reason. + + Configure per relationship via `ui.itemView.removeAction`: + + ```typescript + + fields: { + // Default: ✕ disconnects the post (it still exists). + posts: relationship({ ref: 'Post.author', many: true }), + // Opt in to destructive delete (confirmed). + notes: relationship({ + ref: 'Note.owner', + many: true, + ui: { itemView: { removeAction: 'delete' } }, // 'disconnect' (default) | 'delete' | 'none' + }), + }, + }) + ``` + + `@opensaas/stack-core` adds a `removeRelated` server action (distinct + `{ removed }` result shape, like `bulkDelete`, so a redirect-on-success wrapper + never hijacks an in-place removal) and the `RelationshipItemViewConfig.removeAction` + option. + +- [#745](https://github.com/OpenSaasAU/stack/pull/745) [`4d99e91`](https://github.com/OpenSaasAU/stack/commit/4d99e910b61c6196564a7248abf3d32b1d6be883) Thanks [@borisno2](https://github.com/borisno2)! - Add a Cell registry with default cells for core field types + + List tables now render every value through a **Cell** resolved by a + cell-component registry that mirrors the form-field registry's priority chain: + per-field override → custom type registry → field-type registry → plain-text + fallback. Each core field type ships a default Cell — text (plain), integer + (tabular figures), select (coloured Badge), timestamp (formatted date), checkbox + (mark), and to-one relationship (Item label link). Unknown/third-party types + without a registered Cell fall back to plain text. + + Select options gain optional, additive per-option UI metadata mapping a value to + a badge variant. Existing options keep working unchanged; unmapped options render + the neutral badge. + + ```typescript + // opensaas.config.ts — colour a status value in list-table cells + status: select({ + options: [ + { label: 'Draft', value: 'draft', ui: { variant: 'secondary' } }, + { label: 'Published', value: 'published', ui: { variant: 'success' } }, + ], + }) + ``` + + Register a Cell for a custom/third-party field exactly as you register its form + component, or override a single field's Cell: + + ```typescript + 'use client' + import { registerCellComponent } from '@opensaas/stack-ui' + registerCellComponent('myField', MyCell) + + // or per-field override (highest priority) + price: integer({ ui: { cell: CurrencyCell } }) + ``` + +- [#750](https://github.com/OpenSaasAU/stack/pull/750) [`047487a`](https://github.com/OpenSaasAU/stack/commit/047487adf502f10f7f6774ff52c38c70d465f533) Thanks [@borisno2](https://github.com/borisno2)! - Add row selection and a built-in Bulk action Delete to the admin list view + + The list table now renders a selection checkbox column when the list's delete + access is not statically false. The header checkbox toggles the visible page, + per-row checkboxes accumulate an explicit id set across pages, and the selection + clears when the filter changes. A selection bar shows the count, a Clear action, + a named `data-slot="selection-actions"` seam for future custom bulk actions, and + — only when delete access allows — a Delete that confirms first, deletes each + selected row through the secured context honouring Silent failure, and reports + "N of M deleted" (partial access denials are visible without revealing which or + why). + + The admin list view also honours an optional `?pageSize=` URL param, preserved + across sorting, searching and paging. + + New exports: `RowSelectionBar` (with `RowSelectionBarProps` / + `RowSelectionBarClassNames`) and the `useRowSelection` hook plus the pure + `isPageFullySelected` / `getPageCheckboxState` helpers. + + ```tsx + import { RowSelectionBar, useRowSelection } from '@opensaas/stack-ui' + + const selection = useRowSelection('Post', filterKey) + { + /* delete the selected ids through the secured context */ + }} + /> + ``` + +- [#751](https://github.com/OpenSaasAU/stack/pull/751) [`20459b5`](https://github.com/OpenSaasAU/stack/commit/20459b5a7f8b2578342509442d36017cfa2f08f6) Thanks [@list({](https://github.com/list({)! - Derive the admin item view from the list shape, with read-only Relationship tables and a totals footer ([#734](https://github.com/OpenSaasAU/stack/issues/734)) + + A record's edit page now derives its layout from the list's shape. Scalar and + to-one fields stay in a details card (whole-form Save/Cancel, unchanged), and + each to-many relationship renders as a read-only **Relationship table**: one + to-many relationship gives a two-column split, none gives a single centered + card, several stack. Table columns default to the related list's own column + curation minus the back-reference to the parent, cells come from the cell + registry, and a totals footer always shows the row count plus sums for any + explicitly-configured numeric columns (each formatted by that column's Cell). + Rows are fetched through the secured context, so only access-visible data shows. + Rows are read-only here — a row click navigates to the related record. + + `@opensaas/stack-core` gains additive item-view config (no breaking changes): + + ```typescript + lists: { + + fields: { + posts: relationship({ + ref: 'Post.author', + many: true, + ui: { + itemView: { + // Override the Relationship table's columns… + columns: ['title', 'status', 'viewCount'], + // …and sum numeric columns in the totals footer. + sum: ['viewCount'], + // Or demote it back to the compact picker in the details card: + // displayMode: 'picker', + }, + }, + }), + }, + // Reorder the Relationship-table sections: + ui: { itemView: { order: ['posts'] } }, + }), + } + ``` + + New `@opensaas/stack-ui` exports: `RelationshipTable`, `RelationshipTableClient`, + and the pure `deriveItemViewLayout` helper (with `ItemViewLayout`, + `ItemViewArrangement`, `RelationshipTableSection`). The Relationship table ships + named Slots (`relationship-table`, `relationship-table-toolbar`, + `relationship-table-row`, `relationship-table-cell`, `relationship-table-footer`) + as extension seams for the follow-up inline-edit, create-drawer, and row-removal + work. + +- [#774](https://github.com/OpenSaasAU/stack/pull/774) [`62a1612`](https://github.com/OpenSaasAU/stack/commit/62a16127c7b6610a35fb239911eff3486de585be) Thanks [@borisno2](https://github.com/borisno2)! - Bound the admin item-view Relationship tables with a `take` and a "showing N of M" footer + + The read-only Relationship tables on a record's edit page (issue [#734](https://github.com/OpenSaasAU/stack/issues/734)) previously + fetched every related row unbounded. They now fetch a bounded page of related rows + and surface the full access-scoped total in the footer. + + - **Bounded fetch:** each to-many Relationship table fetches at most a default cap + of related rows (`DEFAULT_ITEM_VIEW_TAKE`, 10), overridable per relationship via + `ui.itemView.take`. Rows are still fetched through the secured context, so only + access-visible rows come back. + - **"Showing N of M" footer:** the totals footer now reads `Showing N of M rows`, + where N is the rendered (bounded) count and M is the full access-scoped total, + fetched via a filtered `_count` that folds the related list's own `query` access + in (mirroring the list view's count columns). A fully-denied related list reads + `Showing 0 of 0` and never leaks a true total. The row count is always shown, + including the zero-column footer path. + + ```typescript + sessions: relationship({ + ref: 'Session.user', + many: true, + // Cap this table at 5 rows; the footer still shows the full access-scoped total. + ui: { itemView: { take: 5 } }, + }) + ``` + + Core: `mergeIncludeWithAccessControl` now preserves a caller-supplied `take` on a + to-many relation include (it only narrows the fetch, never widening past the access + `where`), so the secured `findUnique`/`findMany` include can bound related-row reads. + +- [#764](https://github.com/OpenSaasAU/stack/pull/764) [`c210319`](https://github.com/OpenSaasAU/stack/commit/c210319c3b25ff74d832d3c2ec5d3253d5d8b832) Thanks [@list({](https://github.com/list({)! - Admin list view: to-many relationship columns render an access-visible count, sort by relation count, and filter by numeric count comparisons (issue [#732](https://github.com/OpenSaasAU/stack/issues/732)). Virtual fields render via their Cell but are excluded from sorting and filtering. + + A to-many relationship used as a list column now shows the count of the related rows the session may see — fetched in the SAME query via a filtered Prisma `_count`, with the related list's `query` access folded into the count's `where`, so it never counts rows the session cannot read and issues no per-row query. Clicking the column header sorts by relation `_count`, and its Filter spec offers numeric comparisons on the count (`posts:>5`) in the filter builder and in shared URLs. + + Because Prisma cannot compare a relation count in a `where`, a to-many relationship's Filter spec emits a structured count marker that is resolved to an access-scoped `{ id: { in } }` before the query runs, through the secured context. + + New `@opensaas/stack-core` exports: `buildRelationshipCountSelect`, `resolveRelationshipCountFilters`, `isToManyRelationshipField`, and `RELATIONSHIP_COUNT_FILTER_KEY` (with the `RelationshipCountFilterMarker` type). + + ```ts + // A to-many relationship column now shows an access-scoped count and is + // sortable / filterable by that count — zero config: + + fields: { + name: text(), + posts: relationship({ ref: 'Post.author', many: true }), + }, + }) + // List view: the `posts` column renders the count; its header sorts by count; + // `posts:>5` filters by count in the builder and in a shared URL. + ``` + +- [#788](https://github.com/OpenSaasAU/stack/pull/788) [`613902c`](https://github.com/OpenSaasAU/stack/commit/613902c13e092f29939618f87c6d3dfeac74a60d) Thanks [@borisno2](https://github.com/borisno2)! - Standalone `ListTable` now routes every cell through the shared cell registry (`CellRenderer`), matching `ListView`. The bespoke relationship renderer and `fieldTypes[column] === 'relationship'` branch are gone in favour of `RelationshipCell`, which already handles link navigation and `stopPropagation`. + + A new optional `fieldOptions` prop lets `select` columns resolve label mapping and `ui.variant` badge colour, exactly like `ListView`: + + ```tsx + + ``` + + Existing `ListTable` call sites keep working unchanged. + +### Patch Changes + +- [#780](https://github.com/OpenSaasAU/stack/pull/780) [`55d55e0`](https://github.com/OpenSaasAU/stack/commit/55d55e0a1ed9521b6e31283524d9194a9420059a) Thanks [@borisno2](https://github.com/borisno2)! - Fix a to-one relationship filter token (e.g. `author:Ada`) leaking related-list data by ANDing the related list's `query` access filter into the nested condition instead of running it unscoped. + +- [#775](https://github.com/OpenSaasAU/stack/pull/775) [`2fcb582`](https://github.com/OpenSaasAU/stack/commit/2fcb5820bc00d9d432265d1ba01404097e296e8e) Thanks [@borisno2](https://github.com/borisno2)! - Exclude json columns from relationship-table inline editing so an unchanged json cell no longer wastes a secured update round-trip + ## 0.30.0 ## 0.29.0 diff --git a/packages/ui/package.json b/packages/ui/package.json index a2f736af..b0fa0198 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opensaas/stack-ui", - "version": "0.30.0", + "version": "0.31.0", "description": "Composable React UI components for OpenSaas Stack", "type": "module", "main": "./dist/index.js",