@opensaas/stack-ui 0.21.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 +64 -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
@@ -4,7 +4,7 @@ import { ItemFormClient } from './ItemFormClient.js'
4
4
  import { formatListName } from '../lib/utils.js'
5
5
  import type { ServerActionInput } from '../server/types.js'
6
6
  import { type AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
7
- import { serializeFieldConfigs } from '../lib/serializeFieldConfig.js'
7
+ import { buildRelationshipInclude, prepareItemForm } from '../lib/prepareItemForm.js'
8
8
 
9
9
  export interface ItemFormProps {
10
10
  context: AccessContext<unknown>
@@ -48,16 +48,8 @@ export async function ItemForm({
48
48
  let itemData: Record<string, unknown> = {}
49
49
  if (mode === 'edit' && itemId) {
50
50
  try {
51
- // Build include object for relationships
52
- const includeRelationships: Record<string, boolean> = {}
53
- for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
54
- const fieldConfigAny = fieldConfig as { type: string }
55
- if (fieldConfigAny.type === 'relationship') {
56
- includeRelationships[fieldName] = true
57
- }
58
- }
59
-
60
51
  // Fetch item with relationships included
52
+ const includeRelationships = buildRelationshipInclude(listConfig)
61
53
  const delegate = context.db[getDbKey(listKey)]
62
54
  if (delegate?.findUnique) {
63
55
  itemData = await delegate.findUnique({
@@ -90,72 +82,14 @@ export async function ItemForm({
90
82
  }
91
83
  }
92
84
 
93
- // Fetch relationship options for all relationship fields
94
- const relationshipData: Record<string, Array<{ id: string; label: string }>> = {}
95
- for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
96
- // Check if field is a relationship type by checking the discriminated union
97
- const fieldConfigAny = fieldConfig as { type: string; ref?: string }
98
- if (fieldConfigAny.type === 'relationship') {
99
- const ref = fieldConfigAny.ref
100
- if (ref) {
101
- // Parse ref format: "ListName.fieldName"
102
- const relatedListName = ref.split('.')[0]
103
- const relatedListConfig = config.lists[relatedListName]
104
-
105
- if (relatedListConfig) {
106
- try {
107
- const dbContext = context.db
108
- const delegate = dbContext[getDbKey(relatedListName)]
109
- const relatedItems = delegate?.findMany ? await delegate.findMany({}) : []
110
-
111
- // Use 'name' field as label if it exists, otherwise use 'id'
112
- relationshipData[fieldName] = relatedItems.map((item: Record<string, unknown>) => ({
113
- id: item.id as string,
114
- label: ((item.name || item.title || item.id) as string) || '',
115
- }))
116
- } catch (error) {
117
- console.error(`Failed to fetch relationship items for ${fieldName}:`, error)
118
- relationshipData[fieldName] = []
119
- }
120
- }
121
- }
122
- }
123
- }
124
-
125
- // Serialize field configs to remove non-serializable properties
126
- const serializableFields = serializeFieldConfigs(listConfig.fields)
127
-
128
- // Transform relationship data in itemData from objects to IDs for form
129
- // Also apply valueForClientSerialization transformation
130
- const formData = { ...itemData }
131
- for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
132
- const fieldConfigAny = fieldConfig as {
133
- type: string
134
- many?: boolean
135
- ui?: Record<string, unknown>
136
- }
137
- if (fieldConfigAny.type === 'relationship' && formData[fieldName]) {
138
- const value = formData[fieldName]
139
- if (fieldConfigAny.many && Array.isArray(value)) {
140
- // Many relationship: extract IDs from array of objects
141
- formData[fieldName] = value.map((item: Record<string, unknown>) => item.id as string)
142
- } else if (value && typeof value === 'object' && 'id' in value) {
143
- // Single relationship: extract ID from object
144
- formData[fieldName] = (value as Record<string, unknown>).id as string
145
- }
146
- }
147
-
148
- // Apply valueForClientSerialization if defined
149
- if (
150
- fieldConfigAny.ui?.valueForClientSerialization &&
151
- typeof fieldConfigAny.ui.valueForClientSerialization === 'function'
152
- ) {
153
- const transformer = fieldConfigAny.ui.valueForClientSerialization as (args: {
154
- value: unknown
155
- }) => unknown
156
- formData[fieldName] = transformer({ value: formData[fieldName] })
157
- }
158
- }
85
+ // Fetch relationship options, serialize field configs, and transform the
86
+ // record into client-ready form data (shared with the singleton editor).
87
+ const { serializableFields, initialData, relationshipData } = await prepareItemForm(
88
+ context,
89
+ config,
90
+ listConfig,
91
+ itemData,
92
+ )
159
93
 
160
94
  return (
161
95
  <div className="p-8 max-w-4xl">
@@ -187,7 +121,7 @@ export async function ItemForm({
187
121
  urlKey={urlKey}
188
122
  mode={mode}
189
123
  fields={serializableFields}
190
- initialData={JSON.parse(JSON.stringify(formData))}
124
+ initialData={initialData}
191
125
  itemId={itemId}
192
126
  basePath={basePath}
193
127
  serverAction={serverAction}
@@ -21,6 +21,13 @@ export interface ItemFormClientProps {
21
21
  basePath: string
22
22
  serverAction: (input: ServerActionInput) => Promise<unknown>
23
23
  relationshipData?: Record<string, Array<{ id: string; label: string }>>
24
+ /**
25
+ * Whether to render the delete affordance in edit mode. Defaults to `true`.
26
+ * Singletons set this to `false` — a singleton has exactly one record that
27
+ * can't be deleted (core blocks `delete` even in sudo), so the control is
28
+ * suppressed for UX hygiene.
29
+ */
30
+ canDelete?: boolean
24
31
  }
25
32
 
26
33
  /**
@@ -58,6 +65,7 @@ export function ItemFormClient({
58
65
  basePath,
59
66
  serverAction,
60
67
  relationshipData = {},
68
+ canDelete = true,
61
69
  }: ItemFormClientProps) {
62
70
  const router = useRouter()
63
71
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
@@ -162,8 +170,8 @@ export function ItemFormClient({
162
170
  </Button>
163
171
  </div>
164
172
 
165
- {/* Delete Button (Edit Mode Only) */}
166
- {mode === 'edit' && itemId && (
173
+ {/* Delete Button (Edit Mode Only; suppressed for singletons) */}
174
+ {mode === 'edit' && itemId && canDelete && (
167
175
  <Button
168
176
  type="button"
169
177
  variant="destructive"
@@ -22,7 +22,12 @@ export function Navigation({
22
22
  currentPath = '',
23
23
  onSignOut,
24
24
  }: NavigationProps) {
25
- const lists = Object.keys(config.lists || {})
25
+ const allLists = Object.keys(config.lists || {})
26
+ // Split lists into standard lists (under "Lists") and singletons (under
27
+ // "Settings"). A singleton edits a single record, so it belongs in a distinct
28
+ // Settings group rather than the standard list grid.
29
+ const lists = allLists.filter((listKey) => !config.lists[listKey]?.isSingleton)
30
+ const singletons = allLists.filter((listKey) => config.lists[listKey]?.isSingleton)
26
31
 
27
32
  return (
28
33
  <nav className="w-64 border-r border-border bg-card h-screen sticky top-0 flex flex-col">
@@ -90,6 +95,58 @@ export function Navigation({
90
95
  })}
91
96
  </>
92
97
  )}
98
+
99
+ {singletons.length > 0 && (
100
+ <>
101
+ <div className="pt-4 pb-2 px-3">
102
+ <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
103
+ Settings
104
+ </p>
105
+ </div>
106
+ {singletons.map((listKey) => {
107
+ const urlKey = getUrlKey(listKey)
108
+ const isActive = currentPath.startsWith(`/${urlKey}`)
109
+ return (
110
+ <Link
111
+ key={listKey}
112
+ href={`${basePath}/${urlKey}`}
113
+ className={`block px-3 py-2.5 rounded-lg text-sm font-medium transition-all relative overflow-hidden group ${
114
+ isActive
115
+ ? 'bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-lg shadow-primary/25'
116
+ : 'text-foreground hover:bg-accent/50 hover:text-accent-foreground'
117
+ }`}
118
+ >
119
+ {isActive && (
120
+ <div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 animate-pulse" />
121
+ )}
122
+ <span className="relative flex items-center gap-2">
123
+ <svg
124
+ className="w-4 h-4 opacity-60 group-hover:opacity-100 transition-opacity"
125
+ fill="none"
126
+ stroke="currentColor"
127
+ viewBox="0 0 24 24"
128
+ aria-hidden="true"
129
+ >
130
+ <path
131
+ strokeLinecap="round"
132
+ strokeLinejoin="round"
133
+ strokeWidth={2}
134
+ 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"
135
+ />
136
+ <path
137
+ strokeLinecap="round"
138
+ strokeLinejoin="round"
139
+ strokeWidth={2}
140
+ d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
141
+ />
142
+ </svg>
143
+ {formatListName(listKey)}
144
+ </span>
145
+ </Link>
146
+ )
147
+ })}
148
+ </>
149
+ )}
93
150
  </div>
94
151
  </div>
95
152
 
@@ -0,0 +1,228 @@
1
+ import * as React from 'react'
2
+ import Link from 'next/link.js'
3
+ import { ItemFormClient } from './ItemFormClient.js'
4
+ import { formatListName } from '../lib/utils.js'
5
+ import type { ServerActionInput } from '../server/types.js'
6
+ import { type AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
7
+ import { prepareItemForm } from '../lib/prepareItemForm.js'
8
+ import { isOperationPotentiallyAllowed } from '../lib/operationAccess.js'
9
+
10
+ export interface SingletonViewProps {
11
+ context: AccessContext<unknown>
12
+ config: OpenSaasConfig
13
+ listKey: string
14
+ basePath?: string
15
+ // Server action can return any shape depending on the list item type
16
+ serverAction: (input: ServerActionInput) => Promise<unknown>
17
+ }
18
+
19
+ /**
20
+ * Singleton editor — renders a single-record edit form for a list configured
21
+ * with `isSingleton: true`.
22
+ *
23
+ * Resolves the record via the singleton `get()` operation (which auto-creates
24
+ * the row with field defaults when absent, unless `autoCreate: false`), then
25
+ * reuses the same `ItemFormClient` + serialization path as `ItemForm` so the
26
+ * existing field rendering, validation, and `serverAction` save flow apply.
27
+ *
28
+ * A `null` from `get()` is ambiguous at the boundary — it means EITHER an
29
+ * `autoCreate: false` singleton with no row yet, OR that `query` access is
30
+ * denied (access-controlled reads return null/[] silently). We disambiguate
31
+ * using the list's operation-level access:
32
+ *
33
+ * - `query` denied → friendly "no access" message (never an editable form).
34
+ * - `query` allowed + `create` allowed → a create-on-first-save form
35
+ * (`ItemFormClient` in `mode="create"`); core assigns the singleton `id` and
36
+ * enforces the single-record constraint on create.
37
+ * - `query` allowed + `create` denied → friendly "no record yet" message.
38
+ *
39
+ * An update-denied singleton still renders the edit form (the happy path), but
40
+ * the save fails gracefully: the server action's `update` access check returns
41
+ * a denied envelope, which `ItemFormClient` surfaces as an error.
42
+ *
43
+ * Server Component that fetches data and sets up actions.
44
+ */
45
+ export async function SingletonView({
46
+ context,
47
+ config,
48
+ listKey,
49
+ basePath = '/admin',
50
+ serverAction,
51
+ }: SingletonViewProps) {
52
+ const listConfig = config.lists[listKey]
53
+ const urlKey = getUrlKey(listKey)
54
+
55
+ if (!listConfig) {
56
+ return (
57
+ <div className="p-8">
58
+ <div className="bg-destructive/10 border border-destructive text-destructive rounded-lg p-6">
59
+ <h2 className="text-lg font-semibold mb-2">List not found</h2>
60
+ <p>The list &quot;{listKey}&quot; does not exist in your configuration.</p>
61
+ </div>
62
+ </div>
63
+ )
64
+ }
65
+
66
+ // Resolve the singleton record. `get()` auto-creates with field defaults when
67
+ // absent (the default), so a record is the common case. It returns null when
68
+ // either `autoCreate: false` with no row yet, OR `query` access is denied —
69
+ // these are indistinguishable here, so we disambiguate via access below.
70
+ let record: Record<string, unknown> | null = null
71
+ try {
72
+ const delegate = context.db[getDbKey(listKey)]
73
+ if (delegate?.get) {
74
+ record = await delegate.get()
75
+ }
76
+ } catch (error) {
77
+ console.error(`Failed to resolve singleton ${listKey}:`, error)
78
+ }
79
+
80
+ if (!record) {
81
+ // A null `get()` is ambiguous (autoCreate:false-empty vs query-denied).
82
+ // Evaluate operation access to choose the safe affordance.
83
+ const accessArgs = { session: context.session, context }
84
+ const canQuery = await isOperationPotentiallyAllowed(
85
+ listConfig.access?.operation,
86
+ 'query',
87
+ accessArgs,
88
+ )
89
+
90
+ // Query denied → the session cannot read this singleton at all. Show a
91
+ // friendly message; never an editable/create form.
92
+ if (!canQuery) {
93
+ return (
94
+ <div className="p-8 max-w-4xl">
95
+ <div className="mb-8">
96
+ <h1 className="text-3xl font-bold">{formatListName(listKey)}</h1>
97
+ </div>
98
+ <div className="bg-muted/50 border border-border rounded-lg p-6">
99
+ <p className="text-muted-foreground">
100
+ You don&apos;t have access to {formatListName(listKey)}.
101
+ </p>
102
+ </div>
103
+ </div>
104
+ )
105
+ }
106
+
107
+ // Query allowed but no row → an `autoCreate: false` singleton. Offer a
108
+ // create-on-first-save form only when `create` is actually permitted.
109
+ const canCreate = await isOperationPotentiallyAllowed(
110
+ listConfig.access?.operation,
111
+ 'create',
112
+ accessArgs,
113
+ )
114
+
115
+ if (!canCreate) {
116
+ return (
117
+ <div className="p-8 max-w-4xl">
118
+ <div className="mb-8">
119
+ <h1 className="text-3xl font-bold">{formatListName(listKey)}</h1>
120
+ </div>
121
+ <div className="bg-muted/50 border border-border rounded-lg p-6">
122
+ <p className="text-muted-foreground">
123
+ There is no {formatListName(listKey)} record yet.
124
+ </p>
125
+ </div>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ // Create-on-first-save: render the form in create mode with an empty record.
131
+ // The save goes through the existing `serverAction` create path; core
132
+ // assigns the singleton `id` (always `1`) and enforces the single-record
133
+ // constraint, so the form sends only the user-entered field data.
134
+ const {
135
+ serializableFields: createFields,
136
+ initialData: createInitialData,
137
+ relationshipData: createRelationshipData,
138
+ } = await prepareItemForm(context, config, listConfig, {})
139
+
140
+ return (
141
+ <div className="p-8 max-w-4xl">
142
+ {/* Header — a singleton has no list view, so link back to the dashboard. */}
143
+ <div className="mb-8">
144
+ <Link
145
+ href={basePath}
146
+ className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
147
+ >
148
+ <svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
149
+ <path
150
+ strokeLinecap="round"
151
+ strokeLinejoin="round"
152
+ strokeWidth={2}
153
+ d="M15 19l-7-7 7-7"
154
+ />
155
+ </svg>
156
+ Back to dashboard
157
+ </Link>
158
+ <h1 className="text-3xl font-bold">Create {formatListName(listKey)}</h1>
159
+ </div>
160
+
161
+ {/* Create-on-first-save form */}
162
+ <div className="bg-card border border-border rounded-lg p-6">
163
+ <ItemFormClient
164
+ listKey={listKey}
165
+ urlKey={urlKey}
166
+ mode="create"
167
+ fields={createFields}
168
+ initialData={createInitialData}
169
+ basePath={basePath}
170
+ serverAction={serverAction}
171
+ relationshipData={createRelationshipData}
172
+ canDelete={false}
173
+ />
174
+ </div>
175
+ </div>
176
+ )
177
+ }
178
+
179
+ // Reuse the shared field-serialization + relationship-data logic so the
180
+ // singleton editor stays in lockstep with the regular item form.
181
+ const { serializableFields, initialData, relationshipData } = await prepareItemForm(
182
+ context,
183
+ config,
184
+ listConfig,
185
+ record,
186
+ )
187
+
188
+ const itemId = record.id as string
189
+
190
+ return (
191
+ <div className="p-8 max-w-4xl">
192
+ {/* Header — a singleton has no list view, so link back to the dashboard. */}
193
+ <div className="mb-8">
194
+ <Link
195
+ href={basePath}
196
+ className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
197
+ >
198
+ <svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
199
+ <path
200
+ strokeLinecap="round"
201
+ strokeLinejoin="round"
202
+ strokeWidth={2}
203
+ d="M15 19l-7-7 7-7"
204
+ />
205
+ </svg>
206
+ Back to dashboard
207
+ </Link>
208
+ <h1 className="text-3xl font-bold">Edit {formatListName(listKey)}</h1>
209
+ </div>
210
+
211
+ {/* Form */}
212
+ <div className="bg-card border border-border rounded-lg p-6">
213
+ <ItemFormClient
214
+ listKey={listKey}
215
+ urlKey={urlKey}
216
+ mode="edit"
217
+ fields={serializableFields}
218
+ initialData={initialData}
219
+ itemId={itemId}
220
+ basePath={basePath}
221
+ serverAction={serverAction}
222
+ relationshipData={relationshipData}
223
+ canDelete={false}
224
+ />
225
+ </div>
226
+ </div>
227
+ )
228
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export { ListView } from './components/ListView.js'
7
7
  export { ListViewClient } from './components/ListViewClient.js'
8
8
  export { ItemForm } from './components/ItemForm.js'
9
9
  export { ItemFormClient } from './components/ItemFormClient.js'
10
+ export { SingletonView } from './components/SingletonView.js'
10
11
  export { ConfirmDialog } from './components/ConfirmDialog.js'
11
12
  export { LoadingSpinner } from './components/LoadingSpinner.js'
12
13
  export { SkeletonLoader, TableSkeleton, FormSkeleton } from './components/SkeletonLoader.js'
@@ -35,6 +36,7 @@ export type { ListViewProps } from './components/ListView.js'
35
36
  export type { ListViewClientProps } from './components/ListViewClient.js'
36
37
  export type { ItemFormProps } from './components/ItemForm.js'
37
38
  export type { ItemFormClientProps } from './components/ItemFormClient.js'
39
+ export type { SingletonViewProps } from './components/SingletonView.js'
38
40
  export type { ConfirmDialogProps } from './components/ConfirmDialog.js'
39
41
  export type { LoadingSpinnerProps } from './components/LoadingSpinner.js'
40
42
  export type { SkeletonLoaderProps } from './components/SkeletonLoader.js'
@@ -0,0 +1,53 @@
1
+ import type { AccessContext, OperationAccess, Session } from '@opensaas/stack-core'
2
+
3
+ /**
4
+ * The names of the operation-level access checks we evaluate in the UI.
5
+ */
6
+ export type OperationAccessName = 'query' | 'create' | 'update' | 'delete'
7
+
8
+ /**
9
+ * Evaluate a list's operation-level access control for the current session,
10
+ * coercing the result to a single "is this operation potentially permitted?"
11
+ * boolean.
12
+ *
13
+ * This mirrors how the core access engine treats a result:
14
+ * - `false` → denied
15
+ * - `true` → permitted
16
+ * - a filter object → permitted, but scoped to matching rows
17
+ *
18
+ * Because the UI cannot know whether a returned filter would match the
19
+ * (possibly not-yet-created) singleton row, a filter is treated as "potentially
20
+ * permitted" — the actual operation still runs through the access engine, which
21
+ * re-applies the filter. This helper only decides which affordance to render.
22
+ *
23
+ * Access functions are user-defined and may throw (e.g. they assume a session
24
+ * shape that anonymous requests don't have). A throw is treated as **denied** —
25
+ * the safest outcome, so a misbehaving access function never exposes an
26
+ * editable/create form to a session that might not be allowed.
27
+ *
28
+ * No access function configured for the operation is also treated as denied,
29
+ * matching the core engine's deny-by-default (`checkAccess` returns `false`
30
+ * when `accessControl` is undefined).
31
+ */
32
+ export async function isOperationPotentiallyAllowed(
33
+ access: OperationAccess | undefined,
34
+ operation: OperationAccessName,
35
+ args: { session: Session | null; context: AccessContext<unknown> },
36
+ ): Promise<boolean> {
37
+ const accessControl = access?.[operation]
38
+ // Deny by default — no rule means no access (matches the core engine).
39
+ if (!accessControl) return false
40
+
41
+ try {
42
+ const result = await accessControl({
43
+ session: args.session,
44
+ // The access engine passes the same context through to the function.
45
+ context: args.context,
46
+ })
47
+ // `false` denies; `true` or a filter object both mean "potentially allowed".
48
+ return result !== false
49
+ } catch {
50
+ // A throwing access function is treated as denied — never widen access on error.
51
+ return false
52
+ }
53
+ }
@@ -0,0 +1,121 @@
1
+ import { type AccessContext, getDbKey, OpenSaasConfig } from '@opensaas/stack-core'
2
+ import type { ListConfig } from '@opensaas/stack-core'
3
+ import { serializeFieldConfigs, type SerializableFieldConfig } from './serializeFieldConfig.js'
4
+
5
+ /**
6
+ * Data prepared on the server for the client item form.
7
+ *
8
+ * Everything here is JSON-serializable and contains only what `ItemFormClient`
9
+ * needs to render — see the repo rule on minimal, serializable client props.
10
+ */
11
+ export interface PreparedItemForm {
12
+ /** Field configs stripped of functions/non-serializable props. */
13
+ serializableFields: Record<string, SerializableFieldConfig>
14
+ /** Initial form values (relationships reduced to ids, client transforms applied). */
15
+ initialData: Record<string, unknown>
16
+ /** Relationship options keyed by field name. */
17
+ relationshipData: Record<string, Array<{ id: string; label: string }>>
18
+ }
19
+
20
+ /**
21
+ * Build the `include` object needed to hydrate relationship fields when
22
+ * fetching an item for editing.
23
+ */
24
+ export function buildRelationshipInclude(
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig is generic over TypeInfo
26
+ listConfig: ListConfig<any>,
27
+ ): Record<string, boolean> {
28
+ const includeRelationships: Record<string, boolean> = {}
29
+ for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
30
+ if ((fieldConfig as { type: string }).type === 'relationship') {
31
+ includeRelationships[fieldName] = true
32
+ }
33
+ }
34
+ return includeRelationships
35
+ }
36
+
37
+ /**
38
+ * Prepare the serializable props for `ItemFormClient` from an already-fetched
39
+ * record (or an empty object for create).
40
+ *
41
+ * This is shared by `ItemForm` (which fetches via `findUnique`) and
42
+ * `SingletonView` (which resolves via the singleton `get()`), so the
43
+ * relationship/serialization logic lives in exactly one place.
44
+ */
45
+ export async function prepareItemForm(
46
+ context: AccessContext<unknown>,
47
+ config: OpenSaasConfig,
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig is generic over TypeInfo
49
+ listConfig: ListConfig<any>,
50
+ itemData: Record<string, unknown>,
51
+ ): Promise<PreparedItemForm> {
52
+ // Fetch relationship options for all relationship fields
53
+ const relationshipData: Record<string, Array<{ id: string; label: string }>> = {}
54
+ for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
55
+ const fieldConfigAny = fieldConfig as { type: string; ref?: string }
56
+ if (fieldConfigAny.type === 'relationship') {
57
+ const ref = fieldConfigAny.ref
58
+ if (ref) {
59
+ // Parse ref format: "ListName.fieldName"
60
+ const relatedListName = ref.split('.')[0]
61
+ const relatedListConfig = config.lists[relatedListName]
62
+
63
+ if (relatedListConfig) {
64
+ try {
65
+ const delegate = context.db[getDbKey(relatedListName)]
66
+ const relatedItems = delegate?.findMany ? await delegate.findMany({}) : []
67
+
68
+ // Use 'name' field as label if it exists, otherwise use 'id'
69
+ relationshipData[fieldName] = relatedItems.map((item: Record<string, unknown>) => ({
70
+ id: item.id as string,
71
+ label: ((item.name || item.title || item.id) as string) || '',
72
+ }))
73
+ } catch (error) {
74
+ console.error(`Failed to fetch relationship items for ${fieldName}:`, error)
75
+ relationshipData[fieldName] = []
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ // Serialize field configs to remove non-serializable properties
83
+ const serializableFields = serializeFieldConfigs(listConfig.fields)
84
+
85
+ // Transform relationship data in itemData from objects to IDs for form
86
+ // Also apply valueForClientSerialization transformation
87
+ const formData = { ...itemData }
88
+ for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
89
+ const fieldConfigAny = fieldConfig as {
90
+ type: string
91
+ many?: boolean
92
+ ui?: Record<string, unknown>
93
+ }
94
+ if (fieldConfigAny.type === 'relationship' && formData[fieldName]) {
95
+ const value = formData[fieldName]
96
+ if (fieldConfigAny.many && Array.isArray(value)) {
97
+ // Many relationship: extract IDs from array of objects
98
+ formData[fieldName] = value.map((item: Record<string, unknown>) => item.id as string)
99
+ } else if (value && typeof value === 'object' && 'id' in value) {
100
+ // Single relationship: extract ID from object
101
+ formData[fieldName] = (value as Record<string, unknown>).id as string
102
+ }
103
+ }
104
+
105
+ // Apply valueForClientSerialization if defined
106
+ if (
107
+ fieldConfigAny.ui?.valueForClientSerialization &&
108
+ typeof fieldConfigAny.ui.valueForClientSerialization === 'function'
109
+ ) {
110
+ const transformer = fieldConfigAny.ui.valueForClientSerialization as (args: {
111
+ value: unknown
112
+ }) => unknown
113
+ formData[fieldName] = transformer({ value: formData[fieldName] })
114
+ }
115
+ }
116
+
117
+ // JSON round-trip ensures only serializable data crosses the client boundary
118
+ const initialData = JSON.parse(JSON.stringify(formData)) as Record<string, unknown>
119
+
120
+ return { serializableFields, initialData, relationshipData }
121
+ }