@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
|
@@ -16,9 +16,16 @@ export interface DashboardProps {
|
|
|
16
16
|
export async function Dashboard({ context, config, basePath = '/admin' }: DashboardProps) {
|
|
17
17
|
const lists = Object.keys(config.lists || {})
|
|
18
18
|
|
|
19
|
-
//
|
|
19
|
+
// Split lists into standard lists (shown in the counted grid) and singletons
|
|
20
|
+
// (shown in their own "Settings" section). A singleton's count is always 0/1,
|
|
21
|
+
// so the "N items" label is misleading — show a "Configure" affordance instead.
|
|
22
|
+
const standardLists = lists.filter((listKey) => !config.lists[listKey]?.isSingleton)
|
|
23
|
+
const singletonLists = lists.filter((listKey) => config.lists[listKey]?.isSingleton)
|
|
24
|
+
|
|
25
|
+
// Get counts for the standard lists only. Singletons don't show a count, so
|
|
26
|
+
// there's no need to call count() for them here.
|
|
20
27
|
const listCounts = await Promise.all(
|
|
21
|
-
|
|
28
|
+
standardLists.map(async (listKey) => {
|
|
22
29
|
try {
|
|
23
30
|
const delegate = context.db[getDbKey(listKey)]
|
|
24
31
|
const count = delegate?.count ? await delegate.count() : 0
|
|
@@ -51,7 +58,7 @@ export async function Dashboard({ context, config, basePath = '/admin' }: Dashbo
|
|
|
51
58
|
Add lists to your opensaas.config.ts to get started.
|
|
52
59
|
</p>
|
|
53
60
|
</Card>
|
|
54
|
-
) : (
|
|
61
|
+
) : standardLists.length > 0 ? (
|
|
55
62
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
56
63
|
{listCounts.map(({ listKey, count }) => {
|
|
57
64
|
const urlKey = getUrlKey(listKey)
|
|
@@ -97,9 +104,103 @@ export async function Dashboard({ context, config, basePath = '/admin' }: Dashbo
|
|
|
97
104
|
)
|
|
98
105
|
})}
|
|
99
106
|
</div>
|
|
107
|
+
) : null}
|
|
108
|
+
|
|
109
|
+
{/* Settings section — singletons present a "Configure" affordance instead
|
|
110
|
+
of a misleading "N items" count. */}
|
|
111
|
+
{singletonLists.length > 0 && (
|
|
112
|
+
<div className={standardLists.length > 0 ? 'mt-12' : ''}>
|
|
113
|
+
<div className="mb-4 flex items-center gap-2">
|
|
114
|
+
<svg
|
|
115
|
+
className="w-5 h-5 text-muted-foreground"
|
|
116
|
+
fill="none"
|
|
117
|
+
stroke="currentColor"
|
|
118
|
+
viewBox="0 0 24 24"
|
|
119
|
+
aria-hidden="true"
|
|
120
|
+
>
|
|
121
|
+
<path
|
|
122
|
+
strokeLinecap="round"
|
|
123
|
+
strokeLinejoin="round"
|
|
124
|
+
strokeWidth={2}
|
|
125
|
+
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
126
|
+
/>
|
|
127
|
+
<path
|
|
128
|
+
strokeLinecap="round"
|
|
129
|
+
strokeLinejoin="round"
|
|
130
|
+
strokeWidth={2}
|
|
131
|
+
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
132
|
+
/>
|
|
133
|
+
</svg>
|
|
134
|
+
<h2 className="text-xl font-semibold text-foreground">Settings</h2>
|
|
135
|
+
</div>
|
|
136
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
137
|
+
{singletonLists.map((listKey) => {
|
|
138
|
+
const urlKey = getUrlKey(listKey)
|
|
139
|
+
return (
|
|
140
|
+
<Link key={listKey} href={`${basePath}/${urlKey}`}>
|
|
141
|
+
<Card className="group hover:border-primary hover:shadow-lg hover:shadow-primary/20 transition-all duration-200 cursor-pointer h-full relative overflow-hidden">
|
|
142
|
+
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
143
|
+
<CardHeader className="relative">
|
|
144
|
+
<div className="flex items-start justify-between">
|
|
145
|
+
<div>
|
|
146
|
+
<CardTitle className="text-xl group-hover:text-primary transition-colors">
|
|
147
|
+
{formatListName(listKey)}
|
|
148
|
+
</CardTitle>
|
|
149
|
+
</div>
|
|
150
|
+
<div className="text-muted-foreground opacity-60 group-hover:opacity-100 transition-opacity">
|
|
151
|
+
<svg
|
|
152
|
+
className="w-7 h-7"
|
|
153
|
+
fill="none"
|
|
154
|
+
stroke="currentColor"
|
|
155
|
+
viewBox="0 0 24 24"
|
|
156
|
+
aria-hidden="true"
|
|
157
|
+
>
|
|
158
|
+
<path
|
|
159
|
+
strokeLinecap="round"
|
|
160
|
+
strokeLinejoin="round"
|
|
161
|
+
strokeWidth={2}
|
|
162
|
+
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
163
|
+
/>
|
|
164
|
+
<path
|
|
165
|
+
strokeLinecap="round"
|
|
166
|
+
strokeLinejoin="round"
|
|
167
|
+
strokeWidth={2}
|
|
168
|
+
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
169
|
+
/>
|
|
170
|
+
</svg>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</CardHeader>
|
|
174
|
+
<CardContent className="relative">
|
|
175
|
+
<div className="flex items-center text-sm font-medium text-primary">
|
|
176
|
+
<span>Configure</span>
|
|
177
|
+
<svg
|
|
178
|
+
className="ml-1 w-4 h-4 group-hover:translate-x-1 transition-transform"
|
|
179
|
+
fill="none"
|
|
180
|
+
stroke="currentColor"
|
|
181
|
+
viewBox="0 0 24 24"
|
|
182
|
+
>
|
|
183
|
+
<path
|
|
184
|
+
strokeLinecap="round"
|
|
185
|
+
strokeLinejoin="round"
|
|
186
|
+
strokeWidth={2}
|
|
187
|
+
d="M9 5l7 7-7 7"
|
|
188
|
+
/>
|
|
189
|
+
</svg>
|
|
190
|
+
</div>
|
|
191
|
+
</CardContent>
|
|
192
|
+
</Card>
|
|
193
|
+
</Link>
|
|
194
|
+
)
|
|
195
|
+
})}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
100
198
|
)}
|
|
101
199
|
|
|
102
|
-
{
|
|
200
|
+
{/* Quick Actions only contains "Create {list}" links for standard lists,
|
|
201
|
+
so hide the whole card when there are no standard lists (e.g. a
|
|
202
|
+
singleton-only admin). */}
|
|
203
|
+
{standardLists.length > 0 && (
|
|
103
204
|
<Card className="mt-12 bg-gradient-to-br from-accent/10 to-primary/10 border-accent/20">
|
|
104
205
|
<CardHeader>
|
|
105
206
|
<CardTitle className="text-lg flex items-center gap-2">
|
|
@@ -109,7 +210,9 @@ export async function Dashboard({ context, config, basePath = '/admin' }: Dashbo
|
|
|
109
210
|
</CardHeader>
|
|
110
211
|
<CardContent>
|
|
111
212
|
<div className="flex flex-wrap gap-3">
|
|
112
|
-
{
|
|
213
|
+
{/* Singletons have a single record (no create), so they're
|
|
214
|
+
excluded here — only standard lists get a "Create" quick-action. */}
|
|
215
|
+
{standardLists.map((listKey) => {
|
|
113
216
|
const urlKey = getUrlKey(listKey)
|
|
114
217
|
return (
|
|
115
218
|
<Link
|
|
@@ -4,7 +4,7 @@ import { ItemFormClient } from './ItemFormClient.js'
|
|
|
4
4
|
import { formatListName } from '../lib/utils.js'
|
|
5
5
|
import type { ServerActionInput } from '../server/types.js'
|
|
6
6
|
import { type AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
|
|
7
|
-
import {
|
|
7
|
+
import { buildRelationshipInclude, prepareItemForm } from '../lib/prepareItemForm.js'
|
|
8
8
|
|
|
9
9
|
export interface ItemFormProps {
|
|
10
10
|
context: AccessContext<unknown>
|
|
@@ -48,16 +48,8 @@ export async function ItemForm({
|
|
|
48
48
|
let itemData: Record<string, unknown> = {}
|
|
49
49
|
if (mode === 'edit' && itemId) {
|
|
50
50
|
try {
|
|
51
|
-
// Build include object for relationships
|
|
52
|
-
const includeRelationships: Record<string, boolean> = {}
|
|
53
|
-
for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
54
|
-
const fieldConfigAny = fieldConfig as { type: string }
|
|
55
|
-
if (fieldConfigAny.type === 'relationship') {
|
|
56
|
-
includeRelationships[fieldName] = true
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
51
|
// Fetch item with relationships included
|
|
52
|
+
const includeRelationships = buildRelationshipInclude(listConfig)
|
|
61
53
|
const delegate = context.db[getDbKey(listKey)]
|
|
62
54
|
if (delegate?.findUnique) {
|
|
63
55
|
itemData = await delegate.findUnique({
|
|
@@ -90,72 +82,14 @@ export async function ItemForm({
|
|
|
90
82
|
}
|
|
91
83
|
}
|
|
92
84
|
|
|
93
|
-
// Fetch relationship options
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Parse ref format: "ListName.fieldName"
|
|
102
|
-
const relatedListName = ref.split('.')[0]
|
|
103
|
-
const relatedListConfig = config.lists[relatedListName]
|
|
104
|
-
|
|
105
|
-
if (relatedListConfig) {
|
|
106
|
-
try {
|
|
107
|
-
const dbContext = context.db
|
|
108
|
-
const delegate = dbContext[getDbKey(relatedListName)]
|
|
109
|
-
const relatedItems = delegate?.findMany ? await delegate.findMany({}) : []
|
|
110
|
-
|
|
111
|
-
// Use 'name' field as label if it exists, otherwise use 'id'
|
|
112
|
-
relationshipData[fieldName] = relatedItems.map((item: Record<string, unknown>) => ({
|
|
113
|
-
id: item.id as string,
|
|
114
|
-
label: ((item.name || item.title || item.id) as string) || '',
|
|
115
|
-
}))
|
|
116
|
-
} catch (error) {
|
|
117
|
-
console.error(`Failed to fetch relationship items for ${fieldName}:`, error)
|
|
118
|
-
relationshipData[fieldName] = []
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Serialize field configs to remove non-serializable properties
|
|
126
|
-
const serializableFields = serializeFieldConfigs(listConfig.fields)
|
|
127
|
-
|
|
128
|
-
// Transform relationship data in itemData from objects to IDs for form
|
|
129
|
-
// Also apply valueForClientSerialization transformation
|
|
130
|
-
const formData = { ...itemData }
|
|
131
|
-
for (const [fieldName, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
132
|
-
const fieldConfigAny = fieldConfig as {
|
|
133
|
-
type: string
|
|
134
|
-
many?: boolean
|
|
135
|
-
ui?: Record<string, unknown>
|
|
136
|
-
}
|
|
137
|
-
if (fieldConfigAny.type === 'relationship' && formData[fieldName]) {
|
|
138
|
-
const value = formData[fieldName]
|
|
139
|
-
if (fieldConfigAny.many && Array.isArray(value)) {
|
|
140
|
-
// Many relationship: extract IDs from array of objects
|
|
141
|
-
formData[fieldName] = value.map((item: Record<string, unknown>) => item.id as string)
|
|
142
|
-
} else if (value && typeof value === 'object' && 'id' in value) {
|
|
143
|
-
// Single relationship: extract ID from object
|
|
144
|
-
formData[fieldName] = (value as Record<string, unknown>).id as string
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Apply valueForClientSerialization if defined
|
|
149
|
-
if (
|
|
150
|
-
fieldConfigAny.ui?.valueForClientSerialization &&
|
|
151
|
-
typeof fieldConfigAny.ui.valueForClientSerialization === 'function'
|
|
152
|
-
) {
|
|
153
|
-
const transformer = fieldConfigAny.ui.valueForClientSerialization as (args: {
|
|
154
|
-
value: unknown
|
|
155
|
-
}) => unknown
|
|
156
|
-
formData[fieldName] = transformer({ value: formData[fieldName] })
|
|
157
|
-
}
|
|
158
|
-
}
|
|
85
|
+
// Fetch relationship options, serialize field configs, and transform the
|
|
86
|
+
// record into client-ready form data (shared with the singleton editor).
|
|
87
|
+
const { serializableFields, initialData, relationshipData } = await prepareItemForm(
|
|
88
|
+
context,
|
|
89
|
+
config,
|
|
90
|
+
listConfig,
|
|
91
|
+
itemData,
|
|
92
|
+
)
|
|
159
93
|
|
|
160
94
|
return (
|
|
161
95
|
<div className="p-8 max-w-4xl">
|
|
@@ -187,7 +121,7 @@ export async function ItemForm({
|
|
|
187
121
|
urlKey={urlKey}
|
|
188
122
|
mode={mode}
|
|
189
123
|
fields={serializableFields}
|
|
190
|
-
initialData={
|
|
124
|
+
initialData={initialData}
|
|
191
125
|
itemId={itemId}
|
|
192
126
|
basePath={basePath}
|
|
193
127
|
serverAction={serverAction}
|
|
@@ -21,6 +21,13 @@ export interface ItemFormClientProps {
|
|
|
21
21
|
basePath: string
|
|
22
22
|
serverAction: (input: ServerActionInput) => Promise<unknown>
|
|
23
23
|
relationshipData?: Record<string, Array<{ id: string; label: string }>>
|
|
24
|
+
/**
|
|
25
|
+
* Whether to render the delete affordance in edit mode. Defaults to `true`.
|
|
26
|
+
* Singletons set this to `false` — a singleton has exactly one record that
|
|
27
|
+
* can't be deleted (core blocks `delete` even in sudo), so the control is
|
|
28
|
+
* suppressed for UX hygiene.
|
|
29
|
+
*/
|
|
30
|
+
canDelete?: boolean
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
/**
|
|
@@ -58,6 +65,7 @@ export function ItemFormClient({
|
|
|
58
65
|
basePath,
|
|
59
66
|
serverAction,
|
|
60
67
|
relationshipData = {},
|
|
68
|
+
canDelete = true,
|
|
61
69
|
}: ItemFormClientProps) {
|
|
62
70
|
const router = useRouter()
|
|
63
71
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
@@ -162,8 +170,8 @@ export function ItemFormClient({
|
|
|
162
170
|
</Button>
|
|
163
171
|
</div>
|
|
164
172
|
|
|
165
|
-
{/* Delete Button (Edit Mode Only) */}
|
|
166
|
-
{mode === 'edit' && itemId && (
|
|
173
|
+
{/* Delete Button (Edit Mode Only; suppressed for singletons) */}
|
|
174
|
+
{mode === 'edit' && itemId && canDelete && (
|
|
167
175
|
<Button
|
|
168
176
|
type="button"
|
|
169
177
|
variant="destructive"
|
|
@@ -3,6 +3,15 @@ import { ListViewClient } from './ListViewClient.js'
|
|
|
3
3
|
import { formatListName } from '../lib/utils.js'
|
|
4
4
|
import { type AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Default sort for the list table, mirroring Keystone's `ui.listView.initialSort`.
|
|
8
|
+
* Plain serializable data so it can cross the server/client boundary.
|
|
9
|
+
*/
|
|
10
|
+
export interface ListViewSort {
|
|
11
|
+
field: string
|
|
12
|
+
direction: 'asc' | 'desc'
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
export interface ListViewProps {
|
|
7
16
|
context: AccessContext<unknown>
|
|
8
17
|
config: OpenSaasConfig
|
|
@@ -12,6 +21,11 @@ export interface ListViewProps {
|
|
|
12
21
|
page?: number
|
|
13
22
|
pageSize?: number
|
|
14
23
|
search?: string
|
|
24
|
+
/**
|
|
25
|
+
* Default sort applied to the table (from the list's `ui.listView.initialSort`).
|
|
26
|
+
* When omitted, no default sort is applied.
|
|
27
|
+
*/
|
|
28
|
+
initialSort?: ListViewSort
|
|
15
29
|
}
|
|
16
30
|
|
|
17
31
|
/**
|
|
@@ -27,6 +41,7 @@ export async function ListView({
|
|
|
27
41
|
page = 1,
|
|
28
42
|
pageSize = 50,
|
|
29
43
|
search,
|
|
44
|
+
initialSort,
|
|
30
45
|
}: ListViewProps) {
|
|
31
46
|
const key = getDbKey(listKey)
|
|
32
47
|
const urlKey = getUrlKey(listKey)
|
|
@@ -142,6 +157,7 @@ export async function ListView({
|
|
|
142
157
|
)}
|
|
143
158
|
relationshipRefs={relationshipRefs}
|
|
144
159
|
columns={columns}
|
|
160
|
+
initialSort={initialSort}
|
|
145
161
|
listKey={listKey}
|
|
146
162
|
urlKey={urlKey}
|
|
147
163
|
basePath={basePath}
|
|
@@ -23,6 +23,12 @@ export interface ListViewClientProps {
|
|
|
23
23
|
fieldTypes: Record<string, string>
|
|
24
24
|
relationshipRefs: Record<string, string>
|
|
25
25
|
columns?: string[]
|
|
26
|
+
/**
|
|
27
|
+
* Default sort for the table (from the list's `ui.listView.initialSort`).
|
|
28
|
+
* Seeds the initial sort column/direction. When omitted, the table starts
|
|
29
|
+
* unsorted (current default behaviour).
|
|
30
|
+
*/
|
|
31
|
+
initialSort?: { field: string; direction: 'asc' | 'desc' }
|
|
26
32
|
listKey: string
|
|
27
33
|
urlKey: string
|
|
28
34
|
basePath: string
|
|
@@ -41,6 +47,7 @@ export function ListViewClient({
|
|
|
41
47
|
fieldTypes,
|
|
42
48
|
relationshipRefs,
|
|
43
49
|
columns,
|
|
50
|
+
initialSort,
|
|
44
51
|
urlKey,
|
|
45
52
|
basePath,
|
|
46
53
|
page,
|
|
@@ -49,8 +56,8 @@ export function ListViewClient({
|
|
|
49
56
|
search: initialSearch,
|
|
50
57
|
}: ListViewClientProps) {
|
|
51
58
|
const router = useRouter()
|
|
52
|
-
const [sortBy, setSortBy] = useState<string | null>(null)
|
|
53
|
-
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
|
|
59
|
+
const [sortBy, setSortBy] = useState<string | null>(initialSort?.field ?? null)
|
|
60
|
+
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(initialSort?.direction ?? 'asc')
|
|
54
61
|
const [searchInput, setSearchInput] = useState(initialSearch || '')
|
|
55
62
|
|
|
56
63
|
// Determine which columns to show
|
|
@@ -22,7 +22,12 @@ export function Navigation({
|
|
|
22
22
|
currentPath = '',
|
|
23
23
|
onSignOut,
|
|
24
24
|
}: NavigationProps) {
|
|
25
|
-
const
|
|
25
|
+
const allLists = Object.keys(config.lists || {})
|
|
26
|
+
// Split lists into standard lists (under "Lists") and singletons (under
|
|
27
|
+
// "Settings"). A singleton edits a single record, so it belongs in a distinct
|
|
28
|
+
// Settings group rather than the standard list grid.
|
|
29
|
+
const lists = allLists.filter((listKey) => !config.lists[listKey]?.isSingleton)
|
|
30
|
+
const singletons = allLists.filter((listKey) => config.lists[listKey]?.isSingleton)
|
|
26
31
|
|
|
27
32
|
return (
|
|
28
33
|
<nav className="w-64 border-r border-border bg-card h-screen sticky top-0 flex flex-col">
|
|
@@ -90,6 +95,58 @@ export function Navigation({
|
|
|
90
95
|
})}
|
|
91
96
|
</>
|
|
92
97
|
)}
|
|
98
|
+
|
|
99
|
+
{singletons.length > 0 && (
|
|
100
|
+
<>
|
|
101
|
+
<div className="pt-4 pb-2 px-3">
|
|
102
|
+
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
103
|
+
Settings
|
|
104
|
+
</p>
|
|
105
|
+
</div>
|
|
106
|
+
{singletons.map((listKey) => {
|
|
107
|
+
const urlKey = getUrlKey(listKey)
|
|
108
|
+
const isActive = currentPath.startsWith(`/${urlKey}`)
|
|
109
|
+
return (
|
|
110
|
+
<Link
|
|
111
|
+
key={listKey}
|
|
112
|
+
href={`${basePath}/${urlKey}`}
|
|
113
|
+
className={`block px-3 py-2.5 rounded-lg text-sm font-medium transition-all relative overflow-hidden group ${
|
|
114
|
+
isActive
|
|
115
|
+
? 'bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-lg shadow-primary/25'
|
|
116
|
+
: 'text-foreground hover:bg-accent/50 hover:text-accent-foreground'
|
|
117
|
+
}`}
|
|
118
|
+
>
|
|
119
|
+
{isActive && (
|
|
120
|
+
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 animate-pulse" />
|
|
121
|
+
)}
|
|
122
|
+
<span className="relative flex items-center gap-2">
|
|
123
|
+
<svg
|
|
124
|
+
className="w-4 h-4 opacity-60 group-hover:opacity-100 transition-opacity"
|
|
125
|
+
fill="none"
|
|
126
|
+
stroke="currentColor"
|
|
127
|
+
viewBox="0 0 24 24"
|
|
128
|
+
aria-hidden="true"
|
|
129
|
+
>
|
|
130
|
+
<path
|
|
131
|
+
strokeLinecap="round"
|
|
132
|
+
strokeLinejoin="round"
|
|
133
|
+
strokeWidth={2}
|
|
134
|
+
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
135
|
+
/>
|
|
136
|
+
<path
|
|
137
|
+
strokeLinecap="round"
|
|
138
|
+
strokeLinejoin="round"
|
|
139
|
+
strokeWidth={2}
|
|
140
|
+
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
141
|
+
/>
|
|
142
|
+
</svg>
|
|
143
|
+
{formatListName(listKey)}
|
|
144
|
+
</span>
|
|
145
|
+
</Link>
|
|
146
|
+
)
|
|
147
|
+
})}
|
|
148
|
+
</>
|
|
149
|
+
)}
|
|
93
150
|
</div>
|
|
94
151
|
</div>
|
|
95
152
|
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import Link from 'next/link.js'
|
|
3
|
+
import { ItemFormClient } from './ItemFormClient.js'
|
|
4
|
+
import { formatListName } from '../lib/utils.js'
|
|
5
|
+
import type { ServerActionInput } from '../server/types.js'
|
|
6
|
+
import { type AccessContext, getDbKey, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
|
|
7
|
+
import { prepareItemForm } from '../lib/prepareItemForm.js'
|
|
8
|
+
import { isOperationPotentiallyAllowed } from '../lib/operationAccess.js'
|
|
9
|
+
|
|
10
|
+
export interface SingletonViewProps {
|
|
11
|
+
context: AccessContext<unknown>
|
|
12
|
+
config: OpenSaasConfig
|
|
13
|
+
listKey: string
|
|
14
|
+
basePath?: string
|
|
15
|
+
// Server action can return any shape depending on the list item type
|
|
16
|
+
serverAction: (input: ServerActionInput) => Promise<unknown>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Singleton editor — renders a single-record edit form for a list configured
|
|
21
|
+
* with `isSingleton: true`.
|
|
22
|
+
*
|
|
23
|
+
* Resolves the record via the singleton `get()` operation (which auto-creates
|
|
24
|
+
* the row with field defaults when absent, unless `autoCreate: false`), then
|
|
25
|
+
* reuses the same `ItemFormClient` + serialization path as `ItemForm` so the
|
|
26
|
+
* existing field rendering, validation, and `serverAction` save flow apply.
|
|
27
|
+
*
|
|
28
|
+
* A `null` from `get()` is ambiguous at the boundary — it means EITHER an
|
|
29
|
+
* `autoCreate: false` singleton with no row yet, OR that `query` access is
|
|
30
|
+
* denied (access-controlled reads return null/[] silently). We disambiguate
|
|
31
|
+
* using the list's operation-level access:
|
|
32
|
+
*
|
|
33
|
+
* - `query` denied → friendly "no access" message (never an editable form).
|
|
34
|
+
* - `query` allowed + `create` allowed → a create-on-first-save form
|
|
35
|
+
* (`ItemFormClient` in `mode="create"`); core assigns the singleton `id` and
|
|
36
|
+
* enforces the single-record constraint on create.
|
|
37
|
+
* - `query` allowed + `create` denied → friendly "no record yet" message.
|
|
38
|
+
*
|
|
39
|
+
* An update-denied singleton still renders the edit form (the happy path), but
|
|
40
|
+
* the save fails gracefully: the server action's `update` access check returns
|
|
41
|
+
* a denied envelope, which `ItemFormClient` surfaces as an error.
|
|
42
|
+
*
|
|
43
|
+
* Server Component that fetches data and sets up actions.
|
|
44
|
+
*/
|
|
45
|
+
export async function SingletonView({
|
|
46
|
+
context,
|
|
47
|
+
config,
|
|
48
|
+
listKey,
|
|
49
|
+
basePath = '/admin',
|
|
50
|
+
serverAction,
|
|
51
|
+
}: SingletonViewProps) {
|
|
52
|
+
const listConfig = config.lists[listKey]
|
|
53
|
+
const urlKey = getUrlKey(listKey)
|
|
54
|
+
|
|
55
|
+
if (!listConfig) {
|
|
56
|
+
return (
|
|
57
|
+
<div className="p-8">
|
|
58
|
+
<div className="bg-destructive/10 border border-destructive text-destructive rounded-lg p-6">
|
|
59
|
+
<h2 className="text-lg font-semibold mb-2">List not found</h2>
|
|
60
|
+
<p>The list "{listKey}" does not exist in your configuration.</p>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Resolve the singleton record. `get()` auto-creates with field defaults when
|
|
67
|
+
// absent (the default), so a record is the common case. It returns null when
|
|
68
|
+
// either `autoCreate: false` with no row yet, OR `query` access is denied —
|
|
69
|
+
// these are indistinguishable here, so we disambiguate via access below.
|
|
70
|
+
let record: Record<string, unknown> | null = null
|
|
71
|
+
try {
|
|
72
|
+
const delegate = context.db[getDbKey(listKey)]
|
|
73
|
+
if (delegate?.get) {
|
|
74
|
+
record = await delegate.get()
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(`Failed to resolve singleton ${listKey}:`, error)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!record) {
|
|
81
|
+
// A null `get()` is ambiguous (autoCreate:false-empty vs query-denied).
|
|
82
|
+
// Evaluate operation access to choose the safe affordance.
|
|
83
|
+
const accessArgs = { session: context.session, context }
|
|
84
|
+
const canQuery = await isOperationPotentiallyAllowed(
|
|
85
|
+
listConfig.access?.operation,
|
|
86
|
+
'query',
|
|
87
|
+
accessArgs,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// Query denied → the session cannot read this singleton at all. Show a
|
|
91
|
+
// friendly message; never an editable/create form.
|
|
92
|
+
if (!canQuery) {
|
|
93
|
+
return (
|
|
94
|
+
<div className="p-8 max-w-4xl">
|
|
95
|
+
<div className="mb-8">
|
|
96
|
+
<h1 className="text-3xl font-bold">{formatListName(listKey)}</h1>
|
|
97
|
+
</div>
|
|
98
|
+
<div className="bg-muted/50 border border-border rounded-lg p-6">
|
|
99
|
+
<p className="text-muted-foreground">
|
|
100
|
+
You don't have access to {formatListName(listKey)}.
|
|
101
|
+
</p>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Query allowed but no row → an `autoCreate: false` singleton. Offer a
|
|
108
|
+
// create-on-first-save form only when `create` is actually permitted.
|
|
109
|
+
const canCreate = await isOperationPotentiallyAllowed(
|
|
110
|
+
listConfig.access?.operation,
|
|
111
|
+
'create',
|
|
112
|
+
accessArgs,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if (!canCreate) {
|
|
116
|
+
return (
|
|
117
|
+
<div className="p-8 max-w-4xl">
|
|
118
|
+
<div className="mb-8">
|
|
119
|
+
<h1 className="text-3xl font-bold">{formatListName(listKey)}</h1>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="bg-muted/50 border border-border rounded-lg p-6">
|
|
122
|
+
<p className="text-muted-foreground">
|
|
123
|
+
There is no {formatListName(listKey)} record yet.
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Create-on-first-save: render the form in create mode with an empty record.
|
|
131
|
+
// The save goes through the existing `serverAction` create path; core
|
|
132
|
+
// assigns the singleton `id` (always `1`) and enforces the single-record
|
|
133
|
+
// constraint, so the form sends only the user-entered field data.
|
|
134
|
+
const {
|
|
135
|
+
serializableFields: createFields,
|
|
136
|
+
initialData: createInitialData,
|
|
137
|
+
relationshipData: createRelationshipData,
|
|
138
|
+
} = await prepareItemForm(context, config, listConfig, {})
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="p-8 max-w-4xl">
|
|
142
|
+
{/* Header — a singleton has no list view, so link back to the dashboard. */}
|
|
143
|
+
<div className="mb-8">
|
|
144
|
+
<Link
|
|
145
|
+
href={basePath}
|
|
146
|
+
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
|
|
147
|
+
>
|
|
148
|
+
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
149
|
+
<path
|
|
150
|
+
strokeLinecap="round"
|
|
151
|
+
strokeLinejoin="round"
|
|
152
|
+
strokeWidth={2}
|
|
153
|
+
d="M15 19l-7-7 7-7"
|
|
154
|
+
/>
|
|
155
|
+
</svg>
|
|
156
|
+
Back to dashboard
|
|
157
|
+
</Link>
|
|
158
|
+
<h1 className="text-3xl font-bold">Create {formatListName(listKey)}</h1>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{/* Create-on-first-save form */}
|
|
162
|
+
<div className="bg-card border border-border rounded-lg p-6">
|
|
163
|
+
<ItemFormClient
|
|
164
|
+
listKey={listKey}
|
|
165
|
+
urlKey={urlKey}
|
|
166
|
+
mode="create"
|
|
167
|
+
fields={createFields}
|
|
168
|
+
initialData={createInitialData}
|
|
169
|
+
basePath={basePath}
|
|
170
|
+
serverAction={serverAction}
|
|
171
|
+
relationshipData={createRelationshipData}
|
|
172
|
+
canDelete={false}
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Reuse the shared field-serialization + relationship-data logic so the
|
|
180
|
+
// singleton editor stays in lockstep with the regular item form.
|
|
181
|
+
const { serializableFields, initialData, relationshipData } = await prepareItemForm(
|
|
182
|
+
context,
|
|
183
|
+
config,
|
|
184
|
+
listConfig,
|
|
185
|
+
record,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
const itemId = record.id as string
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className="p-8 max-w-4xl">
|
|
192
|
+
{/* Header — a singleton has no list view, so link back to the dashboard. */}
|
|
193
|
+
<div className="mb-8">
|
|
194
|
+
<Link
|
|
195
|
+
href={basePath}
|
|
196
|
+
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
|
|
197
|
+
>
|
|
198
|
+
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
199
|
+
<path
|
|
200
|
+
strokeLinecap="round"
|
|
201
|
+
strokeLinejoin="round"
|
|
202
|
+
strokeWidth={2}
|
|
203
|
+
d="M15 19l-7-7 7-7"
|
|
204
|
+
/>
|
|
205
|
+
</svg>
|
|
206
|
+
Back to dashboard
|
|
207
|
+
</Link>
|
|
208
|
+
<h1 className="text-3xl font-bold">Edit {formatListName(listKey)}</h1>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* Form */}
|
|
212
|
+
<div className="bg-card border border-border rounded-lg p-6">
|
|
213
|
+
<ItemFormClient
|
|
214
|
+
listKey={listKey}
|
|
215
|
+
urlKey={urlKey}
|
|
216
|
+
mode="edit"
|
|
217
|
+
fields={serializableFields}
|
|
218
|
+
initialData={initialData}
|
|
219
|
+
itemId={itemId}
|
|
220
|
+
basePath={basePath}
|
|
221
|
+
serverAction={serverAction}
|
|
222
|
+
relationshipData={relationshipData}
|
|
223
|
+
canDelete={false}
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
}
|