@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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +95 -0
- package/CLAUDE.md +46 -9
- package/README.md +41 -10
- package/dist/components/AdminUI.d.ts +1 -1
- package/dist/components/AdminUI.d.ts.map +1 -1
- package/dist/components/AdminUI.js +23 -3
- package/dist/components/Dashboard.d.ts.map +1 -1
- package/dist/components/Dashboard.js +13 -4
- package/dist/components/ItemForm.d.ts.map +1 -1
- package/dist/components/ItemForm.js +6 -65
- package/dist/components/ItemFormClient.d.ts +8 -1
- package/dist/components/ItemFormClient.d.ts.map +1 -1
- package/dist/components/ItemFormClient.js +2 -2
- package/dist/components/ListView.d.ts +14 -1
- package/dist/components/ListView.d.ts.map +1 -1
- package/dist/components/ListView.js +2 -2
- package/dist/components/ListViewClient.d.ts +10 -1
- package/dist/components/ListViewClient.d.ts.map +1 -1
- package/dist/components/ListViewClient.js +3 -3
- package/dist/components/Navigation.d.ts.map +1 -1
- package/dist/components/Navigation.js +12 -1
- package/dist/components/SingletonView.d.ts +37 -0
- package/dist/components/SingletonView.d.ts.map +1 -0
- package/dist/components/SingletonView.js +82 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/lib/operationAccess.d.ts +34 -0
- package/dist/lib/operationAccess.d.ts.map +1 -0
- package/dist/lib/operationAccess.js +43 -0
- package/dist/lib/prepareItemForm.d.ts +35 -0
- package/dist/lib/prepareItemForm.d.ts.map +1 -0
- package/dist/lib/prepareItemForm.js +85 -0
- package/dist/styles/globals.css +12 -0
- package/package.json +2 -2
- package/src/components/AdminUI.tsx +36 -2
- package/src/components/Dashboard.tsx +108 -5
- package/src/components/ItemForm.tsx +11 -77
- package/src/components/ItemFormClient.tsx +10 -2
- package/src/components/ListView.tsx +16 -0
- package/src/components/ListViewClient.tsx +9 -2
- package/src/components/Navigation.tsx +58 -1
- package/src/components/SingletonView.tsx +228 -0
- package/src/index.ts +2 -0
- package/src/lib/operationAccess.ts +53 -0
- package/src/lib/prepareItemForm.ts +121 -0
- package/tests/components/AdminUIListView.test.tsx +134 -0
- package/tests/components/AdminUISingleton.test.tsx +296 -0
- package/tests/components/AdminUISingletonSuppress.test.tsx +259 -0
- package/tests/components/ListViewClient.test.tsx +60 -0
- package/tests/components/SingletonNavDashboard.test.tsx +141 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
|
|
2
|
-
> @opensaas/stack-ui@0.
|
|
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.
|
|
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
|
-
-
|
|
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 (
|
|
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
|
-
-
|
|
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 {
|
|
198
|
-
import config from '
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
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
|
|
36
|
-
import {
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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":"
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
13
|
-
|
|
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
|
-
}) })),
|
|
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,
|
|
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 {
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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;
|
|
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;
|
|
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
|
}
|