@opensaas/stack-ui 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +95 -0
  3. package/CLAUDE.md +46 -9
  4. package/README.md +41 -10
  5. package/dist/components/AdminUI.d.ts +1 -1
  6. package/dist/components/AdminUI.d.ts.map +1 -1
  7. package/dist/components/AdminUI.js +23 -3
  8. package/dist/components/Dashboard.d.ts.map +1 -1
  9. package/dist/components/Dashboard.js +13 -4
  10. package/dist/components/ItemForm.d.ts.map +1 -1
  11. package/dist/components/ItemForm.js +6 -65
  12. package/dist/components/ItemFormClient.d.ts +8 -1
  13. package/dist/components/ItemFormClient.d.ts.map +1 -1
  14. package/dist/components/ItemFormClient.js +2 -2
  15. package/dist/components/ListView.d.ts +14 -1
  16. package/dist/components/ListView.d.ts.map +1 -1
  17. package/dist/components/ListView.js +2 -2
  18. package/dist/components/ListViewClient.d.ts +10 -1
  19. package/dist/components/ListViewClient.d.ts.map +1 -1
  20. package/dist/components/ListViewClient.js +3 -3
  21. package/dist/components/Navigation.d.ts.map +1 -1
  22. package/dist/components/Navigation.js +12 -1
  23. package/dist/components/SingletonView.d.ts +37 -0
  24. package/dist/components/SingletonView.d.ts.map +1 -0
  25. package/dist/components/SingletonView.js +82 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/lib/operationAccess.d.ts +34 -0
  30. package/dist/lib/operationAccess.d.ts.map +1 -0
  31. package/dist/lib/operationAccess.js +43 -0
  32. package/dist/lib/prepareItemForm.d.ts +35 -0
  33. package/dist/lib/prepareItemForm.d.ts.map +1 -0
  34. package/dist/lib/prepareItemForm.js +85 -0
  35. package/dist/styles/globals.css +12 -0
  36. package/package.json +2 -2
  37. package/src/components/AdminUI.tsx +36 -2
  38. package/src/components/Dashboard.tsx +108 -5
  39. package/src/components/ItemForm.tsx +11 -77
  40. package/src/components/ItemFormClient.tsx +10 -2
  41. package/src/components/ListView.tsx +16 -0
  42. package/src/components/ListViewClient.tsx +9 -2
  43. package/src/components/Navigation.tsx +58 -1
  44. package/src/components/SingletonView.tsx +228 -0
  45. package/src/index.ts +2 -0
  46. package/src/lib/operationAccess.ts +53 -0
  47. package/src/lib/prepareItemForm.ts +121 -0
  48. package/tests/components/AdminUIListView.test.tsx +134 -0
  49. package/tests/components/AdminUISingleton.test.tsx +296 -0
  50. package/tests/components/AdminUISingletonSuppress.test.tsx +259 -0
  51. package/tests/components/ListViewClient.test.tsx +60 -0
  52. package/tests/components/SingletonNavDashboard.test.tsx +141 -0
@@ -1,11 +1,11 @@
1
1
 
2
- > @opensaas/stack-ui@0.22.0 build /home/runner/work/stack/stack/packages/ui
2
+ > @opensaas/stack-ui@0.24.0 build /home/runner/work/stack/stack/packages/ui
3
3
  > tsc && npm run build:css
4
4
 
5
5
  npm warn Unknown env config "verify-deps-before-run". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
6
6
  npm warn Unknown env config "npm-globalconfig". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
7
7
  npm warn Unknown env config "_jsr-registry". This will stop working in the next major version of npm. See `npm help npmrc` for supported config options.
8
8
 
9
- > @opensaas/stack-ui@0.22.0 build:css
9
+ > @opensaas/stack-ui@0.24.0 build:css
10
10
  > mkdir -p dist/styles && postcss ./src/styles/globals.css -o ./dist/styles/globals.css
11
11
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,100 @@
1
1
  # @opensaas/stack-ui
2
2
 
3
+ ## 0.24.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#552](https://github.com/OpenSaasAU/stack/pull/552) [`66496b4`](https://github.com/OpenSaasAU/stack/commit/66496b487bae61f3cdea26fcfcaf605caaaa5520) Thanks [@borisno2](https://github.com/borisno2)! - Add list-level `ui.listView` config (mirroring Keystone) for default columns and sort
8
+
9
+ Lists now support a `ui.listView` block in `opensaas.config.ts` that sets the
10
+ admin list table's default column selection/order and default sort. Naming
11
+ mirrors Keystone's `ui.listView` so migrators can map defaults directly.
12
+
13
+ ```typescript
14
+ lists: {
15
+ Post: list({
16
+ fields: {
17
+ title: text(),
18
+ status: text(),
19
+ createdAt: timestamp(),
20
+ },
21
+ ui: {
22
+ listView: {
23
+ // Column selection AND order
24
+ initialColumns: ['title', 'status'],
25
+ // Default sort
26
+ initialSort: { field: 'createdAt', direction: 'desc' },
27
+ },
28
+ },
29
+ }),
30
+ }
31
+ ```
32
+
33
+ When `ui.listView` is absent, behaviour is unchanged: the table shows all
34
+ non-system fields and applies no default sort.
35
+
36
+ ## 0.23.0
37
+
38
+ ### Minor Changes
39
+
40
+ - [#543](https://github.com/OpenSaasAU/stack/pull/543) [`4de6a3b`](https://github.com/OpenSaasAU/stack/commit/4de6a3b35ff2337fbd32f285e6c0cc63a0b2d2cf) Thanks [@borisno2](https://github.com/borisno2)! - Handle `autoCreate: false` singletons and access-denied reads in the AdminUI singleton editor.
41
+
42
+ When a singleton's `get()` returns no record, `SingletonView` now disambiguates the two reasons a singleton can be empty and renders the safe affordance:
43
+ - **`autoCreate: false` with no row yet** (query + create allowed): renders a create-on-first-save form (reuses `ItemFormClient` in `mode="create"`). Core assigns the singleton `id` and enforces the single-record constraint on save, so the form sends only the user-entered field data.
44
+ - **`query` access denied**: renders a friendly "no access" message — never an editable or create form.
45
+ - **create denied (autoCreate: false, no row)**: renders a friendly "no record yet" message instead of an unusable form.
46
+
47
+ An update-denied singleton still renders the edit form, but the save fails gracefully via the server action's denied envelope. The happy path (a record exists → edit form) and non-singleton lists are unchanged.
48
+
49
+ - [#542](https://github.com/OpenSaasAU/stack/pull/542) [`ef6ce9a`](https://github.com/OpenSaasAU/stack/commit/ef6ce9a3d9c8c129626d98640004c2c0bf84b656) Thanks [@borisno2](https://github.com/borisno2)! - Render a single-record editor for `isSingleton` lists in `AdminUI`
50
+
51
+ A list configured with `isSingleton: true` now renders a single-record editor at
52
+ its bare `[list]` route instead of a list table. The new `SingletonView`
53
+ component resolves the record via the singleton `get()` operation (which
54
+ auto-creates the row with field defaults when absent) and reuses the existing
55
+ `ItemFormClient` in edit mode, so field rendering, validation, and the existing
56
+ `serverAction` save path all apply unchanged. Non-singleton lists are
57
+ unaffected and still render the table.
58
+
59
+ ```typescript
60
+ // opensaas.config.ts
61
+ lists: {
62
+ SiteSettings: list({
63
+ isSingleton: true,
64
+ fields: {
65
+ siteName: text(),
66
+ supportEmail: text(),
67
+ },
68
+ }),
69
+ }
70
+ ```
71
+
72
+ Visiting `/admin/site-settings` now shows an "Edit Site Settings" form for the
73
+ single record rather than a one-row list.
74
+
75
+ - [#544](https://github.com/OpenSaasAU/stack/pull/544) [`581ef89`](https://github.com/OpenSaasAU/stack/commit/581ef89975e41a359b0a92c4808fbcdee7fe1607) Thanks [@borisno2](https://github.com/borisno2)! - Add first-class singleton presentation to the admin Navigation and Dashboard
76
+
77
+ Singleton lists (`isSingleton`) are now visually distinguished from ordinary lists:
78
+ - **Navigation:** singletons render under a dedicated "Settings" group with a gear
79
+ icon, separate from the standard "Lists" group. Each still links to its
80
+ single-record editor (`/<basePath>/<url>`). The "Settings" group is omitted when
81
+ there are no singletons (and the "Lists" group is omitted when there are only
82
+ singletons).
83
+ - **Dashboard:** singletons appear in their own "Settings" section with a
84
+ "Configure" affordance instead of the misleading "N items" count (a singleton's
85
+ count is always 0 or 1). The Dashboard no longer calls `count()` for singletons.
86
+
87
+ Non-singleton lists are unchanged.
88
+
89
+ - [#545](https://github.com/OpenSaasAU/stack/pull/545) [`f2cc754`](https://github.com/OpenSaasAU/stack/commit/f2cc754e34b07a427168ddb11cfc33d74457af82) Thanks [@borisno2](https://github.com/borisno2)! - Suppress create/delete affordances and redirect sub-routes for singleton lists in the admin UI.
90
+
91
+ Singleton lists (`isSingleton: true`) have a single record edited at their bare `[list]` route, so the create and delete affordances no longer apply:
92
+ - The Dashboard "Quick Actions" no longer renders a "Create {list}" link for singletons (only standard lists). The Quick Actions card is hidden entirely in a singleton-only admin.
93
+ - The singleton editor (`SingletonView`) no longer renders a Delete control. A new optional `canDelete` prop (default `true`) on `ItemFormClient` controls this; non-singleton edit forms keep their Delete button.
94
+ - The singleton sub-routes `/admin/<list>/create` and `/admin/<list>/<id>` now server-side `redirect()` to the bare editor `/admin/<list>`, so old links keep working.
95
+
96
+ Non-singleton create/delete affordances and routing are unchanged.
97
+
3
98
  ## 0.22.0
4
99
 
5
100
  ## 0.21.0
package/CLAUDE.md CHANGED
@@ -50,7 +50,14 @@ Composable CRUD components:
50
50
 
51
51
  ### Server (`/server`)
52
52
 
53
- - `getAdminContext(headers)` - Get context with session from request headers
53
+ Type-only re-exports for wiring the admin's server action (no runtime exports):
54
+
55
+ - `ServerActionInput` - Props passed to the generic server action (re-exported from stack-core)
56
+ - `ActionResult<T>` - Result shape returned by a server action
57
+
58
+ The host app builds the access-scoped `context` itself (from the generated
59
+ `.opensaas/context`) and passes it to `AdminUI`. There is no `getAdminContext`
60
+ helper in this package.
54
61
 
55
62
  ## Architecture Patterns
56
63
 
@@ -154,7 +161,7 @@ export function RichTextField({ placeholder, minHeight, customOption, ...basePro
154
161
 
155
162
  ### Server/Client Boundaries
156
163
 
157
- - `AdminUI` is server component (uses `getAdminContext`)
164
+ - `AdminUI` is a server component (the host builds and passes `context`)
158
165
  - Forms and interactive components are client components
159
166
  - Data serialization via props (no functions, only JSON-serializable data)
160
167
 
@@ -168,7 +175,7 @@ export function RichTextField({ placeholder, minHeight, customOption, ...basePro
168
175
 
169
176
  ### With @opensaas/stack-auth
170
177
 
171
- - `getAdminContext` uses Better-auth to get session
178
+ - The host resolves the Better-auth session and passes it to `getContext(session)`
172
179
  - Session flows through context to access control
173
180
  - Auth UI components imported separately from `@opensaas/stack-auth/ui`
174
181
 
@@ -191,18 +198,48 @@ import '../../../lib/register-fields' // Side-effect import
191
198
 
192
199
  ### Basic Admin Setup
193
200
 
201
+ The host builds the access-scoped `context` (and `config`) from the generated
202
+ `.opensaas/context` and wires a `'use server'` action that forwards to
203
+ `context.serverAction`:
204
+
194
205
  ```typescript
195
206
  // app/admin/[[...admin]]/page.tsx
196
207
  import { AdminUI } from '@opensaas/stack-ui'
197
- import { getAdminContext } from '@opensaas/stack-ui/server'
198
- import config from '@/opensaas.config'
208
+ import type { ServerActionInput } from '@opensaas/stack-ui/server'
209
+ import { getContext, config } from '@/.opensaas/context'
210
+
211
+ // User-defined wrapper that runs the server action with an access-scoped context
212
+ async function serverAction(props: ServerActionInput) {
213
+ 'use server'
214
+ const context = await getContext()
215
+ return await context.serverAction(props)
216
+ }
199
217
 
200
- export default async function AdminPage() {
201
- const context = await getAdminContext()
202
- return <AdminUI context={context} config={config} />
218
+ interface AdminPageProps {
219
+ params: Promise<{ admin?: string[] }>
220
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>
221
+ }
222
+
223
+ export default async function AdminPage({ params, searchParams }: AdminPageProps) {
224
+ const resolvedParams = await params
225
+ const resolvedSearchParams = await searchParams
226
+ return (
227
+ <AdminUI
228
+ context={await getContext()}
229
+ config={await config}
230
+ params={resolvedParams.admin}
231
+ searchParams={resolvedSearchParams}
232
+ basePath="/admin"
233
+ serverAction={serverAction}
234
+ />
235
+ )
203
236
  }
204
237
  ```
205
238
 
239
+ With auth, resolve the session and pass it to `getContext(session)` (in both the
240
+ page and the wrapper). See `examples/starter`, `examples/starter-auth`, and
241
+ `examples/auth-demo`.
242
+
206
243
  ### Custom Field Component (Global Registration)
207
244
 
208
245
  ```typescript
@@ -305,7 +342,7 @@ Avoid `any` types - all props are strongly typed for type safety.
305
342
 
306
343
  ## Performance
307
344
 
308
- - Server components by default (AdminUI, getAdminContext)
345
+ - Server components by default (AdminUI renders on the server)
309
346
  - Client components marked with `'use client'`
310
347
  - Minimal client-side JS for interactive features only
311
348
  - Data fetching on server reduces client bundle size
package/README.md CHANGED
@@ -32,8 +32,8 @@ import { ItemCreateForm, ListTable, SearchBar } from '@opensaas/stack-ui/standal
32
32
  // Full components (page-level)
33
33
  import { Dashboard, ListView, ItemForm, AdminUI } from '@opensaas/stack-ui'
34
34
 
35
- // Server utilities
36
- import { getAdminContext } from '@opensaas/stack-ui/server'
35
+ // Server utility types
36
+ import type { ServerActionInput, ActionResult } from '@opensaas/stack-ui/server'
37
37
 
38
38
  // Utility functions
39
39
  import { cn, formatListName, formatFieldName } from '@opensaas/stack-ui/lib/utils'
@@ -175,19 +175,50 @@ import { DeleteButton } from '@opensaas/stack-ui/standalone'
175
175
 
176
176
  ### Level 4: Full Admin UI (`@opensaas/stack-ui`)
177
177
 
178
- Complete admin interface with routing and navigation.
178
+ Complete admin interface with routing and navigation. The host app builds the
179
+ access-scoped `context` (and `config`) from the generated `.opensaas/context`
180
+ and passes them in, along with a `'use server'` wrapper that forwards form
181
+ submissions to `context.serverAction`:
179
182
 
180
183
  ```tsx
184
+ // app/admin/[[...admin]]/page.tsx
181
185
  import { AdminUI } from '@opensaas/stack-ui'
182
- ;<AdminUI
183
- context={context}
184
- params={params?.admin}
185
- searchParams={searchParams}
186
- basePath="/admin"
187
- serverAction={handleServerAction}
188
- />
186
+ import type { ServerActionInput } from '@opensaas/stack-ui/server'
187
+ import { getContext, config } from '@/.opensaas/context'
188
+
189
+ // User-defined wrapper that runs the server action with an access-scoped context
190
+ async function serverAction(props: ServerActionInput) {
191
+ 'use server'
192
+ const context = await getContext()
193
+ return await context.serverAction(props)
194
+ }
195
+
196
+ interface AdminPageProps {
197
+ params: Promise<{ admin?: string[] }>
198
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>
199
+ }
200
+
201
+ export default async function AdminPage({ params, searchParams }: AdminPageProps) {
202
+ const resolvedParams = await params
203
+ const resolvedSearchParams = await searchParams
204
+ return (
205
+ <AdminUI
206
+ context={await getContext()}
207
+ config={await config}
208
+ params={resolvedParams.admin}
209
+ searchParams={resolvedSearchParams}
210
+ basePath="/admin"
211
+ serverAction={serverAction}
212
+ />
213
+ )
214
+ }
189
215
  ```
190
216
 
217
+ > With `@opensaas/stack-auth`, resolve the session first and pass it to
218
+ > `getContext(session)` (in both the page and the `serverAction` wrapper) so
219
+ > access control runs as the signed-in user. See `examples/starter-auth` and
220
+ > `examples/auth-demo`.
221
+
191
222
  ## Component Registry
192
223
 
193
224
  Extend or override field components:
@@ -17,7 +17,7 @@ export interface AdminUIProps {
17
17
  *
18
18
  * Handles routing based on params array:
19
19
  * - [] → Dashboard
20
- * - [list] → ListView
20
+ * - [list] → ListView (or SingletonView when the list is `isSingleton`)
21
21
  * - [list, 'create'] → ItemForm (create)
22
22
  * - [list, id] → ItemForm (edit)
23
23
  */
@@ -1 +1 @@
1
- {"version":3,"file":"AdminUI.d.ts","sourceRoot":"","sources":["../../src/components/AdminUI.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,EAAE,KAAK,aAAa,EAAqB,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAG5F,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB,YAAY,CAAC,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAA;KAAE,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC5D,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAChC;AAED;;;;;;;;;GASG;AACH,wBAAgB,OAAO,CAAC,EACtB,OAAO,EACP,MAAM,EACN,MAAW,EACX,YAAiB,EACjB,QAAmB,EACnB,YAAY,EACZ,SAAS,GACV,EAAE,YAAY,2CA4Ed"}
1
+ {"version":3,"file":"AdminUI.d.ts","sourceRoot":"","sources":["../../src/components/AdminUI.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,EACL,KAAK,aAAa,EAGlB,cAAc,EACf,MAAM,sBAAsB,CAAA;AAG7B,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB,YAAY,CAAC,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAA;KAAE,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC5D,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAChC;AAED;;;;;;;;;GASG;AACH,wBAAgB,OAAO,CAAC,EACtB,OAAO,EACP,MAAM,EACN,MAAW,EACX,YAAiB,EACjB,QAAmB,EACnB,YAAY,EACZ,SAAS,GACV,EAAE,YAAY,2CAuGd"}
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { redirect } from 'next/navigation.js';
2
3
  import { Navigation } from './Navigation.js';
3
4
  import { Dashboard } from './Dashboard.js';
4
5
  import { ListView } from './ListView.js';
5
6
  import { ItemForm } from './ItemForm.js';
6
- import { getListKeyFromUrl } from '@opensaas/stack-core';
7
+ import { SingletonView } from './SingletonView.js';
8
+ import { getListKeyFromUrl, getUrlKey, } from '@opensaas/stack-core';
7
9
  import { generateThemeCSS } from '../lib/theme.js';
8
10
  /**
9
11
  * Main AdminUI component - complete admin interface with routing
@@ -11,7 +13,7 @@ import { generateThemeCSS } from '../lib/theme.js';
11
13
  *
12
14
  * Handles routing based on params array:
13
15
  * - [] → Dashboard
14
- * - [list] → ListView
16
+ * - [list] → ListView (or SingletonView when the list is `isSingleton`)
15
17
  * - [list, 'create'] → ItemForm (create)
16
18
  * - [list, id] → ItemForm (edit)
17
19
  */
@@ -28,6 +30,14 @@ export function AdminUI({ context, config, params = [], searchParams = {}, baseP
28
30
  // Dashboard
29
31
  content = _jsx(Dashboard, { context: context, config: config, basePath: basePath });
30
32
  }
33
+ else if (config.lists[listKey]?.isSingleton && action) {
34
+ // A singleton has a single record edited at its bare [list] route, so the
35
+ // create/id sub-routes (`[list, 'create']` / `[list, id]`) don't apply.
36
+ // Redirect them to the bare editor so old links keep working. This runs
37
+ // before the create/edit ItemForm branches; non-singleton routing below is
38
+ // unchanged.
39
+ redirect(`${basePath}/${getUrlKey(listKey)}`);
40
+ }
31
41
  else if (action === 'create') {
32
42
  // Create form
33
43
  content = (_jsx(ItemForm, { context: context, config: config, listKey: listKey, mode: "create", basePath: basePath, serverAction: serverAction }));
@@ -36,11 +46,21 @@ export function AdminUI({ context, config, params = [], searchParams = {}, baseP
36
46
  // Edit form (action is the item ID)
37
47
  content = (_jsx(ItemForm, { context: context, config: config, listKey: listKey, mode: "edit", itemId: action, basePath: basePath, serverAction: serverAction }));
38
48
  }
49
+ else if (config.lists[listKey]?.isSingleton) {
50
+ // Singleton editor: a singleton has a single record, so its bare [list]
51
+ // route renders a single-record editor instead of a list table.
52
+ content = (_jsx(SingletonView, { context: context, config: config, listKey: listKey, basePath: basePath, serverAction: serverAction }));
53
+ }
39
54
  else {
40
55
  // List view
41
56
  const search = typeof searchParams.search === 'string' ? searchParams.search : undefined;
42
57
  const page = typeof searchParams.page === 'string' ? parseInt(searchParams.page, 10) : 1;
43
- content = (_jsx(ListView, { context: context, config: config, listKey: listKey, basePath: basePath, search: search, page: page }));
58
+ // Read list-view defaults (column selection/order + default sort) from the
59
+ // list-level `ui.listView` config (mirrors Keystone). When absent, the
60
+ // ListView falls back to its existing defaults (all non-system fields,
61
+ // no default sort).
62
+ const listView = config.lists[listKey]?.ui?.listView;
63
+ content = (_jsx(ListView, { context: context, config: config, listKey: listKey, basePath: basePath, search: search, page: page, columns: listView?.initialColumns, initialSort: listView?.initialSort }));
44
64
  }
45
65
  // Generate theme styles if custom theme is configured
46
66
  const themeStyles = config.ui?.theme ? generateThemeCSS(config.ui.theme) : null;
@@ -1 +1 @@
1
- {"version":3,"file":"Dashboard.d.ts","sourceRoot":"","sources":["../../src/components/Dashboard.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,aAAa,EAAuB,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAG9F,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,QAAmB,EAAE,EAAE,cAAc,oDAmHvF"}
1
+ {"version":3,"file":"Dashboard.d.ts","sourceRoot":"","sources":["../../src/components/Dashboard.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,aAAa,EAAuB,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAG9F,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,QAAmB,EAAE,EAAE,cAAc,oDA0NvF"}
@@ -9,8 +9,14 @@ import { Card, CardContent, CardHeader, CardTitle } from '../primitives/card.js'
9
9
  */
10
10
  export async function Dashboard({ context, config, basePath = '/admin' }) {
11
11
  const lists = Object.keys(config.lists || {});
12
- // Get counts for each list
13
- const listCounts = await Promise.all(lists.map(async (listKey) => {
12
+ // Split lists into standard lists (shown in the counted grid) and singletons
13
+ // (shown in their own "Settings" section). A singleton's count is always 0/1,
14
+ // so the "N items" label is misleading — show a "Configure" affordance instead.
15
+ const standardLists = lists.filter((listKey) => !config.lists[listKey]?.isSingleton);
16
+ const singletonLists = lists.filter((listKey) => config.lists[listKey]?.isSingleton);
17
+ // Get counts for the standard lists only. Singletons don't show a count, so
18
+ // there's no need to call count() for them here.
19
+ const listCounts = await Promise.all(standardLists.map(async (listKey) => {
14
20
  try {
15
21
  const delegate = context.db[getDbKey(listKey)];
16
22
  const count = delegate?.count ? await delegate.count() : 0;
@@ -21,10 +27,13 @@ export async function Dashboard({ context, config, basePath = '/admin' }) {
21
27
  return { listKey, count: 0 };
22
28
  }
23
29
  }));
24
- return (_jsxs("div", { className: "p-8", children: [_jsxs("div", { className: "mb-8 relative", children: [_jsx("div", { className: "absolute inset-0 bg-gradient-to-r from-primary/5 to-accent/5 opacity-100 rounded-2xl" }), _jsxs("div", { className: "relative p-6", children: [_jsx("h1", { className: "text-4xl font-bold mb-2 bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent", children: "Dashboard" }), _jsx("p", { className: "text-muted-foreground", children: "Manage your application data" })] })] }), lists.length === 0 ? (_jsxs(Card, { className: "p-12 text-center border-2 border-dashed", children: [_jsx("div", { className: "mb-4 text-4xl", children: "\uD83D\uDCE6" }), _jsx("p", { className: "text-muted-foreground mb-2 font-medium", children: "No lists configured" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Add lists to your opensaas.config.ts to get started." })] })) : (_jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6", children: listCounts.map(({ listKey, count }) => {
30
+ return (_jsxs("div", { className: "p-8", children: [_jsxs("div", { className: "mb-8 relative", children: [_jsx("div", { className: "absolute inset-0 bg-gradient-to-r from-primary/5 to-accent/5 opacity-100 rounded-2xl" }), _jsxs("div", { className: "relative p-6", children: [_jsx("h1", { className: "text-4xl font-bold mb-2 bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent", children: "Dashboard" }), _jsx("p", { className: "text-muted-foreground", children: "Manage your application data" })] })] }), lists.length === 0 ? (_jsxs(Card, { className: "p-12 text-center border-2 border-dashed", children: [_jsx("div", { className: "mb-4 text-4xl", children: "\uD83D\uDCE6" }), _jsx("p", { className: "text-muted-foreground mb-2 font-medium", children: "No lists configured" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Add lists to your opensaas.config.ts to get started." })] })) : standardLists.length > 0 ? (_jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6", children: listCounts.map(({ listKey, count }) => {
25
31
  const urlKey = getUrlKey(listKey);
26
32
  return (_jsx(Link, { href: `${basePath}/${urlKey}`, children: _jsxs(Card, { className: "group hover:border-primary hover:shadow-lg hover:shadow-primary/20 transition-all duration-200 cursor-pointer h-full relative overflow-hidden", children: [_jsx("div", { className: "absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" }), _jsx(CardHeader, { className: "relative", children: _jsxs("div", { className: "flex items-start justify-between", children: [_jsxs("div", { children: [_jsx(CardTitle, { className: "text-xl group-hover:text-primary transition-colors", children: formatListName(listKey) }), _jsxs("p", { className: "text-sm text-muted-foreground mt-1 font-medium", children: [count, " ", count === 1 ? 'item' : 'items'] })] }), _jsx("div", { className: "text-3xl opacity-60 group-hover:opacity-100 transition-opacity", children: "\uD83D\uDCCB" })] }) }), _jsx(CardContent, { className: "relative", children: _jsxs("div", { className: "flex items-center text-sm font-medium text-primary", children: [_jsx("span", { children: "View all" }), _jsx("svg", { className: "ml-1 w-4 h-4 group-hover:translate-x-1 transition-transform", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 5l7 7-7 7" }) })] }) })] }) }, listKey));
27
- }) })), lists.length > 0 && (_jsxs(Card, { className: "mt-12 bg-gradient-to-br from-accent/10 to-primary/10 border-accent/20", children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "text-lg flex items-center gap-2", children: [_jsx("span", { className: "text-xl", children: "\u26A1" }), "Quick Actions"] }) }), _jsx(CardContent, { children: _jsx("div", { className: "flex flex-wrap gap-3", children: lists.map((listKey) => {
33
+ }) })) : null, singletonLists.length > 0 && (_jsxs("div", { className: standardLists.length > 0 ? 'mt-12' : '', children: [_jsxs("div", { className: "mb-4 flex items-center gap-2", children: [_jsxs("svg", { className: "w-5 h-5 text-muted-foreground", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: [_jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" }), _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z" })] }), _jsx("h2", { className: "text-xl font-semibold text-foreground", children: "Settings" })] }), _jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6", children: singletonLists.map((listKey) => {
34
+ const urlKey = getUrlKey(listKey);
35
+ return (_jsx(Link, { href: `${basePath}/${urlKey}`, children: _jsxs(Card, { className: "group hover:border-primary hover:shadow-lg hover:shadow-primary/20 transition-all duration-200 cursor-pointer h-full relative overflow-hidden", children: [_jsx("div", { className: "absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" }), _jsx(CardHeader, { className: "relative", children: _jsxs("div", { className: "flex items-start justify-between", children: [_jsx("div", { children: _jsx(CardTitle, { className: "text-xl group-hover:text-primary transition-colors", children: formatListName(listKey) }) }), _jsx("div", { className: "text-muted-foreground opacity-60 group-hover:opacity-100 transition-opacity", children: _jsxs("svg", { className: "w-7 h-7", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: [_jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" }), _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z" })] }) })] }) }), _jsx(CardContent, { className: "relative", children: _jsxs("div", { className: "flex items-center text-sm font-medium text-primary", children: [_jsx("span", { children: "Configure" }), _jsx("svg", { className: "ml-1 w-4 h-4 group-hover:translate-x-1 transition-transform", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 5l7 7-7 7" }) })] }) })] }) }, listKey));
36
+ }) })] })), standardLists.length > 0 && (_jsxs(Card, { className: "mt-12 bg-gradient-to-br from-accent/10 to-primary/10 border-accent/20", children: [_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "text-lg flex items-center gap-2", children: [_jsx("span", { className: "text-xl", children: "\u26A1" }), "Quick Actions"] }) }), _jsx(CardContent, { children: _jsx("div", { className: "flex flex-wrap gap-3", children: standardLists.map((listKey) => {
28
37
  const urlKey = getUrlKey(listKey);
29
38
  return (_jsxs(Link, { href: `${basePath}/${urlKey}/create`, className: "inline-flex items-center gap-1 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground font-medium text-sm transition-colors border border-primary/20", children: [_jsx("span", { className: "text-lg", children: "+" }), "Create ", formatListName(listKey)] }, listKey));
30
39
  }) }) })] }))] }));
@@ -1 +1 @@
1
- {"version":3,"file":"ItemForm.d.ts","sourceRoot":"","sources":["../../src/components/ItemForm.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,EAAE,KAAK,aAAa,EAAuB,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAG9F,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAA;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAC7D;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,OAAO,EACP,IAAI,EACJ,MAAM,EACN,QAAmB,EACnB,YAAY,GACb,EAAE,aAAa,oDAuKf"}
1
+ {"version":3,"file":"ItemForm.d.ts","sourceRoot":"","sources":["../../src/components/ItemForm.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,EAAE,KAAK,aAAa,EAAuB,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAG9F,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAA;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAC7D;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,OAAO,EACP,IAAI,EACJ,MAAM,EACN,QAAmB,EACnB,YAAY,GACb,EAAE,aAAa,oDAqGf"}
@@ -3,7 +3,7 @@ import Link from 'next/link.js';
3
3
  import { ItemFormClient } from './ItemFormClient.js';
4
4
  import { formatListName } from '../lib/utils.js';
5
5
  import { getDbKey, getUrlKey } from '@opensaas/stack-core';
6
- import { serializeFieldConfigs } from '../lib/serializeFieldConfig.js';
6
+ import { buildRelationshipInclude, prepareItemForm } from '../lib/prepareItemForm.js';
7
7
  /**
8
8
  * Item form component - create or edit an item
9
9
  * Server Component that fetches data and sets up actions
@@ -18,15 +18,8 @@ export async function ItemForm({ context, config, listKey, mode, itemId, basePat
18
18
  let itemData = {};
19
19
  if (mode === 'edit' && itemId) {
20
20
  try {
21
- // Build include object for relationships
22
- const includeRelationships = {};
23
- for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
24
- const fieldConfigAny = fieldConfig;
25
- if (fieldConfigAny.type === 'relationship') {
26
- includeRelationships[fieldName] = true;
27
- }
28
- }
29
21
  // Fetch item with relationships included
22
+ const includeRelationships = buildRelationshipInclude(listConfig);
30
23
  const delegate = context.db[getDbKey(listKey)];
31
24
  if (delegate?.findUnique) {
32
25
  itemData = await delegate.findUnique({
@@ -42,60 +35,8 @@ export async function ItemForm({ context, config, listKey, mode, itemId, basePat
42
35
  return (_jsx("div", { className: "p-8", children: _jsxs("div", { className: "bg-destructive/10 border border-destructive text-destructive rounded-lg p-6", children: [_jsx("h2", { className: "text-lg font-semibold mb-2", children: "Item not found" }), _jsx("p", { children: "The item you're trying to edit doesn't exist or you don't have access to it." }), _jsxs(Link, { href: `${basePath}/${urlKey}`, className: "inline-block mt-4 text-primary hover:underline", children: ["\u2190 Back to ", formatListName(listKey)] })] }) }));
43
36
  }
44
37
  }
45
- // Fetch relationship options for all relationship fields
46
- const relationshipData = {};
47
- for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
48
- // Check if field is a relationship type by checking the discriminated union
49
- const fieldConfigAny = fieldConfig;
50
- if (fieldConfigAny.type === 'relationship') {
51
- const ref = fieldConfigAny.ref;
52
- if (ref) {
53
- // Parse ref format: "ListName.fieldName"
54
- const relatedListName = ref.split('.')[0];
55
- const relatedListConfig = config.lists[relatedListName];
56
- if (relatedListConfig) {
57
- try {
58
- const dbContext = context.db;
59
- const delegate = dbContext[getDbKey(relatedListName)];
60
- const relatedItems = delegate?.findMany ? await delegate.findMany({}) : [];
61
- // Use 'name' field as label if it exists, otherwise use 'id'
62
- relationshipData[fieldName] = relatedItems.map((item) => ({
63
- id: item.id,
64
- label: (item.name || item.title || item.id) || '',
65
- }));
66
- }
67
- catch (error) {
68
- console.error(`Failed to fetch relationship items for ${fieldName}:`, error);
69
- relationshipData[fieldName] = [];
70
- }
71
- }
72
- }
73
- }
74
- }
75
- // Serialize field configs to remove non-serializable properties
76
- const serializableFields = serializeFieldConfigs(listConfig.fields);
77
- // Transform relationship data in itemData from objects to IDs for form
78
- // Also apply valueForClientSerialization transformation
79
- const formData = { ...itemData };
80
- for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
81
- const fieldConfigAny = fieldConfig;
82
- if (fieldConfigAny.type === 'relationship' && formData[fieldName]) {
83
- const value = formData[fieldName];
84
- if (fieldConfigAny.many && Array.isArray(value)) {
85
- // Many relationship: extract IDs from array of objects
86
- formData[fieldName] = value.map((item) => item.id);
87
- }
88
- else if (value && typeof value === 'object' && 'id' in value) {
89
- // Single relationship: extract ID from object
90
- formData[fieldName] = value.id;
91
- }
92
- }
93
- // Apply valueForClientSerialization if defined
94
- if (fieldConfigAny.ui?.valueForClientSerialization &&
95
- typeof fieldConfigAny.ui.valueForClientSerialization === 'function') {
96
- const transformer = fieldConfigAny.ui.valueForClientSerialization;
97
- formData[fieldName] = transformer({ value: formData[fieldName] });
98
- }
99
- }
100
- return (_jsxs("div", { className: "p-8 max-w-4xl", children: [_jsxs("div", { className: "mb-8", children: [_jsxs(Link, { href: `${basePath}/${urlKey}`, className: "inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4", children: [_jsx("svg", { className: "w-4 h-4 mr-1", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 19l-7-7 7-7" }) }), "Back to ", formatListName(listKey)] }), _jsxs("h1", { className: "text-3xl font-bold", children: [mode === 'create' ? 'Create' : 'Edit', " ", formatListName(listKey)] })] }), _jsx("div", { className: "bg-card border border-border rounded-lg p-6", children: _jsx(ItemFormClient, { listKey: listKey, urlKey: urlKey, mode: mode, fields: serializableFields, initialData: JSON.parse(JSON.stringify(formData)), itemId: itemId, basePath: basePath, serverAction: serverAction, relationshipData: relationshipData }) })] }));
38
+ // Fetch relationship options, serialize field configs, and transform the
39
+ // record into client-ready form data (shared with the singleton editor).
40
+ const { serializableFields, initialData, relationshipData } = await prepareItemForm(context, config, listConfig, itemData);
41
+ return (_jsxs("div", { className: "p-8 max-w-4xl", children: [_jsxs("div", { className: "mb-8", children: [_jsxs(Link, { href: `${basePath}/${urlKey}`, className: "inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4", children: [_jsx("svg", { className: "w-4 h-4 mr-1", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 19l-7-7 7-7" }) }), "Back to ", formatListName(listKey)] }), _jsxs("h1", { className: "text-3xl font-bold", children: [mode === 'create' ? 'Create' : 'Edit', " ", formatListName(listKey)] })] }), _jsx("div", { className: "bg-card border border-border rounded-lg p-6", children: _jsx(ItemFormClient, { listKey: listKey, urlKey: urlKey, mode: mode, fields: serializableFields, initialData: initialData, itemId: itemId, basePath: basePath, serverAction: serverAction, relationshipData: relationshipData }) })] }));
101
42
  }
@@ -13,6 +13,13 @@ export interface ItemFormClientProps {
13
13
  id: string;
14
14
  label: string;
15
15
  }>>;
16
+ /**
17
+ * Whether to render the delete affordance in edit mode. Defaults to `true`.
18
+ * Singletons set this to `false` — a singleton has exactly one record that
19
+ * can't be deleted (core blocks `delete` even in sudo), so the control is
20
+ * suppressed for UX hygiene.
21
+ */
22
+ canDelete?: boolean;
16
23
  }
17
24
  /**
18
25
  * Client component for the AdminUI item form.
@@ -21,5 +28,5 @@ export interface ItemFormClientProps {
21
28
  * forms via `useItemForm`; this component only adapts submission to the
22
29
  * AdminUI server action + router navigation, and adds the delete flow.
23
30
  */
24
- export declare function ItemFormClient({ listKey, urlKey, mode, fields, initialData, itemId, basePath, serverAction, relationshipData, }: ItemFormClientProps): import("react/jsx-runtime").JSX.Element;
31
+ export declare function ItemFormClient({ listKey, urlKey, mode, fields, initialData, itemId, basePath, serverAction, relationshipData, canDelete, }: ItemFormClientProps): import("react/jsx-runtime").JSX.Element;
25
32
  //# sourceMappingURL=ItemFormClient.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ItemFormClient.d.ts","sourceRoot":"","sources":["../../src/components/ItemFormClient.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAA;AAG7E,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAA;IACvB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAA;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACrC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;CACxE;AAoBD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,IAAI,EACJ,MAAM,EACN,WAAgB,EAChB,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,gBAAqB,GACtB,EAAE,mBAAmB,2CAkIrB"}
1
+ {"version":3,"file":"ItemFormClient.d.ts","sourceRoot":"","sources":["../../src/components/ItemFormClient.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAA;AAG7E,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAA;IACvB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAA;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACrC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;IACvE;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAoBD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,IAAI,EACJ,MAAM,EACN,WAAgB,EAChB,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,gBAAqB,EACrB,SAAgB,GACjB,EAAE,mBAAmB,2CAkIrB"}
@@ -30,7 +30,7 @@ function toSubmitResult(result, deniedMessage) {
30
30
  * forms via `useItemForm`; this component only adapts submission to the
31
31
  * AdminUI server action + router navigation, and adds the delete flow.
32
32
  */
33
- export function ItemFormClient({ listKey, urlKey, mode, fields, initialData = {}, itemId, basePath, serverAction, relationshipData = {}, }) {
33
+ export function ItemFormClient({ listKey, urlKey, mode, fields, initialData = {}, itemId, basePath, serverAction, relationshipData = {}, canDelete = true, }) {
34
34
  const router = useRouter();
35
35
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
36
36
  // Separate transition for delete (not a form submit, so outside the engine).
@@ -70,5 +70,5 @@ export function ItemFormClient({ listKey, urlKey, mode, fields, initialData = {}
70
70
  });
71
71
  };
72
72
  const busy = isPending || isDeleting;
73
- return (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-6", children: [generalError && (_jsx("div", { className: "bg-destructive/10 border border-destructive text-destructive rounded-lg p-4", children: _jsx("p", { className: "text-sm font-medium", children: generalError }) })), _jsx("div", { className: "space-y-6", children: editableFields.map(([fieldName, fieldConfig]) => (_jsx(FieldRenderer, { fieldName: fieldName, fieldConfig: fieldConfig, value: formData[fieldName], onChange: (value) => handleFieldChange(fieldName, value), error: errors[fieldName], disabled: busy, mode: "edit", relationshipItems: relationshipData[fieldName] || [], relationshipLoading: false, basePath: basePath }, fieldName))) }), _jsxs("div", { className: "flex items-center justify-between pt-6 border-t border-border", children: [_jsxs("div", { className: "flex gap-3", children: [_jsxs(Button, { type: "submit", disabled: busy, className: "gap-2", children: [isPending && (_jsx(LoadingSpinner, { size: "sm", className: "border-primary-foreground border-t-transparent" })), isPending ? 'Saving...' : mode === 'create' ? 'Create' : 'Save'] }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => router.push(`${basePath}/${urlKey}`), disabled: busy, children: "Cancel" })] }), mode === 'edit' && itemId && (_jsx(Button, { type: "button", variant: "destructive", onClick: () => setShowDeleteConfirm(true), disabled: busy, children: "Delete" }))] }), _jsx(ConfirmDialog, { isOpen: showDeleteConfirm, title: "Delete Item", message: "Are you sure you want to delete this item? This action cannot be undone.", confirmLabel: "Delete", cancelLabel: "Cancel", variant: "danger", onConfirm: handleDelete, onCancel: () => setShowDeleteConfirm(false) })] }));
73
+ return (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-6", children: [generalError && (_jsx("div", { className: "bg-destructive/10 border border-destructive text-destructive rounded-lg p-4", children: _jsx("p", { className: "text-sm font-medium", children: generalError }) })), _jsx("div", { className: "space-y-6", children: editableFields.map(([fieldName, fieldConfig]) => (_jsx(FieldRenderer, { fieldName: fieldName, fieldConfig: fieldConfig, value: formData[fieldName], onChange: (value) => handleFieldChange(fieldName, value), error: errors[fieldName], disabled: busy, mode: "edit", relationshipItems: relationshipData[fieldName] || [], relationshipLoading: false, basePath: basePath }, fieldName))) }), _jsxs("div", { className: "flex items-center justify-between pt-6 border-t border-border", children: [_jsxs("div", { className: "flex gap-3", children: [_jsxs(Button, { type: "submit", disabled: busy, className: "gap-2", children: [isPending && (_jsx(LoadingSpinner, { size: "sm", className: "border-primary-foreground border-t-transparent" })), isPending ? 'Saving...' : mode === 'create' ? 'Create' : 'Save'] }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => router.push(`${basePath}/${urlKey}`), disabled: busy, children: "Cancel" })] }), mode === 'edit' && itemId && canDelete && (_jsx(Button, { type: "button", variant: "destructive", onClick: () => setShowDeleteConfirm(true), disabled: busy, children: "Delete" }))] }), _jsx(ConfirmDialog, { isOpen: showDeleteConfirm, title: "Delete Item", message: "Are you sure you want to delete this item? This action cannot be undone.", confirmLabel: "Delete", cancelLabel: "Cancel", variant: "danger", onConfirm: handleDelete, onCancel: () => setShowDeleteConfirm(false) })] }));
74
74
  }
@@ -1,4 +1,12 @@
1
1
  import { type AccessContext, OpenSaasConfig } from '@opensaas/stack-core';
2
+ /**
3
+ * Default sort for the list table, mirroring Keystone's `ui.listView.initialSort`.
4
+ * Plain serializable data so it can cross the server/client boundary.
5
+ */
6
+ export interface ListViewSort {
7
+ field: string;
8
+ direction: 'asc' | 'desc';
9
+ }
2
10
  export interface ListViewProps {
3
11
  context: AccessContext<unknown>;
4
12
  config: OpenSaasConfig;
@@ -8,10 +16,15 @@ export interface ListViewProps {
8
16
  page?: number;
9
17
  pageSize?: number;
10
18
  search?: string;
19
+ /**
20
+ * Default sort applied to the table (from the list's `ui.listView.initialSort`).
21
+ * When omitted, no default sort is applied.
22
+ */
23
+ initialSort?: ListViewSort;
11
24
  }
12
25
  /**
13
26
  * List view component - displays items in a table
14
27
  * Server Component that fetches data and renders client table
15
28
  */
16
- export declare function ListView({ context, config, listKey, basePath, columns, page, pageSize, search, }: ListViewProps): Promise<import("react/jsx-runtime").JSX.Element>;
29
+ export declare function ListView({ context, config, listKey, basePath, columns, page, pageSize, search, initialSort, }: ListViewProps): Promise<import("react/jsx-runtime").JSX.Element>;
17
30
  //# sourceMappingURL=ListView.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ListView.d.ts","sourceRoot":"","sources":["../../src/components/ListView.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,aAAa,EAAuB,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAE9F,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,OAAO,EACP,QAAmB,EACnB,OAAO,EACP,IAAQ,EACR,QAAa,EACb,MAAM,GACP,EAAE,aAAa,oDA6Hf"}
1
+ {"version":3,"file":"ListView.d.ts","sourceRoot":"","sources":["../../src/components/ListView.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,aAAa,EAAuB,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAE9F;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,KAAK,GAAG,MAAM,CAAA;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,WAAW,CAAC,EAAE,YAAY,CAAA;CAC3B;AAED;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,EAC7B,OAAO,EACP,MAAM,EACN,OAAO,EACP,QAAmB,EACnB,OAAO,EACP,IAAQ,EACR,QAAa,EACb,MAAM,EACN,WAAW,GACZ,EAAE,aAAa,oDA8Hf"}
@@ -7,7 +7,7 @@ import { getDbKey, getUrlKey } from '@opensaas/stack-core';
7
7
  * List view component - displays items in a table
8
8
  * Server Component that fetches data and renders client table
9
9
  */
10
- export async function ListView({ context, config, listKey, basePath = '/admin', columns, page = 1, pageSize = 50, search, }) {
10
+ export async function ListView({ context, config, listKey, basePath = '/admin', columns, page = 1, pageSize = 50, search, initialSort, }) {
11
11
  const key = getDbKey(listKey);
12
12
  const urlKey = getUrlKey(listKey);
13
13
  const listConfig = config.lists[listKey];
@@ -79,5 +79,5 @@ export async function ListView({ context, config, listKey, basePath = '/admin',
79
79
  return (_jsxs("div", { className: "p-8", children: [_jsxs("div", { className: "flex items-center justify-between mb-8", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-bold mb-2", children: formatListName(listKey) }), _jsxs("p", { className: "text-muted-foreground", children: [total, " ", total === 1 ? 'item' : 'items'] })] }), _jsxs(Link, { href: `${basePath}/${urlKey}/create`, className: "inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors", children: [_jsx("span", { className: "mr-2", children: "+" }), "Create ", formatListName(listKey)] })] }), _jsx(ListViewClient, { items: serializedItems || [], fieldTypes: Object.fromEntries(Object.entries(listConfig.fields).map(([key, field]) => [
80
80
  key,
81
81
  field.type,
82
- ])), relationshipRefs: relationshipRefs, columns: columns, listKey: listKey, urlKey: urlKey, basePath: basePath, page: page, pageSize: pageSize, total: total || 0, search: search })] }));
82
+ ])), relationshipRefs: relationshipRefs, columns: columns, initialSort: initialSort, listKey: listKey, urlKey: urlKey, basePath: basePath, page: page, pageSize: pageSize, total: total || 0, search: search })] }));
83
83
  }