@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
|
@@ -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
|
+
})
|
|
@@ -73,6 +73,66 @@ describe('ListViewClient', () => {
|
|
|
73
73
|
expect(screen.getByText('Status')).toBeInTheDocument()
|
|
74
74
|
expect(screen.queryByText('Views')).not.toBeInTheDocument()
|
|
75
75
|
})
|
|
76
|
+
|
|
77
|
+
it('should render columns in the provided order (initialColumns)', () => {
|
|
78
|
+
// initialColumns flows through AdminUI as the `columns` prop and drives
|
|
79
|
+
// both selection AND order.
|
|
80
|
+
render(<ListViewClient {...defaultProps} columns={['views', 'title']} />)
|
|
81
|
+
|
|
82
|
+
const headers = screen.getAllByRole('columnheader')
|
|
83
|
+
// First two headers should follow the provided order, then Actions.
|
|
84
|
+
expect(headers[0]).toHaveTextContent('Views')
|
|
85
|
+
expect(headers[1]).toHaveTextContent('Title')
|
|
86
|
+
expect(headers[2]).toHaveTextContent('Actions')
|
|
87
|
+
// Unlisted column is excluded.
|
|
88
|
+
expect(screen.queryByText('Status')).not.toBeInTheDocument()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('initialSort', () => {
|
|
93
|
+
it('should apply default sort from initialSort without any click', () => {
|
|
94
|
+
render(
|
|
95
|
+
<ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'asc' }} />,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const rows = screen.getAllByRole('row')
|
|
99
|
+
// Sorted ascending by title: First, Second, Third
|
|
100
|
+
expect(rows[1]).toHaveTextContent('First Post')
|
|
101
|
+
expect(rows[2]).toHaveTextContent('Second Post')
|
|
102
|
+
expect(rows[3]).toHaveTextContent('Third Post')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should respect descending direction from initialSort', () => {
|
|
106
|
+
render(
|
|
107
|
+
<ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'desc' }} />,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const rows = screen.getAllByRole('row')
|
|
111
|
+
// Sorted descending by title: Third, Second, First
|
|
112
|
+
expect(rows[1]).toHaveTextContent('Third Post')
|
|
113
|
+
expect(rows[2]).toHaveTextContent('Second Post')
|
|
114
|
+
expect(rows[3]).toHaveTextContent('First Post')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should show sort indicator on the initialSort column', () => {
|
|
118
|
+
render(
|
|
119
|
+
<ListViewClient {...defaultProps} initialSort={{ field: 'title', direction: 'desc' }} />,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
expect(screen.getByText('↓')).toBeInTheDocument()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should not apply any default sort when initialSort is absent', () => {
|
|
126
|
+
render(<ListViewClient {...defaultProps} />)
|
|
127
|
+
|
|
128
|
+
const rows = screen.getAllByRole('row')
|
|
129
|
+
// Unchanged: items render in their original (input) order, no indicator.
|
|
130
|
+
expect(rows[1]).toHaveTextContent('First Post')
|
|
131
|
+
expect(rows[2]).toHaveTextContent('Second Post')
|
|
132
|
+
expect(rows[3]).toHaveTextContent('Third Post')
|
|
133
|
+
expect(screen.queryByText('↑')).not.toBeInTheDocument()
|
|
134
|
+
expect(screen.queryByText('↓')).not.toBeInTheDocument()
|
|
135
|
+
})
|
|
76
136
|
})
|
|
77
137
|
|
|
78
138
|
describe('sorting', () => {
|
|
@@ -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
|
+
})
|