@opensaas/stack-ui 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +62 -0
  3. package/CLAUDE.md +46 -9
  4. package/README.md +41 -10
  5. package/dist/components/AdminUI.d.ts +1 -1
  6. package/dist/components/AdminUI.d.ts.map +1 -1
  7. package/dist/components/AdminUI.js +17 -2
  8. package/dist/components/Dashboard.d.ts.map +1 -1
  9. package/dist/components/Dashboard.js +13 -4
  10. package/dist/components/ItemForm.d.ts.map +1 -1
  11. package/dist/components/ItemForm.js +6 -65
  12. package/dist/components/ItemFormClient.d.ts +8 -1
  13. package/dist/components/ItemFormClient.d.ts.map +1 -1
  14. package/dist/components/ItemFormClient.js +2 -2
  15. package/dist/components/Navigation.d.ts.map +1 -1
  16. package/dist/components/Navigation.js +12 -1
  17. package/dist/components/SingletonView.d.ts +37 -0
  18. package/dist/components/SingletonView.d.ts.map +1 -0
  19. package/dist/components/SingletonView.js +82 -0
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/lib/operationAccess.d.ts +34 -0
  24. package/dist/lib/operationAccess.d.ts.map +1 -0
  25. package/dist/lib/operationAccess.js +43 -0
  26. package/dist/lib/prepareItemForm.d.ts +35 -0
  27. package/dist/lib/prepareItemForm.d.ts.map +1 -0
  28. package/dist/lib/prepareItemForm.js +85 -0
  29. package/dist/styles/globals.css +12 -0
  30. package/package.json +2 -2
  31. package/src/components/AdminUI.tsx +28 -2
  32. package/src/components/Dashboard.tsx +108 -5
  33. package/src/components/ItemForm.tsx +11 -77
  34. package/src/components/ItemFormClient.tsx +10 -2
  35. package/src/components/Navigation.tsx +58 -1
  36. package/src/components/SingletonView.tsx +228 -0
  37. package/src/index.ts +2 -0
  38. package/src/lib/operationAccess.ts +53 -0
  39. package/src/lib/prepareItemForm.ts +121 -0
  40. package/tests/components/AdminUISingleton.test.tsx +296 -0
  41. package/tests/components/AdminUISingletonSuppress.test.tsx +259 -0
  42. package/tests/components/SingletonNavDashboard.test.tsx +141 -0
@@ -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
+ })
@@ -0,0 +1,259 @@
1
+ import { describe, it, expect, vi, beforeEach } 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 { ItemForm } from '../../src/components/ItemForm.js'
9
+ import { SingletonView } from '../../src/components/SingletonView.js'
10
+ import { Dashboard } from '../../src/components/Dashboard.js'
11
+
12
+ // Mock Next.js navigation — AdminUI calls redirect() (server-side) for singleton
13
+ // sub-routes, and the form/table client components call useRouter().
14
+ const mockPush = vi.fn()
15
+ const mockRefresh = vi.fn()
16
+ const mockRedirect = vi.fn()
17
+ vi.mock('next/navigation.js', () => ({
18
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
19
+ redirect: (url: string) => mockRedirect(url),
20
+ }))
21
+
22
+ // next/link renders an anchor; happy-dom can render it directly.
23
+ vi.mock('next/link.js', () => ({
24
+ default: ({ children, href }: { children: React.ReactNode; href: string }) => (
25
+ <a href={href}>{children}</a>
26
+ ),
27
+ }))
28
+
29
+ /**
30
+ * A config with one singleton list (Settings) and one ordinary list (Post).
31
+ * Built with the real `list()` + field builders so the components see the same
32
+ * field shapes they would in a real app.
33
+ */
34
+ const config: OpenSaasConfig = {
35
+ db: { provider: 'sqlite', url: 'file:./test.db' },
36
+ lists: {
37
+ Settings: list({
38
+ isSingleton: true,
39
+ fields: {
40
+ siteName: text(),
41
+ },
42
+ }),
43
+ Post: list({
44
+ fields: {
45
+ title: text(),
46
+ },
47
+ }),
48
+ },
49
+ }
50
+
51
+ interface DelegateStub {
52
+ get?: () => Promise<Record<string, unknown> | null>
53
+ findUnique?: (args: unknown) => Promise<Record<string, unknown> | null>
54
+ findMany?: (args: unknown) => Promise<Array<Record<string, unknown>>>
55
+ count?: (args?: unknown) => Promise<number>
56
+ }
57
+
58
+ /**
59
+ * Build a minimal AccessContext whose db delegates return canned data. Only the
60
+ * methods the views call are implemented.
61
+ */
62
+ function makeContext(delegates: Record<string, DelegateStub>): AccessContext<unknown> {
63
+ const context = {
64
+ db: delegates,
65
+ session: null,
66
+ storage: {},
67
+ plugins: {},
68
+ _isSudo: false,
69
+ _resolveOutputCounter: { depth: 0 },
70
+ }
71
+ // Cast: this is a stub for rendering tests, not a full Prisma-backed context.
72
+ return context as unknown as AccessContext<unknown>
73
+ }
74
+
75
+ const noopServerAction = vi.fn(async () => ({ success: true }))
76
+
77
+ beforeEach(() => {
78
+ mockRedirect.mockClear()
79
+ mockPush.mockClear()
80
+ mockRefresh.mockClear()
81
+ })
82
+
83
+ describe('AdminUI singleton sub-route redirects', () => {
84
+ it('redirects a singleton [list, "create"] to the bare editor route', async () => {
85
+ const context = makeContext({
86
+ settings: { get: vi.fn(async () => ({ id: '1', siteName: 'My Site' })) },
87
+ })
88
+
89
+ await AdminUI({
90
+ context,
91
+ config,
92
+ params: ['settings', 'create'],
93
+ basePath: '/admin',
94
+ serverAction: noopServerAction,
95
+ })
96
+
97
+ expect(mockRedirect).toHaveBeenCalledTimes(1)
98
+ expect(mockRedirect).toHaveBeenCalledWith('/admin/settings')
99
+ })
100
+
101
+ it('redirects a singleton [list, id] to the bare editor route', async () => {
102
+ const context = makeContext({
103
+ settings: { get: vi.fn(async () => ({ id: '1', siteName: 'My Site' })) },
104
+ })
105
+
106
+ await AdminUI({
107
+ context,
108
+ config,
109
+ params: ['settings', '1'],
110
+ basePath: '/admin',
111
+ serverAction: noopServerAction,
112
+ })
113
+
114
+ expect(mockRedirect).toHaveBeenCalledTimes(1)
115
+ expect(mockRedirect).toHaveBeenCalledWith('/admin/settings')
116
+ })
117
+
118
+ it('honours a custom basePath in the redirect target', async () => {
119
+ const context = makeContext({
120
+ settings: { get: vi.fn(async () => ({ id: '1', siteName: 'My Site' })) },
121
+ })
122
+
123
+ await AdminUI({
124
+ context,
125
+ config,
126
+ params: ['settings', 'create'],
127
+ basePath: '/dashboard',
128
+ serverAction: noopServerAction,
129
+ })
130
+
131
+ expect(mockRedirect).toHaveBeenCalledWith('/dashboard/settings')
132
+ })
133
+
134
+ it('does NOT redirect a singleton bare [list] route (renders the editor)', async () => {
135
+ const context = makeContext({
136
+ settings: { get: vi.fn(async () => ({ id: '1', siteName: 'My Site' })) },
137
+ })
138
+
139
+ await AdminUI({
140
+ context,
141
+ config,
142
+ params: ['settings'],
143
+ basePath: '/admin',
144
+ serverAction: noopServerAction,
145
+ })
146
+
147
+ expect(mockRedirect).not.toHaveBeenCalled()
148
+ })
149
+
150
+ it('does NOT redirect non-singleton create/edit routes (routing unchanged)', async () => {
151
+ const context = makeContext({
152
+ post: {
153
+ findMany: vi.fn(async () => []),
154
+ count: vi.fn(async () => 0),
155
+ findUnique: vi.fn(async () => ({ id: '1', title: 'First Post' })),
156
+ },
157
+ })
158
+
159
+ // Non-singleton create
160
+ await AdminUI({
161
+ context,
162
+ config,
163
+ params: ['post', 'create'],
164
+ basePath: '/admin',
165
+ serverAction: noopServerAction,
166
+ })
167
+ expect(mockRedirect).not.toHaveBeenCalled()
168
+
169
+ // Non-singleton edit
170
+ await AdminUI({
171
+ context,
172
+ config,
173
+ params: ['post', '1'],
174
+ basePath: '/admin',
175
+ serverAction: noopServerAction,
176
+ })
177
+ expect(mockRedirect).not.toHaveBeenCalled()
178
+ })
179
+ })
180
+
181
+ describe('Dashboard create suppression for singletons', () => {
182
+ it('does not render a "Create {singleton}" quick-action but does for a non-singleton', async () => {
183
+ const context = makeContext({
184
+ settings: { count: vi.fn(async () => 1) },
185
+ post: { count: vi.fn(async () => 2) },
186
+ })
187
+
188
+ const element = await Dashboard({ context, config, basePath: '/admin' })
189
+ render(element)
190
+
191
+ // The Quick Actions block offers "Create" only for the standard list.
192
+ expect(screen.getByText('Create Post')).toBeInTheDocument()
193
+ expect(screen.queryByText('Create Settings')).not.toBeInTheDocument()
194
+
195
+ // There is no create link pointing at the singleton anywhere on the dashboard.
196
+ const createLinks = screen.getAllByRole('link', { name: /Create/ })
197
+ for (const link of createLinks) {
198
+ expect(link).not.toHaveAttribute('href', '/admin/settings/create')
199
+ }
200
+ })
201
+
202
+ it('hides the Quick Actions card entirely in a singleton-only admin', async () => {
203
+ const singletonOnly: OpenSaasConfig = {
204
+ db: { provider: 'sqlite', url: 'file:./test.db' },
205
+ lists: {
206
+ Settings: list({ isSingleton: true, fields: { siteName: text() } }),
207
+ },
208
+ }
209
+ const context = makeContext({ settings: { count: vi.fn(async () => 1) } })
210
+
211
+ const element = await Dashboard({ context, config: singletonOnly, basePath: '/admin' })
212
+ render(element)
213
+
214
+ expect(screen.queryByText('Quick Actions')).not.toBeInTheDocument()
215
+ expect(screen.queryByText('Create Settings')).not.toBeInTheDocument()
216
+ })
217
+ })
218
+
219
+ describe('Delete suppression in the singleton editor', () => {
220
+ it('renders no delete control in the singleton editor (SingletonView)', async () => {
221
+ const context = makeContext({
222
+ settings: { get: vi.fn(async () => ({ id: '1', siteName: 'My Site' })) },
223
+ })
224
+
225
+ const element = await SingletonView({
226
+ context,
227
+ config,
228
+ listKey: 'Settings',
229
+ basePath: '/admin',
230
+ serverAction: noopServerAction,
231
+ })
232
+ render(element)
233
+
234
+ // The edit form renders (Save present) but the Delete affordance is gone.
235
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
236
+ expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument()
237
+ })
238
+
239
+ it('still renders the delete control for a non-singleton edit form (ItemForm)', async () => {
240
+ const context = makeContext({
241
+ post: { findUnique: vi.fn(async () => ({ id: '1', title: 'First Post' })) },
242
+ })
243
+
244
+ const element = await ItemForm({
245
+ context,
246
+ config,
247
+ listKey: 'Post',
248
+ mode: 'edit',
249
+ itemId: '1',
250
+ basePath: '/admin',
251
+ serverAction: noopServerAction,
252
+ })
253
+ render(element)
254
+
255
+ // Non-singleton edit forms keep the delete affordance.
256
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
257
+ expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
258
+ })
259
+ })
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import * as React from 'react'
3
+ import { render, screen, within } 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 { Navigation } from '../../src/components/Navigation.js'
8
+ import { Dashboard } from '../../src/components/Dashboard.js'
9
+
10
+ // next/link renders an anchor; happy-dom can render it directly.
11
+ vi.mock('next/link.js', () => ({
12
+ default: ({ children, href }: { children: React.ReactNode; href: string }) => (
13
+ <a href={href}>{children}</a>
14
+ ),
15
+ }))
16
+
17
+ /**
18
+ * A config with one singleton list (SiteConfig) and one ordinary list (Post).
19
+ * Built with the real `list()` + field builders so the components see the same
20
+ * field shapes they would in a real app. The singleton is deliberately NOT
21
+ * named "Settings" so its link label can't be confused with the "Settings"
22
+ * group heading.
23
+ */
24
+ const config: OpenSaasConfig = {
25
+ db: { provider: 'sqlite', url: 'file:./test.db' },
26
+ lists: {
27
+ SiteConfig: list({
28
+ isSingleton: true,
29
+ fields: {
30
+ siteName: text(),
31
+ },
32
+ }),
33
+ Post: list({
34
+ fields: {
35
+ title: text(),
36
+ },
37
+ }),
38
+ },
39
+ }
40
+
41
+ interface DelegateStub {
42
+ count?: (args?: unknown) => Promise<number>
43
+ }
44
+
45
+ /**
46
+ * Build a minimal AccessContext whose db delegates return canned data. Only the
47
+ * methods the views call are implemented.
48
+ */
49
+ function makeContext(delegates: Record<string, DelegateStub>): AccessContext<unknown> {
50
+ const context = {
51
+ db: delegates,
52
+ session: null,
53
+ storage: {},
54
+ plugins: {},
55
+ _isSudo: false,
56
+ _resolveOutputCounter: { depth: 0 },
57
+ }
58
+ // Cast: this is a stub for rendering tests, not a full Prisma-backed context.
59
+ return context as unknown as AccessContext<unknown>
60
+ }
61
+
62
+ describe('Navigation singleton grouping', () => {
63
+ it('renders singletons under a "Settings" group and non-singletons under "Lists"', () => {
64
+ const context = makeContext({})
65
+
66
+ render(<Navigation context={context} config={config} basePath="/admin" />)
67
+
68
+ // Both group headings are present.
69
+ expect(screen.getByText('Lists')).toBeInTheDocument()
70
+ expect(screen.getByText('Settings')).toBeInTheDocument()
71
+
72
+ // The non-singleton Post links to its editor under the "Lists" group, and the
73
+ // singleton SiteConfig links to its editor under the "Settings" group. Both
74
+ // point at the bare [list] route (the editor).
75
+ const postLink = screen.getByRole('link', { name: /Post/ })
76
+ expect(postLink).toHaveAttribute('href', '/admin/post')
77
+
78
+ const siteConfigLink = screen.getByRole('link', { name: /Site Config/ })
79
+ expect(siteConfigLink).toHaveAttribute('href', '/admin/site-config')
80
+ })
81
+
82
+ it('does not render a "Settings" group when there are no singletons', () => {
83
+ const noSingletons: OpenSaasConfig = {
84
+ db: { provider: 'sqlite', url: 'file:./test.db' },
85
+ lists: {
86
+ Post: list({ fields: { title: text() } }),
87
+ },
88
+ }
89
+ const context = makeContext({})
90
+
91
+ render(<Navigation context={context} config={noSingletons} basePath="/admin" />)
92
+
93
+ expect(screen.getByText('Lists')).toBeInTheDocument()
94
+ expect(screen.queryByText('Settings')).not.toBeInTheDocument()
95
+ })
96
+
97
+ it('does not render a "Lists" group when there are only singletons', () => {
98
+ const onlySingletons: OpenSaasConfig = {
99
+ db: { provider: 'sqlite', url: 'file:./test.db' },
100
+ lists: {
101
+ SiteConfig: list({ isSingleton: true, fields: { siteName: text() } }),
102
+ },
103
+ }
104
+ const context = makeContext({})
105
+
106
+ render(<Navigation context={context} config={onlySingletons} basePath="/admin" />)
107
+
108
+ expect(screen.getByText('Settings')).toBeInTheDocument()
109
+ expect(screen.queryByText('Lists')).not.toBeInTheDocument()
110
+ })
111
+ })
112
+
113
+ describe('Dashboard singleton affordance', () => {
114
+ it('shows "Configure" for a singleton (no count) and a count for a non-singleton', async () => {
115
+ const siteConfigCount = vi.fn(async () => 1)
116
+ const postCount = vi.fn(async () => 2)
117
+ const context = makeContext({
118
+ siteConfig: { count: siteConfigCount },
119
+ post: { count: postCount },
120
+ })
121
+
122
+ const element = await Dashboard({ context, config, basePath: '/admin' })
123
+ render(element)
124
+
125
+ // Non-singleton Post card shows the normal "N items" count.
126
+ expect(screen.getByText('2 items')).toBeInTheDocument()
127
+
128
+ // The singleton card shows a "Configure" affordance and links to the editor.
129
+ const configureLink = screen.getByRole('link', { name: /Configure/ })
130
+ expect(configureLink).toHaveAttribute('href', '/admin/site-config')
131
+ expect(within(configureLink).getByText('Site Config')).toBeInTheDocument()
132
+
133
+ // The singleton's count() is never called — its count is misleading, so the
134
+ // dashboard does not query or render it.
135
+ expect(siteConfigCount).not.toHaveBeenCalled()
136
+ expect(postCount).toHaveBeenCalledTimes(1)
137
+
138
+ // No "N items" label appears for the singleton.
139
+ expect(screen.queryByText('1 item')).not.toBeInTheDocument()
140
+ })
141
+ })