@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/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
|
+
})
|