@opensaas/stack-ui 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +95 -0
  3. package/CLAUDE.md +46 -9
  4. package/README.md +41 -10
  5. package/dist/components/AdminUI.d.ts +1 -1
  6. package/dist/components/AdminUI.d.ts.map +1 -1
  7. package/dist/components/AdminUI.js +23 -3
  8. package/dist/components/Dashboard.d.ts.map +1 -1
  9. package/dist/components/Dashboard.js +13 -4
  10. package/dist/components/ItemForm.d.ts.map +1 -1
  11. package/dist/components/ItemForm.js +6 -65
  12. package/dist/components/ItemFormClient.d.ts +8 -1
  13. package/dist/components/ItemFormClient.d.ts.map +1 -1
  14. package/dist/components/ItemFormClient.js +2 -2
  15. package/dist/components/ListView.d.ts +14 -1
  16. package/dist/components/ListView.d.ts.map +1 -1
  17. package/dist/components/ListView.js +2 -2
  18. package/dist/components/ListViewClient.d.ts +10 -1
  19. package/dist/components/ListViewClient.d.ts.map +1 -1
  20. package/dist/components/ListViewClient.js +3 -3
  21. package/dist/components/Navigation.d.ts.map +1 -1
  22. package/dist/components/Navigation.js +12 -1
  23. package/dist/components/SingletonView.d.ts +37 -0
  24. package/dist/components/SingletonView.d.ts.map +1 -0
  25. package/dist/components/SingletonView.js +82 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/lib/operationAccess.d.ts +34 -0
  30. package/dist/lib/operationAccess.d.ts.map +1 -0
  31. package/dist/lib/operationAccess.js +43 -0
  32. package/dist/lib/prepareItemForm.d.ts +35 -0
  33. package/dist/lib/prepareItemForm.d.ts.map +1 -0
  34. package/dist/lib/prepareItemForm.js +85 -0
  35. package/dist/styles/globals.css +12 -0
  36. package/package.json +2 -2
  37. package/src/components/AdminUI.tsx +36 -2
  38. package/src/components/Dashboard.tsx +108 -5
  39. package/src/components/ItemForm.tsx +11 -77
  40. package/src/components/ItemFormClient.tsx +10 -2
  41. package/src/components/ListView.tsx +16 -0
  42. package/src/components/ListViewClient.tsx +9 -2
  43. package/src/components/Navigation.tsx +58 -1
  44. package/src/components/SingletonView.tsx +228 -0
  45. package/src/index.ts +2 -0
  46. package/src/lib/operationAccess.ts +53 -0
  47. package/src/lib/prepareItemForm.ts +121 -0
  48. package/tests/components/AdminUIListView.test.tsx +134 -0
  49. package/tests/components/AdminUISingleton.test.tsx +296 -0
  50. package/tests/components/AdminUISingletonSuppress.test.tsx +259 -0
  51. package/tests/components/ListViewClient.test.tsx +60 -0
  52. package/tests/components/SingletonNavDashboard.test.tsx +141 -0
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
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import * as React from 'react'
3
+ import type { AccessContext, OpenSaasConfig } from '@opensaas/stack-core'
4
+ import { list } from '@opensaas/stack-core'
5
+ import { text, timestamp } from '@opensaas/stack-core/fields'
6
+ import { AdminUI } from '../../src/components/AdminUI.js'
7
+ import { ListView } from '../../src/components/ListView.js'
8
+
9
+ // Mock Next.js navigation — client components call useRouter().
10
+ const mockPush = vi.fn()
11
+ const mockRefresh = vi.fn()
12
+ vi.mock('next/navigation.js', () => ({
13
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
14
+ }))
15
+
16
+ vi.mock('next/link.js', () => ({
17
+ default: ({ children, href }: { children: React.ReactNode; href: string }) => (
18
+ <a href={href}>{children}</a>
19
+ ),
20
+ }))
21
+
22
+ interface DelegateStub {
23
+ findMany?: (args: unknown) => Promise<Array<Record<string, unknown>>>
24
+ count?: (args: unknown) => Promise<number>
25
+ }
26
+
27
+ function makeContext(delegates: Record<string, DelegateStub>): AccessContext<unknown> {
28
+ const context = {
29
+ db: delegates,
30
+ session: null,
31
+ storage: {},
32
+ plugins: {},
33
+ _isSudo: false,
34
+ _resolveOutputCounter: { depth: 0 },
35
+ }
36
+ return context as unknown as AccessContext<unknown>
37
+ }
38
+
39
+ const noopServerAction = vi.fn(async () => ({ success: true }))
40
+
41
+ /**
42
+ * AdminUI returns <> {style?} <div><Navigation/><main>{content}</main></div> </>.
43
+ * Drill into <main> to recover the routed content element (the <ListView /> the
44
+ * router chose for the bare [list] route) so we can inspect the props AdminUI
45
+ * passed to it.
46
+ */
47
+ function routedContent(tree: React.ReactNode): React.ReactElement {
48
+ const fragment = tree as React.ReactElement<{ children: React.ReactNode }>
49
+ const children = React.Children.toArray(fragment.props.children)
50
+ const wrapper = children.find(
51
+ (child): child is React.ReactElement<{ children: React.ReactNode }> =>
52
+ React.isValidElement(child) && child.type === 'div',
53
+ )
54
+ if (!wrapper) throw new Error('AdminUI layout wrapper not found')
55
+ const main = React.Children.toArray(wrapper.props.children).find(
56
+ (child): child is React.ReactElement<{ children: React.ReactNode }> =>
57
+ React.isValidElement(child) && child.type === 'main',
58
+ )
59
+ if (!main) throw new Error('AdminUI <main> not found')
60
+ return main.props.children as React.ReactElement
61
+ }
62
+
63
+ describe('AdminUI ui.listView wiring', () => {
64
+ it('passes initialColumns + initialSort from ui.listView to ListView', async () => {
65
+ const config: OpenSaasConfig = {
66
+ db: { provider: 'sqlite', url: 'file:./test.db' },
67
+ lists: {
68
+ Post: list({
69
+ fields: {
70
+ title: text(),
71
+ status: text(),
72
+ createdAt: timestamp(),
73
+ },
74
+ ui: {
75
+ listView: {
76
+ initialColumns: ['title', 'status'],
77
+ initialSort: { field: 'createdAt', direction: 'desc' },
78
+ },
79
+ },
80
+ }),
81
+ },
82
+ }
83
+
84
+ const context = makeContext({
85
+ post: { findMany: vi.fn(async () => []), count: vi.fn(async () => 0) },
86
+ })
87
+
88
+ const tree = await AdminUI({
89
+ context,
90
+ config,
91
+ params: ['post'],
92
+ basePath: '/admin',
93
+ serverAction: noopServerAction,
94
+ })
95
+
96
+ const content = routedContent(tree)
97
+ expect(content.type).toBe(ListView)
98
+ // initialColumns drives the `columns` prop (selection + order).
99
+ expect(content.props.columns).toEqual(['title', 'status'])
100
+ // initialSort flows through as the default sort.
101
+ expect(content.props.initialSort).toEqual({ field: 'createdAt', direction: 'desc' })
102
+ })
103
+
104
+ it('passes undefined columns + initialSort when ui.listView is absent', async () => {
105
+ const config: OpenSaasConfig = {
106
+ db: { provider: 'sqlite', url: 'file:./test.db' },
107
+ lists: {
108
+ Post: list({
109
+ fields: {
110
+ title: text(),
111
+ },
112
+ }),
113
+ },
114
+ }
115
+
116
+ const context = makeContext({
117
+ post: { findMany: vi.fn(async () => []), count: vi.fn(async () => 0) },
118
+ })
119
+
120
+ const tree = await AdminUI({
121
+ context,
122
+ config,
123
+ params: ['post'],
124
+ basePath: '/admin',
125
+ serverAction: noopServerAction,
126
+ })
127
+
128
+ const content = routedContent(tree)
129
+ expect(content.type).toBe(ListView)
130
+ // Absent config → current behaviour unchanged (no column override, no default sort).
131
+ expect(content.props.columns).toBeUndefined()
132
+ expect(content.props.initialSort).toBeUndefined()
133
+ })
134
+ })
@@ -0,0 +1,296 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import * as React from 'react'
3
+ import { render, screen } from '@testing-library/react'
4
+ import type { AccessContext, OpenSaasConfig } from '@opensaas/stack-core'
5
+ import { list } from '@opensaas/stack-core'
6
+ import { text } from '@opensaas/stack-core/fields'
7
+ import { AdminUI } from '../../src/components/AdminUI.js'
8
+ import { SingletonView } from '../../src/components/SingletonView.js'
9
+ import { ListView } from '../../src/components/ListView.js'
10
+
11
+ // Mock Next.js navigation — the form/table client components call useRouter().
12
+ const mockPush = vi.fn()
13
+ const mockRefresh = vi.fn()
14
+ vi.mock('next/navigation.js', () => ({
15
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
16
+ }))
17
+
18
+ // next/link renders an anchor; happy-dom can render it directly.
19
+ vi.mock('next/link.js', () => ({
20
+ default: ({ children, href }: { children: React.ReactNode; href: string }) => (
21
+ <a href={href}>{children}</a>
22
+ ),
23
+ }))
24
+
25
+ /**
26
+ * A config with one singleton list (Settings) and one ordinary list (Post).
27
+ * Built with the real `list()` + field builders so the components see the
28
+ * same field shapes they would in a real app.
29
+ */
30
+ const config: OpenSaasConfig = {
31
+ db: { provider: 'sqlite', url: 'file:./test.db' },
32
+ lists: {
33
+ Settings: list({
34
+ isSingleton: true,
35
+ fields: {
36
+ siteName: text(),
37
+ },
38
+ }),
39
+ Post: list({
40
+ fields: {
41
+ title: text(),
42
+ },
43
+ }),
44
+ },
45
+ }
46
+
47
+ interface DelegateStub {
48
+ get?: () => Promise<Record<string, unknown> | null>
49
+ findUnique?: (args: unknown) => Promise<Record<string, unknown> | null>
50
+ findMany?: (args: unknown) => Promise<Array<Record<string, unknown>>>
51
+ count?: (args: unknown) => Promise<number>
52
+ }
53
+
54
+ /**
55
+ * Build a minimal AccessContext whose db delegates return canned data.
56
+ * Only the methods the views call are implemented.
57
+ */
58
+ function makeContext(delegates: Record<string, DelegateStub>): AccessContext<unknown> {
59
+ const context = {
60
+ db: delegates,
61
+ session: null,
62
+ storage: {},
63
+ plugins: {},
64
+ _isSudo: false,
65
+ _resolveOutputCounter: { depth: 0 },
66
+ }
67
+ // Cast: this is a stub for rendering tests, not a full Prisma-backed context.
68
+ return context as unknown as AccessContext<unknown>
69
+ }
70
+
71
+ const noopServerAction = vi.fn(async () => ({ success: true }))
72
+
73
+ // React represents `<Component />` as an element whose `.type` is the component
74
+ // function. The router picks the component but does not await it (nested server
75
+ // components resolve during streaming, not in this unit), so we assert the
76
+ // branch by inspecting which component AdminUI chose for the bare [list] route.
77
+ function routedContent(tree: React.ReactNode): React.ReactElement {
78
+ // AdminUI returns <> {style?} <div><Navigation/><main>{content}</main></div> </>
79
+ const fragment = tree as React.ReactElement<{ children: React.ReactNode }>
80
+ const children = React.Children.toArray(fragment.props.children)
81
+ const wrapper = children.find(
82
+ (child): child is React.ReactElement<{ children: React.ReactNode }> =>
83
+ React.isValidElement(child) && child.type === 'div',
84
+ )
85
+ if (!wrapper) throw new Error('AdminUI layout wrapper not found')
86
+ const main = React.Children.toArray(wrapper.props.children).find(
87
+ (child): child is React.ReactElement<{ children: React.ReactNode }> =>
88
+ React.isValidElement(child) && child.type === 'main',
89
+ )
90
+ if (!main) throw new Error('AdminUI <main> not found')
91
+ return main.props.children as React.ReactElement
92
+ }
93
+
94
+ describe('AdminUI singleton routing', () => {
95
+ it('routes a singleton bare [list] to SingletonView, a non-singleton to ListView', async () => {
96
+ const context = makeContext({
97
+ settings: { get: vi.fn(async () => ({ id: '1', siteName: 'My Site' })) },
98
+ post: { findMany: vi.fn(async () => []), count: vi.fn(async () => 0) },
99
+ })
100
+
101
+ const singletonTree = await AdminUI({
102
+ context,
103
+ config,
104
+ params: ['settings'],
105
+ basePath: '/admin',
106
+ serverAction: noopServerAction,
107
+ })
108
+ expect(routedContent(singletonTree).type).toBe(SingletonView)
109
+
110
+ const listTree = await AdminUI({
111
+ context,
112
+ config,
113
+ params: ['post'],
114
+ basePath: '/admin',
115
+ serverAction: noopServerAction,
116
+ })
117
+ expect(routedContent(listTree).type).toBe(ListView)
118
+ })
119
+
120
+ it('renders a single-record editor for a singleton list (SingletonView)', async () => {
121
+ const singletonGet = vi.fn(async () => ({ id: '1', siteName: 'My Site' }))
122
+ const context = makeContext({ settings: { get: singletonGet } })
123
+
124
+ const element = await SingletonView({
125
+ context,
126
+ config,
127
+ listKey: 'Settings',
128
+ basePath: '/admin',
129
+ serverAction: noopServerAction,
130
+ })
131
+ render(element)
132
+
133
+ // Resolved via the singleton get() (auto-create path), not findMany.
134
+ expect(singletonGet).toHaveBeenCalledTimes(1)
135
+
136
+ // Editor header + the record's value rendered in a field input.
137
+ expect(screen.getByText('Edit Settings')).toBeInTheDocument()
138
+ expect(screen.getByDisplayValue('My Site')).toBeInTheDocument()
139
+
140
+ // Save button (edit form), not a list-table "Create" affordance.
141
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
142
+ expect(screen.queryByText('Create Settings')).not.toBeInTheDocument()
143
+ })
144
+
145
+ it('renders a create-on-save form for an autoCreate:false singleton with no row', async () => {
146
+ // autoCreate:false + no row → get() returns null. query+create are allowed,
147
+ // so the editor must offer a create-on-first-save form (mode="create").
148
+ const autoCreateFalseConfig: OpenSaasConfig = {
149
+ db: { provider: 'sqlite', url: 'file:./test.db' },
150
+ lists: {
151
+ Settings: list({
152
+ isSingleton: { autoCreate: false },
153
+ access: {
154
+ operation: {
155
+ query: () => true,
156
+ create: () => true,
157
+ update: () => true,
158
+ delete: () => true,
159
+ },
160
+ },
161
+ fields: { siteName: text() },
162
+ }),
163
+ },
164
+ }
165
+
166
+ const singletonGet = vi.fn(async () => null)
167
+ const context = makeContext({ settings: { get: singletonGet } })
168
+
169
+ const element = await SingletonView({
170
+ context,
171
+ config: autoCreateFalseConfig,
172
+ listKey: 'Settings',
173
+ basePath: '/admin',
174
+ serverAction: noopServerAction,
175
+ })
176
+ render(element)
177
+
178
+ expect(singletonGet).toHaveBeenCalledTimes(1)
179
+
180
+ // Create header + the create affordance ("Create" submit button), not an edit form.
181
+ expect(screen.getByText('Create Settings')).toBeInTheDocument()
182
+ expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
183
+ expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
184
+ // An empty field input is rendered (no display value yet).
185
+ expect(screen.getByText('Site Name')).toBeInTheDocument()
186
+ })
187
+
188
+ it('renders a friendly message (not an editable form) for a read-denied singleton', async () => {
189
+ // query denied → get() returns null. A null is indistinguishable from an
190
+ // empty autoCreate:false singleton, so the editor disambiguates via access
191
+ // and must NOT show an editable/create form.
192
+ const readDeniedConfig: OpenSaasConfig = {
193
+ db: { provider: 'sqlite', url: 'file:./test.db' },
194
+ lists: {
195
+ Settings: list({
196
+ isSingleton: true,
197
+ access: {
198
+ operation: {
199
+ query: () => false,
200
+ create: () => true,
201
+ update: () => true,
202
+ delete: () => false,
203
+ },
204
+ },
205
+ fields: { siteName: text() },
206
+ }),
207
+ },
208
+ }
209
+
210
+ const singletonGet = vi.fn(async () => null)
211
+ const context = makeContext({ settings: { get: singletonGet } })
212
+
213
+ const element = await SingletonView({
214
+ context,
215
+ config: readDeniedConfig,
216
+ listKey: 'Settings',
217
+ basePath: '/admin',
218
+ serverAction: noopServerAction,
219
+ })
220
+ render(element)
221
+
222
+ // Friendly no-access message, no form affordances at all.
223
+ expect(screen.getByText("You don't have access to Settings.")).toBeInTheDocument()
224
+ expect(screen.queryByRole('button', { name: 'Create' })).not.toBeInTheDocument()
225
+ expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
226
+ expect(screen.queryByText('Create Settings')).not.toBeInTheDocument()
227
+ expect(screen.queryByText('Edit Settings')).not.toBeInTheDocument()
228
+ })
229
+
230
+ it('renders a "no record yet" message when create is denied (no editable form)', async () => {
231
+ // autoCreate:false + no row, query allowed but create denied → cannot offer
232
+ // a create form; show a friendly "no record yet" message instead.
233
+ const createDeniedConfig: OpenSaasConfig = {
234
+ db: { provider: 'sqlite', url: 'file:./test.db' },
235
+ lists: {
236
+ Settings: list({
237
+ isSingleton: { autoCreate: false },
238
+ access: {
239
+ operation: {
240
+ query: () => true,
241
+ create: () => false,
242
+ update: () => true,
243
+ delete: () => false,
244
+ },
245
+ },
246
+ fields: { siteName: text() },
247
+ }),
248
+ },
249
+ }
250
+
251
+ const singletonGet = vi.fn(async () => null)
252
+ const context = makeContext({ settings: { get: singletonGet } })
253
+
254
+ const element = await SingletonView({
255
+ context,
256
+ config: createDeniedConfig,
257
+ listKey: 'Settings',
258
+ basePath: '/admin',
259
+ serverAction: noopServerAction,
260
+ })
261
+ render(element)
262
+
263
+ expect(screen.getByText('There is no Settings record yet.')).toBeInTheDocument()
264
+ expect(screen.queryByRole('button', { name: 'Create' })).not.toBeInTheDocument()
265
+ expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
266
+ })
267
+
268
+ it('renders the list table for a non-singleton list (ListView)', async () => {
269
+ const findMany = vi.fn(async () => [
270
+ { id: '1', title: 'First Post' },
271
+ { id: '2', title: 'Second Post' },
272
+ ])
273
+ const count = vi.fn(async () => 2)
274
+ const context = makeContext({ post: { findMany, count } })
275
+
276
+ const element = await ListView({
277
+ context,
278
+ config,
279
+ listKey: 'Post',
280
+ basePath: '/admin',
281
+ })
282
+ render(element)
283
+
284
+ // List view fetches via findMany/count (the singleton get() is never called).
285
+ expect(findMany).toHaveBeenCalledTimes(1)
286
+ expect(count).toHaveBeenCalledTimes(1)
287
+
288
+ // The list table renders rows + the "Create" affordance.
289
+ expect(screen.getByText('First Post')).toBeInTheDocument()
290
+ expect(screen.getByText('Second Post')).toBeInTheDocument()
291
+ expect(screen.getByText('Create Post')).toBeInTheDocument()
292
+
293
+ // It is NOT the singleton editor.
294
+ expect(screen.queryByText('Edit Post')).not.toBeInTheDocument()
295
+ })
296
+ })