@opensaas/stack-ui 0.22.0 → 0.23.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 (42) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +62 -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 +17 -2
  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/Navigation.d.ts.map +1 -1
  16. package/dist/components/Navigation.js +12 -1
  17. package/dist/components/SingletonView.d.ts +37 -0
  18. package/dist/components/SingletonView.d.ts.map +1 -0
  19. package/dist/components/SingletonView.js +82 -0
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/lib/operationAccess.d.ts +34 -0
  24. package/dist/lib/operationAccess.d.ts.map +1 -0
  25. package/dist/lib/operationAccess.js +43 -0
  26. package/dist/lib/prepareItemForm.d.ts +35 -0
  27. package/dist/lib/prepareItemForm.d.ts.map +1 -0
  28. package/dist/lib/prepareItemForm.js +85 -0
  29. package/dist/styles/globals.css +12 -0
  30. package/package.json +2 -2
  31. package/src/components/AdminUI.tsx +28 -2
  32. package/src/components/Dashboard.tsx +108 -5
  33. package/src/components/ItemForm.tsx +11 -77
  34. package/src/components/ItemFormClient.tsx +10 -2
  35. package/src/components/Navigation.tsx +58 -1
  36. package/src/components/SingletonView.tsx +228 -0
  37. package/src/index.ts +2 -0
  38. package/src/lib/operationAccess.ts +53 -0
  39. package/src/lib/prepareItemForm.ts +121 -0
  40. package/tests/components/AdminUISingleton.test.tsx +296 -0
  41. package/tests/components/AdminUISingletonSuppress.test.tsx +259 -0
  42. 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.23.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.23.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,67 @@
1
1
  # @opensaas/stack-ui
2
2
 
3
+ ## 0.23.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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.
8
+
9
+ When a singleton's `get()` returns no record, `SingletonView` now disambiguates the two reasons a singleton can be empty and renders the safe affordance:
10
+ - **`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.
11
+ - **`query` access denied**: renders a friendly "no access" message — never an editable or create form.
12
+ - **create denied (autoCreate: false, no row)**: renders a friendly "no record yet" message instead of an unusable form.
13
+
14
+ 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.
15
+
16
+ - [#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`
17
+
18
+ A list configured with `isSingleton: true` now renders a single-record editor at
19
+ its bare `[list]` route instead of a list table. The new `SingletonView`
20
+ component resolves the record via the singleton `get()` operation (which
21
+ auto-creates the row with field defaults when absent) and reuses the existing
22
+ `ItemFormClient` in edit mode, so field rendering, validation, and the existing
23
+ `serverAction` save path all apply unchanged. Non-singleton lists are
24
+ unaffected and still render the table.
25
+
26
+ ```typescript
27
+ // opensaas.config.ts
28
+ lists: {
29
+ SiteSettings: list({
30
+ isSingleton: true,
31
+ fields: {
32
+ siteName: text(),
33
+ supportEmail: text(),
34
+ },
35
+ }),
36
+ }
37
+ ```
38
+
39
+ Visiting `/admin/site-settings` now shows an "Edit Site Settings" form for the
40
+ single record rather than a one-row list.
41
+
42
+ - [#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
43
+
44
+ Singleton lists (`isSingleton`) are now visually distinguished from ordinary lists:
45
+ - **Navigation:** singletons render under a dedicated "Settings" group with a gear
46
+ icon, separate from the standard "Lists" group. Each still links to its
47
+ single-record editor (`/<basePath>/<url>`). The "Settings" group is omitted when
48
+ there are no singletons (and the "Lists" group is omitted when there are only
49
+ singletons).
50
+ - **Dashboard:** singletons appear in their own "Settings" section with a
51
+ "Configure" affordance instead of the misleading "N items" count (a singleton's
52
+ count is always 0 or 1). The Dashboard no longer calls `count()` for singletons.
53
+
54
+ Non-singleton lists are unchanged.
55
+
56
+ - [#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.
57
+
58
+ Singleton lists (`isSingleton: true`) have a single record edited at their bare `[list]` route, so the create and delete affordances no longer apply:
59
+ - 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.
60
+ - 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.
61
+ - 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.
62
+
63
+ Non-singleton create/delete affordances and routing are unchanged.
64
+
3
65
  ## 0.22.0
4
66
 
5
67
  ## 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,2CA+Fd"}
@@ -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,6 +46,11 @@ 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;
@@ -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 +1 @@
1
- {"version":3,"file":"Navigation.d.ts","sourceRoot":"","sources":["../../src/components/Navigation.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,aAAa,EAAa,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAGpF,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAChC;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,EACzB,OAAO,EACP,MAAM,EACN,QAAmB,EACnB,WAAgB,EAChB,SAAS,GACV,EAAE,eAAe,2CAkFjB"}
1
+ {"version":3,"file":"Navigation.d.ts","sourceRoot":"","sources":["../../src/components/Navigation.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,aAAa,EAAa,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAGpF,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAChC;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,EACzB,OAAO,EACP,MAAM,EACN,QAAmB,EACnB,WAAgB,EAChB,SAAS,GACV,EAAE,eAAe,2CA2IjB"}
@@ -8,7 +8,12 @@ import { UserMenu } from './UserMenu.js';
8
8
  * Server Component
9
9
  */
10
10
  export function Navigation({ context, config, basePath = '/admin', currentPath = '', onSignOut, }) {
11
- const lists = Object.keys(config.lists || {});
11
+ const allLists = Object.keys(config.lists || {});
12
+ // Split lists into standard lists (under "Lists") and singletons (under
13
+ // "Settings"). A singleton edits a single record, so it belongs in a distinct
14
+ // Settings group rather than the standard list grid.
15
+ const lists = allLists.filter((listKey) => !config.lists[listKey]?.isSingleton);
16
+ const singletons = allLists.filter((listKey) => config.lists[listKey]?.isSingleton);
12
17
  return (_jsxs("nav", { className: "w-64 border-r border-border bg-card h-screen sticky top-0 flex flex-col", children: [_jsxs("div", { className: "p-6 border-b border-border relative overflow-hidden", children: [_jsx("div", { className: "absolute inset-0 bg-gradient-to-br from-primary/10 to-accent/10 opacity-50" }), _jsx(Link, { href: basePath, className: "block relative", children: _jsx("h1", { className: "text-xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent", children: "OpenSaas Admin" }) })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-4", children: _jsxs("div", { className: "space-y-1", children: [_jsxs(Link, { href: basePath, className: `block px-3 py-2.5 rounded-lg text-sm font-medium transition-all relative overflow-hidden group ${currentPath === ''
13
18
  ? 'bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-lg shadow-primary/25'
14
19
  : 'text-foreground hover:bg-accent/50 hover:text-accent-foreground'}`, children: [currentPath === '' && (_jsx("div", { className: "absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 animate-pulse" })), _jsxs("span", { className: "relative flex items-center gap-2", children: [_jsx("span", { className: currentPath === '' ? 'text-lg' : 'text-base', children: "\uD83D\uDCCA" }), "Dashboard"] })] }), lists.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { className: "pt-4 pb-2 px-3", children: _jsx("p", { className: "text-xs font-semibold text-muted-foreground uppercase tracking-wider", children: "Lists" }) }), lists.map((listKey) => {
@@ -17,5 +22,11 @@ export function Navigation({ context, config, basePath = '/admin', currentPath =
17
22
  return (_jsxs(Link, { href: `${basePath}/${urlKey}`, className: `block px-3 py-2.5 rounded-lg text-sm font-medium transition-all relative overflow-hidden group ${isActive
18
23
  ? 'bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-lg shadow-primary/25'
19
24
  : 'text-foreground hover:bg-accent/50 hover:text-accent-foreground'}`, children: [isActive && (_jsx("div", { className: "absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 animate-pulse" })), _jsxs("span", { className: "relative flex items-center gap-2", children: [_jsx("span", { className: "opacity-60 group-hover:opacity-100 transition-opacity", children: "\uD83D\uDCC1" }), formatListName(listKey)] })] }, listKey));
25
+ })] })), singletons.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { className: "pt-4 pb-2 px-3", children: _jsx("p", { className: "text-xs font-semibold text-muted-foreground uppercase tracking-wider", children: "Settings" }) }), singletons.map((listKey) => {
26
+ const urlKey = getUrlKey(listKey);
27
+ const isActive = currentPath.startsWith(`/${urlKey}`);
28
+ return (_jsxs(Link, { href: `${basePath}/${urlKey}`, className: `block px-3 py-2.5 rounded-lg text-sm font-medium transition-all relative overflow-hidden group ${isActive
29
+ ? 'bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-lg shadow-primary/25'
30
+ : 'text-foreground hover:bg-accent/50 hover:text-accent-foreground'}`, children: [isActive && (_jsx("div", { className: "absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 animate-pulse" })), _jsxs("span", { className: "relative flex items-center gap-2", children: [_jsxs("svg", { className: "w-4 h-4 opacity-60 group-hover:opacity-100 transition-opacity", 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" })] }), formatListName(listKey)] })] }, listKey));
20
31
  })] }))] }) }), context.session && (_jsx(UserMenu, { userName: String(context.session.data?.name) || 'User', userEmail: String(context.session.data?.email) || '', onSignOut: onSignOut }))] }));
21
32
  }
@@ -0,0 +1,37 @@
1
+ import type { ServerActionInput } from '../server/types.js';
2
+ import { type AccessContext, OpenSaasConfig } from '@opensaas/stack-core';
3
+ export interface SingletonViewProps {
4
+ context: AccessContext<unknown>;
5
+ config: OpenSaasConfig;
6
+ listKey: string;
7
+ basePath?: string;
8
+ serverAction: (input: ServerActionInput) => Promise<unknown>;
9
+ }
10
+ /**
11
+ * Singleton editor — renders a single-record edit form for a list configured
12
+ * with `isSingleton: true`.
13
+ *
14
+ * Resolves the record via the singleton `get()` operation (which auto-creates
15
+ * the row with field defaults when absent, unless `autoCreate: false`), then
16
+ * reuses the same `ItemFormClient` + serialization path as `ItemForm` so the
17
+ * existing field rendering, validation, and `serverAction` save flow apply.
18
+ *
19
+ * A `null` from `get()` is ambiguous at the boundary — it means EITHER an
20
+ * `autoCreate: false` singleton with no row yet, OR that `query` access is
21
+ * denied (access-controlled reads return null/[] silently). We disambiguate
22
+ * using the list's operation-level access:
23
+ *
24
+ * - `query` denied → friendly "no access" message (never an editable form).
25
+ * - `query` allowed + `create` allowed → a create-on-first-save form
26
+ * (`ItemFormClient` in `mode="create"`); core assigns the singleton `id` and
27
+ * enforces the single-record constraint on create.
28
+ * - `query` allowed + `create` denied → friendly "no record yet" message.
29
+ *
30
+ * An update-denied singleton still renders the edit form (the happy path), but
31
+ * the save fails gracefully: the server action's `update` access check returns
32
+ * a denied envelope, which `ItemFormClient` surfaces as an error.
33
+ *
34
+ * Server Component that fetches data and sets up actions.
35
+ */
36
+ export declare function SingletonView({ context, config, listKey, basePath, serverAction, }: SingletonViewProps): Promise<import("react/jsx-runtime").JSX.Element>;
37
+ //# sourceMappingURL=SingletonView.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SingletonView.d.ts","sourceRoot":"","sources":["../../src/components/SingletonView.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAC3D,OAAO,EAAE,KAAK,aAAa,EAAuB,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAI9F,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IAEjB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAC7D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,aAAa,CAAC,EAClC,OAAO,EACP,MAAM,EACN,OAAO,EACP,QAAmB,EACnB,YAAY,GACb,EAAE,kBAAkB,oDAiLpB"}