@actuate-media/cms-admin 0.21.1 → 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.
- package/dist/__tests__/components/forms-pagination.render.test.d.ts +2 -0
- package/dist/__tests__/components/forms-pagination.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/forms-pagination.render.test.js +33 -0
- package/dist/__tests__/components/forms-pagination.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.js +30 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js +8 -2
- package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -1
- package/dist/__tests__/views/form-submissions.render.test.d.ts +2 -0
- package/dist/__tests__/views/form-submissions.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-submissions.render.test.js +110 -0
- package/dist/__tests__/views/form-submissions.render.test.js.map +1 -0
- package/dist/__tests__/views/forms-list.render.test.d.ts +2 -0
- package/dist/__tests__/views/forms-list.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/forms-list.render.test.js +122 -0
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/forms/Drawer.d.ts +13 -0
- package/dist/components/forms/Drawer.d.ts.map +1 -0
- package/dist/components/forms/Drawer.js +13 -0
- package/dist/components/forms/Drawer.js.map +1 -0
- package/dist/components/forms/EmbedPanel.d.ts +7 -0
- package/dist/components/forms/EmbedPanel.d.ts.map +1 -0
- package/dist/components/forms/EmbedPanel.js +91 -0
- package/dist/components/forms/EmbedPanel.js.map +1 -0
- package/dist/components/forms/EntryDetailDrawer.d.ts +11 -0
- package/dist/components/forms/EntryDetailDrawer.d.ts.map +1 -0
- package/dist/components/forms/EntryDetailDrawer.js +145 -0
- package/dist/components/forms/EntryDetailDrawer.js.map +1 -0
- package/dist/components/forms/FieldsPanel.d.ts +8 -0
- package/dist/components/forms/FieldsPanel.d.ts.map +1 -0
- package/dist/components/forms/FieldsPanel.js +123 -0
- package/dist/components/forms/FieldsPanel.js.map +1 -0
- package/dist/components/forms/FormSchemaDrawer.d.ts +9 -0
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaDrawer.js +96 -0
- package/dist/components/forms/FormSchemaDrawer.js.map +1 -0
- package/dist/components/forms/NotificationsPanel.d.ts +6 -0
- package/dist/components/forms/NotificationsPanel.d.ts.map +1 -0
- package/dist/components/forms/NotificationsPanel.js +80 -0
- package/dist/components/forms/NotificationsPanel.js.map +1 -0
- package/dist/components/forms/primitives.d.ts +57 -0
- package/dist/components/forms/primitives.d.ts.map +1 -0
- package/dist/components/forms/primitives.js +131 -0
- package/dist/components/forms/primitives.js.map +1 -0
- package/dist/layout/Sidebar.d.ts +5 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +43 -3
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/forms-events.d.ts +17 -0
- package/dist/lib/forms-events.d.ts.map +1 -0
- package/dist/lib/forms-events.js +20 -0
- package/dist/lib/forms-events.js.map +1 -0
- package/dist/lib/forms-service.d.ts +114 -0
- package/dist/lib/forms-service.d.ts.map +1 -0
- package/dist/lib/forms-service.js +210 -0
- package/dist/lib/forms-service.js.map +1 -0
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +224 -77
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +135 -17
- package/dist/views/Forms.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
- package/src/__tests__/layout/sidebar-forms-badge.render.test.tsx +50 -0
- package/src/__tests__/layout/sidebar-submenu.render.test.tsx +9 -2
- package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
- package/src/__tests__/views/forms-list.render.test.tsx +141 -0
- package/src/components/forms/Drawer.tsx +70 -0
- package/src/components/forms/EmbedPanel.tsx +173 -0
- package/src/components/forms/EntryDetailDrawer.tsx +312 -0
- package/src/components/forms/FieldsPanel.tsx +385 -0
- package/src/components/forms/FormSchemaDrawer.tsx +185 -0
- package/src/components/forms/NotificationsPanel.tsx +240 -0
- package/src/components/forms/primitives.tsx +300 -0
- package/src/layout/Sidebar.tsx +72 -6
- package/src/lib/forms-events.ts +32 -0
- package/src/lib/forms-service.ts +343 -0
- package/src/views/FormSubmissions.tsx +529 -394
- package/src/views/Forms.tsx +381 -106
package/src/views/Forms.tsx
CHANGED
|
@@ -1,138 +1,413 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Forms list — manage forms and view high-level submission status. Matches the
|
|
5
|
+
* Forms list screenshot: summary chips, search + status filter pills, and a
|
|
6
|
+
* compact table (Form / Fields / Status / Entries / Last Entry / Notify /
|
|
7
|
+
* Schema). All data flows through `lib/forms-service.ts`; the Schema action
|
|
8
|
+
* opens the right-side `FormSchemaDrawer`.
|
|
9
|
+
*/
|
|
10
|
+
import { ChevronRight, ClipboardList, Plus, Search, Settings2 } from 'lucide-react'
|
|
11
|
+
import * as Switch from '@radix-ui/react-switch'
|
|
12
|
+
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
13
|
+
import { toast } from 'sonner'
|
|
5
14
|
import { sortByRelevance } from '../lib/search.js'
|
|
6
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
fetchForms,
|
|
17
|
+
fetchFormStats,
|
|
18
|
+
setNotifyEnabled,
|
|
19
|
+
type FormListItem,
|
|
20
|
+
type FormStats,
|
|
21
|
+
type FormStatus,
|
|
22
|
+
} from '../lib/forms-service.js'
|
|
23
|
+
import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
|
|
24
|
+
import { FormSchemaDrawer } from '../components/forms/FormSchemaDrawer.js'
|
|
25
|
+
import {
|
|
26
|
+
FieldTypeBadge,
|
|
27
|
+
FormStatusBadge,
|
|
28
|
+
FormsEmptyState,
|
|
29
|
+
FormsErrorState,
|
|
30
|
+
FormsLoading,
|
|
31
|
+
FormsPagination,
|
|
32
|
+
SummaryChip,
|
|
33
|
+
UnreadBadge,
|
|
34
|
+
btnPrimary,
|
|
35
|
+
} from '../components/forms/primitives.js'
|
|
36
|
+
|
|
37
|
+
const PAGE_SIZE = 10
|
|
7
38
|
|
|
8
39
|
export interface FormsProps {
|
|
9
40
|
onNavigate?: (path: string) => void
|
|
10
41
|
}
|
|
11
42
|
|
|
43
|
+
type StatusFilter = 'all' | FormStatus
|
|
44
|
+
|
|
45
|
+
const FILTERS: { id: StatusFilter; label: string }[] = [
|
|
46
|
+
{ id: 'all', label: 'All' },
|
|
47
|
+
{ id: 'active', label: 'Active' },
|
|
48
|
+
{ id: 'draft', label: 'Draft' },
|
|
49
|
+
{ id: 'archived', label: 'Archived' },
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
function formatRelative(iso: string | null | undefined): string {
|
|
53
|
+
if (!iso) return '—'
|
|
54
|
+
const then = new Date(iso).getTime()
|
|
55
|
+
if (Number.isNaN(then)) return '—'
|
|
56
|
+
const diff = Date.now() - then
|
|
57
|
+
const mins = Math.round(diff / 60000)
|
|
58
|
+
if (mins < 1) return 'just now'
|
|
59
|
+
if (mins < 60) return `${mins} min ago`
|
|
60
|
+
const hrs = Math.round(mins / 60)
|
|
61
|
+
if (hrs < 24) return `${hrs} hour${hrs === 1 ? '' : 's'} ago`
|
|
62
|
+
const days = Math.round(hrs / 24)
|
|
63
|
+
if (days < 7) return `${days} day${days === 1 ? '' : 's'} ago`
|
|
64
|
+
return new Date(iso).toLocaleDateString(undefined, {
|
|
65
|
+
month: 'short',
|
|
66
|
+
day: 'numeric',
|
|
67
|
+
year: 'numeric',
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
12
71
|
export function Forms({ onNavigate }: FormsProps) {
|
|
13
|
-
const
|
|
14
|
-
const [
|
|
72
|
+
const [forms, setForms] = useState<FormListItem[]>([])
|
|
73
|
+
const [stats, setStats] = useState<FormStats | null>(null)
|
|
74
|
+
const [loading, setLoading] = useState(true)
|
|
75
|
+
const [error, setError] = useState<string | null>(null)
|
|
76
|
+
const [search, setSearch] = useState('')
|
|
77
|
+
const [filter, setFilter] = useState<StatusFilter>('all')
|
|
78
|
+
const [page, setPage] = useState(1)
|
|
79
|
+
const [schemaForm, setSchemaForm] = useState<FormListItem | null>(null)
|
|
80
|
+
const [schemaOpen, setSchemaOpen] = useState(false)
|
|
15
81
|
|
|
16
|
-
const
|
|
82
|
+
const load = useCallback(async () => {
|
|
83
|
+
setLoading(true)
|
|
84
|
+
setError(null)
|
|
85
|
+
try {
|
|
86
|
+
const [formsData, statsData] = await Promise.all([fetchForms(), fetchFormStats()])
|
|
87
|
+
setForms(formsData)
|
|
88
|
+
setStats(statsData)
|
|
89
|
+
} catch (e) {
|
|
90
|
+
setError(e instanceof Error ? e.message : 'Failed to load forms')
|
|
91
|
+
} finally {
|
|
92
|
+
setLoading(false)
|
|
93
|
+
}
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
void load()
|
|
98
|
+
}, [load])
|
|
17
99
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
(form: any) =>
|
|
21
|
-
(form.name ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
22
|
-
(form.description ?? '').toLowerCase().includes(searchQuery.toLowerCase()),
|
|
23
|
-
)
|
|
100
|
+
// Refresh when another surface signals a forms/submission change.
|
|
101
|
+
useEffect(() => onFormsChanged(() => void load()), [load])
|
|
24
102
|
|
|
25
|
-
|
|
26
|
-
|
|
103
|
+
const filtered = useMemo(() => {
|
|
104
|
+
let results = forms
|
|
105
|
+
if (filter !== 'all') results = results.filter((f) => f.status === filter)
|
|
106
|
+
const q = search.trim().toLowerCase()
|
|
107
|
+
if (q) {
|
|
108
|
+
results = results.filter(
|
|
109
|
+
(f) =>
|
|
110
|
+
f.name.toLowerCase().includes(q) ||
|
|
111
|
+
f.slug.toLowerCase().includes(q) ||
|
|
112
|
+
(f.description ?? '').toLowerCase().includes(q) ||
|
|
113
|
+
f.fields.some(
|
|
114
|
+
(field) =>
|
|
115
|
+
field.label.toLowerCase().includes(q) || field.type.toLowerCase().includes(q),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
results = sortByRelevance(results, search, (f) => [
|
|
119
|
+
f.name,
|
|
120
|
+
f.slug,
|
|
121
|
+
f.description ?? '',
|
|
122
|
+
...f.fields.map((field) => field.label),
|
|
123
|
+
])
|
|
27
124
|
}
|
|
28
125
|
return results
|
|
29
|
-
}, [forms,
|
|
126
|
+
}, [forms, filter, search])
|
|
30
127
|
|
|
31
|
-
|
|
32
|
-
return (
|
|
33
|
-
<div className="flex h-64 items-center justify-center p-3 pr-6 sm:p-4 sm:pr-8">
|
|
34
|
-
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
|
|
35
|
-
</div>
|
|
36
|
-
)
|
|
37
|
-
}
|
|
128
|
+
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
|
38
129
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
>
|
|
49
|
-
Retry
|
|
50
|
-
</button>
|
|
51
|
-
</div>
|
|
52
|
-
)}
|
|
130
|
+
// Keep the page in range when the filtered set shrinks (filters, refresh).
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (page > totalPages) setPage(totalPages)
|
|
133
|
+
}, [page, totalPages])
|
|
134
|
+
|
|
135
|
+
const paged = useMemo(
|
|
136
|
+
() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
|
137
|
+
[filtered, page],
|
|
138
|
+
)
|
|
53
139
|
|
|
54
|
-
|
|
140
|
+
const openSchema = useCallback((form: FormListItem) => {
|
|
141
|
+
setSchemaForm(form)
|
|
142
|
+
setSchemaOpen(true)
|
|
143
|
+
}, [])
|
|
144
|
+
|
|
145
|
+
const handleNotifyToggle = useCallback(async (form: FormListItem, next: boolean) => {
|
|
146
|
+
// Optimistic update; roll back on error.
|
|
147
|
+
setForms((cur) => cur.map((f) => (f.id === form.id ? { ...f, notifyEnabled: next } : f)))
|
|
148
|
+
const res = await setNotifyEnabled(form.id, next)
|
|
149
|
+
if (res.error) {
|
|
150
|
+
setForms((cur) => cur.map((f) => (f.id === form.id ? { ...f, notifyEnabled: !next } : f)))
|
|
151
|
+
toast.error(res.error)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
emitFormsChanged()
|
|
155
|
+
}, [])
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="space-y-5 p-6">
|
|
159
|
+
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
55
160
|
<div>
|
|
56
|
-
<h1 className="
|
|
57
|
-
<p className="text-
|
|
161
|
+
<h1 className="text-foreground text-2xl font-medium">Forms</h1>
|
|
162
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
163
|
+
Manage forms and view incoming submissions
|
|
164
|
+
</p>
|
|
58
165
|
</div>
|
|
59
|
-
<button
|
|
60
|
-
|
|
61
|
-
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
|
|
62
|
-
>
|
|
63
|
-
<Plus className="h-4 w-4" />
|
|
166
|
+
<button className={btnPrimary} onClick={() => onNavigate?.('/forms/new')}>
|
|
167
|
+
<Plus className="h-4 w-4" aria-hidden />
|
|
64
168
|
New Form
|
|
65
169
|
</button>
|
|
66
|
-
</
|
|
170
|
+
</header>
|
|
67
171
|
|
|
68
|
-
|
|
69
|
-
<div className="
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
/>
|
|
79
|
-
</div>
|
|
172
|
+
{stats && (
|
|
173
|
+
<div className="flex flex-wrap gap-2">
|
|
174
|
+
<SummaryChip value={stats.totalForms} label={stats.totalForms === 1 ? 'form' : 'forms'} />
|
|
175
|
+
<SummaryChip value={stats.activeForms} label="active" tone="success" />
|
|
176
|
+
<SummaryChip value={stats.totalEntries} label="total entries" />
|
|
177
|
+
<SummaryChip
|
|
178
|
+
value={stats.unreadEntries}
|
|
179
|
+
label="unread"
|
|
180
|
+
tone={stats.unreadEntries > 0 ? 'destructive' : 'neutral'}
|
|
181
|
+
/>
|
|
80
182
|
</div>
|
|
81
|
-
|
|
183
|
+
)}
|
|
82
184
|
|
|
83
|
-
|
|
84
|
-
<div className="
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
185
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
186
|
+
<div className="relative w-full sm:max-w-xs">
|
|
187
|
+
<Search
|
|
188
|
+
className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
|
189
|
+
aria-hidden
|
|
190
|
+
/>
|
|
191
|
+
<input
|
|
192
|
+
type="search"
|
|
193
|
+
placeholder="Search forms…"
|
|
194
|
+
value={search}
|
|
195
|
+
onChange={(e) => {
|
|
196
|
+
setSearch(e.target.value)
|
|
197
|
+
setPage(1)
|
|
198
|
+
}}
|
|
199
|
+
aria-label="Search forms"
|
|
200
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
201
|
+
/>
|
|
89
202
|
</div>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
203
|
+
<div className="flex gap-1" role="group" aria-label="Filter by status">
|
|
204
|
+
{FILTERS.map((f) => (
|
|
205
|
+
<button
|
|
206
|
+
key={f.id}
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={() => {
|
|
209
|
+
setFilter(f.id)
|
|
210
|
+
setPage(1)
|
|
211
|
+
}}
|
|
212
|
+
aria-pressed={filter === f.id}
|
|
213
|
+
className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
214
|
+
filter === f.id
|
|
215
|
+
? 'bg-primary text-primary-foreground'
|
|
216
|
+
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
217
|
+
}`}
|
|
96
218
|
>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<h3 className="mb-1 font-semibold text-gray-900">{form.name}</h3>
|
|
100
|
-
<p className="mb-2 text-sm text-gray-600">{form.description}</p>
|
|
101
|
-
</div>
|
|
102
|
-
<button className="rounded p-1.5 transition-colors hover:bg-gray-100">
|
|
103
|
-
<MoreVertical className="h-4 w-4 text-gray-600" />
|
|
104
|
-
</button>
|
|
105
|
-
</div>
|
|
106
|
-
<div className="mb-3 flex items-center gap-4 text-sm text-gray-600">
|
|
107
|
-
<span className="flex items-center gap-1">
|
|
108
|
-
<FileText className="h-4 w-4" />
|
|
109
|
-
{form.fieldCount ??
|
|
110
|
-
(Array.isArray(form.fields) ? form.fields.length : form.fields)}{' '}
|
|
111
|
-
fields
|
|
112
|
-
</span>
|
|
113
|
-
<span className="flex items-center gap-1">
|
|
114
|
-
<Send className="h-4 w-4" />
|
|
115
|
-
{form.submissions} submissions
|
|
116
|
-
</span>
|
|
117
|
-
</div>
|
|
118
|
-
<div className="flex items-center gap-2">
|
|
119
|
-
<button
|
|
120
|
-
onClick={() => onNavigate?.(`/forms/${form.id}/submissions`)}
|
|
121
|
-
className="flex-1 rounded-lg bg-blue-600 px-3 py-1.5 text-center text-sm text-white transition-colors hover:bg-blue-700"
|
|
122
|
-
>
|
|
123
|
-
View Submissions
|
|
124
|
-
</button>
|
|
125
|
-
<button
|
|
126
|
-
onClick={() => onNavigate?.(`/forms/${form.id}/edit`)}
|
|
127
|
-
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm transition-colors hover:bg-gray-50"
|
|
128
|
-
>
|
|
129
|
-
Edit
|
|
130
|
-
</button>
|
|
131
|
-
</div>
|
|
132
|
-
</div>
|
|
219
|
+
{f.label}
|
|
220
|
+
</button>
|
|
133
221
|
))}
|
|
134
222
|
</div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{error ? (
|
|
226
|
+
<FormsErrorState message={error} onRetry={load} />
|
|
227
|
+
) : loading ? (
|
|
228
|
+
<FormsLoading label="Loading forms…" />
|
|
229
|
+
) : forms.length === 0 ? (
|
|
230
|
+
<FormsEmptyState
|
|
231
|
+
icon={<ClipboardList className="h-6 w-6" aria-hidden />}
|
|
232
|
+
title="No forms yet"
|
|
233
|
+
description="Create your first form to start collecting submissions."
|
|
234
|
+
action={
|
|
235
|
+
<button className={btnPrimary} onClick={() => onNavigate?.('/forms/new')}>
|
|
236
|
+
<Plus className="h-4 w-4" aria-hidden />
|
|
237
|
+
Create New Form
|
|
238
|
+
</button>
|
|
239
|
+
}
|
|
240
|
+
/>
|
|
241
|
+
) : filtered.length === 0 ? (
|
|
242
|
+
<FormsEmptyState
|
|
243
|
+
title="No forms match your filters"
|
|
244
|
+
description="Try a different search term or status filter."
|
|
245
|
+
action={
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
onClick={() => {
|
|
249
|
+
setSearch('')
|
|
250
|
+
setFilter('all')
|
|
251
|
+
setPage(1)
|
|
252
|
+
}}
|
|
253
|
+
className="text-primary text-sm font-medium hover:underline"
|
|
254
|
+
>
|
|
255
|
+
Clear filters
|
|
256
|
+
</button>
|
|
257
|
+
}
|
|
258
|
+
/>
|
|
259
|
+
) : (
|
|
260
|
+
<FormsTable
|
|
261
|
+
forms={paged}
|
|
262
|
+
onOpenForm={(id) => onNavigate?.(`/forms/${id}/edit`)}
|
|
263
|
+
onOpenEntries={(id) => onNavigate?.(`/forms/${id}/submissions`)}
|
|
264
|
+
onOpenSchema={openSchema}
|
|
265
|
+
onNotifyToggle={handleNotifyToggle}
|
|
266
|
+
footer={
|
|
267
|
+
<FormsPagination
|
|
268
|
+
page={page}
|
|
269
|
+
pageSize={PAGE_SIZE}
|
|
270
|
+
total={filtered.length}
|
|
271
|
+
onPageChange={setPage}
|
|
272
|
+
noun="form"
|
|
273
|
+
label="Forms pagination"
|
|
274
|
+
/>
|
|
275
|
+
}
|
|
276
|
+
/>
|
|
135
277
|
)}
|
|
278
|
+
|
|
279
|
+
<FormSchemaDrawer
|
|
280
|
+
form={schemaForm}
|
|
281
|
+
open={schemaOpen}
|
|
282
|
+
onOpenChange={setSchemaOpen}
|
|
283
|
+
onSaved={load}
|
|
284
|
+
/>
|
|
285
|
+
</div>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function FormsTable({
|
|
290
|
+
forms,
|
|
291
|
+
onOpenForm,
|
|
292
|
+
onOpenEntries,
|
|
293
|
+
onOpenSchema,
|
|
294
|
+
onNotifyToggle,
|
|
295
|
+
footer,
|
|
296
|
+
}: {
|
|
297
|
+
forms: FormListItem[]
|
|
298
|
+
onOpenForm: (id: string) => void
|
|
299
|
+
onOpenEntries: (id: string) => void
|
|
300
|
+
onOpenSchema: (form: FormListItem) => void
|
|
301
|
+
onNotifyToggle: (form: FormListItem, next: boolean) => void
|
|
302
|
+
footer?: ReactNode
|
|
303
|
+
}) {
|
|
304
|
+
return (
|
|
305
|
+
<div className="border-border bg-card rounded-lg border">
|
|
306
|
+
<div className="overflow-x-auto">
|
|
307
|
+
<table className="w-full min-w-[760px] text-sm" aria-label="Forms">
|
|
308
|
+
<thead>
|
|
309
|
+
<tr className="border-border text-muted-foreground border-b text-left text-xs">
|
|
310
|
+
<th scope="col" className="px-4 py-2.5 font-medium">
|
|
311
|
+
Form
|
|
312
|
+
</th>
|
|
313
|
+
<th scope="col" className="px-4 py-2.5 font-medium">
|
|
314
|
+
Fields
|
|
315
|
+
</th>
|
|
316
|
+
<th scope="col" className="px-4 py-2.5 font-medium">
|
|
317
|
+
Status
|
|
318
|
+
</th>
|
|
319
|
+
<th scope="col" className="px-4 py-2.5 font-medium">
|
|
320
|
+
Entries
|
|
321
|
+
</th>
|
|
322
|
+
<th scope="col" className="px-4 py-2.5 font-medium">
|
|
323
|
+
Last Entry
|
|
324
|
+
</th>
|
|
325
|
+
<th scope="col" className="px-4 py-2.5 text-center font-medium">
|
|
326
|
+
Notify
|
|
327
|
+
</th>
|
|
328
|
+
<th scope="col" className="px-4 py-2.5 text-right font-medium">
|
|
329
|
+
<span className="sr-only">Schema</span>
|
|
330
|
+
</th>
|
|
331
|
+
</tr>
|
|
332
|
+
</thead>
|
|
333
|
+
<tbody>
|
|
334
|
+
{forms.map((form) => {
|
|
335
|
+
const fieldCount = form.fieldCount ?? form.fields.length
|
|
336
|
+
const unread = form.unreadEntries ?? 0
|
|
337
|
+
const muted = form.status === 'archived'
|
|
338
|
+
return (
|
|
339
|
+
<tr
|
|
340
|
+
key={form.id}
|
|
341
|
+
className={`border-border hover:bg-accent/40 border-b transition-colors last:border-b-0 ${muted ? 'opacity-60' : ''}`}
|
|
342
|
+
>
|
|
343
|
+
<td className="px-4 py-3">
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
onClick={() => onOpenForm(form.id)}
|
|
347
|
+
className="block max-w-xs text-left"
|
|
348
|
+
>
|
|
349
|
+
<span className="text-foreground block truncate font-medium hover:underline">
|
|
350
|
+
{form.name}
|
|
351
|
+
</span>
|
|
352
|
+
<span className="text-muted-foreground block truncate text-xs">
|
|
353
|
+
/{form.slug}
|
|
354
|
+
{form.description ? ` · ${form.description}` : ''}
|
|
355
|
+
</span>
|
|
356
|
+
</button>
|
|
357
|
+
</td>
|
|
358
|
+
<td className="px-4 py-3">
|
|
359
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
360
|
+
{form.fields.slice(0, 4).map((field) => (
|
|
361
|
+
<FieldTypeBadge key={field.id} type={field.type} />
|
|
362
|
+
))}
|
|
363
|
+
<span className="text-muted-foreground text-xs">{fieldCount} fields</span>
|
|
364
|
+
</div>
|
|
365
|
+
</td>
|
|
366
|
+
<td className="px-4 py-3">
|
|
367
|
+
<FormStatusBadge status={form.status} />
|
|
368
|
+
</td>
|
|
369
|
+
<td className="px-4 py-3">
|
|
370
|
+
<button
|
|
371
|
+
type="button"
|
|
372
|
+
onClick={() => onOpenEntries(form.id)}
|
|
373
|
+
className="text-foreground hover:text-primary focus-visible:ring-ring inline-flex items-center gap-1.5 rounded font-medium tabular-nums focus-visible:ring-2 focus-visible:outline-none"
|
|
374
|
+
aria-label={`View ${form.totalEntries ?? 0} entries for ${form.name}`}
|
|
375
|
+
>
|
|
376
|
+
{form.totalEntries ?? 0}
|
|
377
|
+
<UnreadBadge count={unread} />
|
|
378
|
+
<ChevronRight className="text-muted-foreground h-3.5 w-3.5" aria-hidden />
|
|
379
|
+
</button>
|
|
380
|
+
</td>
|
|
381
|
+
<td className="text-muted-foreground px-4 py-3 whitespace-nowrap">
|
|
382
|
+
{formatRelative(form.lastEntryAt)}
|
|
383
|
+
</td>
|
|
384
|
+
<td className="px-4 py-3 text-center">
|
|
385
|
+
<Switch.Root
|
|
386
|
+
checked={form.notifyEnabled ?? false}
|
|
387
|
+
onCheckedChange={(v) => onNotifyToggle(form, v)}
|
|
388
|
+
aria-label={`Notifications for ${form.name}`}
|
|
389
|
+
className="bg-destructive data-[state=checked]:bg-success focus-visible:ring-ring relative inline-flex h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
390
|
+
>
|
|
391
|
+
<Switch.Thumb className="bg-background block h-3.5 w-3.5 translate-x-0.5 rounded-full shadow-sm transition-transform data-[state=checked]:translate-x-[14px]" />
|
|
392
|
+
</Switch.Root>
|
|
393
|
+
</td>
|
|
394
|
+
<td className="px-4 py-3 text-right">
|
|
395
|
+
<button
|
|
396
|
+
type="button"
|
|
397
|
+
onClick={() => onOpenSchema(form)}
|
|
398
|
+
className="border-border text-foreground hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
399
|
+
>
|
|
400
|
+
<Settings2 className="h-3.5 w-3.5" aria-hidden />
|
|
401
|
+
Schema
|
|
402
|
+
</button>
|
|
403
|
+
</td>
|
|
404
|
+
</tr>
|
|
405
|
+
)
|
|
406
|
+
})}
|
|
407
|
+
</tbody>
|
|
408
|
+
</table>
|
|
409
|
+
</div>
|
|
410
|
+
{footer}
|
|
136
411
|
</div>
|
|
137
412
|
)
|
|
138
413
|
}
|