@actuate-media/cms-admin 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/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.js.map +1 -1
- 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-submenu.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +28 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.d.ts +2 -0
- package/dist/__tests__/views/form-entries.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.js +112 -0
- package/dist/__tests__/views/form-entries.render.test.js.map +1 -0
- 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/actuate-admin.css +1 -1
- package/dist/components/forms/EntriesTable.d.ts +28 -0
- package/dist/components/forms/EntriesTable.d.ts.map +1 -0
- package/dist/components/forms/EntriesTable.js +26 -0
- package/dist/components/forms/EntriesTable.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/NotificationsPanel.js +1 -1
- package/dist/components/forms/NotificationsPanel.js.map +1 -1
- package/dist/components/forms/primitives.d.ts +15 -0
- package/dist/components/forms/primitives.d.ts.map +1 -1
- package/dist/components/forms/primitives.js +36 -1
- package/dist/components/forms/primitives.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +44 -21
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/forms-service.d.ts +36 -2
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js +66 -0
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/views/FormEntries.d.ts +5 -0
- package/dist/views/FormEntries.d.ts.map +1 -0
- package/dist/views/FormEntries.js +211 -0
- package/dist/views/FormEntries.js.map +1 -0
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +204 -77
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +26 -10
- package/dist/views/Forms.js.map +1 -1
- package/package.json +1 -1
- package/src/AdminRoot.tsx +4 -0
- package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
- package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +38 -0
- package/src/__tests__/views/form-entries.render.test.tsx +133 -0
- package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
- package/src/components/forms/EntriesTable.tsx +197 -0
- package/src/components/forms/EntryDetailDrawer.tsx +312 -0
- package/src/components/forms/NotificationsPanel.tsx +1 -1
- package/src/components/forms/primitives.tsx +101 -1
- package/src/layout/Sidebar.tsx +75 -17
- package/src/lib/forms-service.ts +99 -0
- package/src/views/FormEntries.tsx +449 -0
- package/src/views/FormSubmissions.tsx +391 -401
- package/src/views/Forms.tsx +145 -107
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
4
|
+
import { FormsPagination } from '../../components/forms/primitives.js'
|
|
5
|
+
|
|
6
|
+
describe('FormsPagination', () => {
|
|
7
|
+
it('renders nothing when results fit on a single page', () => {
|
|
8
|
+
const { container } = render(
|
|
9
|
+
<FormsPagination page={1} pageSize={25} total={10} onPageChange={() => {}} />,
|
|
10
|
+
)
|
|
11
|
+
expect(container.firstChild).toBeNull()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('shows the result range and page controls across multiple pages', () => {
|
|
15
|
+
render(
|
|
16
|
+
<FormsPagination
|
|
17
|
+
page={1}
|
|
18
|
+
pageSize={10}
|
|
19
|
+
total={42}
|
|
20
|
+
onPageChange={() => {}}
|
|
21
|
+
noun="submission"
|
|
22
|
+
/>,
|
|
23
|
+
)
|
|
24
|
+
expect(screen.getByText('1–10 of 42 submissions')).toBeTruthy()
|
|
25
|
+
expect(screen.getByRole('button', { name: 'Next page' })).toBeTruthy()
|
|
26
|
+
expect(screen.getByLabelText('Previous page')).toHaveProperty('disabled', true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('advances and clamps page navigation', () => {
|
|
30
|
+
const onPageChange = vi.fn()
|
|
31
|
+
render(
|
|
32
|
+
<FormsPagination page={2} pageSize={10} total={42} onPageChange={onPageChange} noun="form" />,
|
|
33
|
+
)
|
|
34
|
+
// Range reflects the current page.
|
|
35
|
+
expect(screen.getByText('11–20 of 42 forms')).toBeTruthy()
|
|
36
|
+
fireEvent.click(screen.getByRole('button', { name: 'Next page' }))
|
|
37
|
+
expect(onPageChange).toHaveBeenCalledWith(3)
|
|
38
|
+
fireEvent.click(screen.getByLabelText('Previous page'))
|
|
39
|
+
expect(onPageChange).toHaveBeenCalledWith(1)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('caps the last page so the final partial page shows the true total', () => {
|
|
43
|
+
render(
|
|
44
|
+
<FormsPagination page={5} pageSize={10} total={42} onPageChange={() => {}} noun="form" />,
|
|
45
|
+
)
|
|
46
|
+
expect(screen.getByText('41–42 of 42 forms')).toBeTruthy()
|
|
47
|
+
expect(screen.getByRole('button', { name: 'Next page' })).toHaveProperty('disabled', true)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
// The Forms nav item expands into a submenu: an "All Entries" child plus one
|
|
6
|
+
// child per active form carrying its own unread badge. Mock the sidebar-counts
|
|
7
|
+
// fetcher so the children are deterministic.
|
|
8
|
+
// Distinct per-form counts so a child badge can't collide with the parent
|
|
9
|
+
// total badge (8 + 5 = 13).
|
|
10
|
+
const fetchFormsSidebarCounts = vi.fn(async () => [
|
|
11
|
+
{ formId: 'f1', name: 'Contact Us', slug: 'contact-us', status: 'active', unread: 8 },
|
|
12
|
+
{ formId: 'f2', name: 'Project Inquiry', slug: 'project-inquiry', status: 'active', unread: 5 },
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
vi.mock('../../lib/forms-service.js', () => ({
|
|
16
|
+
fetchFormsSidebarCounts: () => fetchFormsSidebarCounts(),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
const { Sidebar } = await import('../../layout/Sidebar.js')
|
|
20
|
+
|
|
21
|
+
describe('Sidebar Forms submenu', () => {
|
|
22
|
+
it('renders All Entries plus a child per active form', async () => {
|
|
23
|
+
render(
|
|
24
|
+
<Sidebar
|
|
25
|
+
collapsed={false}
|
|
26
|
+
onToggleCollapse={() => {}}
|
|
27
|
+
currentPath="/forms"
|
|
28
|
+
onNavigate={() => {}}
|
|
29
|
+
/>,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
await waitFor(() => expect(screen.getByText('All Entries')).toBeTruthy())
|
|
33
|
+
// Per-form children (the Contact Us label also appears; scope to nav links).
|
|
34
|
+
expect(screen.getByText('Project Inquiry')).toBeTruthy()
|
|
35
|
+
// The form with unread submissions shows its own badge.
|
|
36
|
+
expect(screen.getByLabelText('8 unread')).toBeTruthy()
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
// Drive the global All Entries inbox through a mocked forms-service so the test
|
|
6
|
+
// owns the data and can assert the cross-form table (Form column + form
|
|
7
|
+
// filter), status-pill refetches, and opening the detail drawer.
|
|
8
|
+
function makeEntry(over: Record<string, unknown> = {}) {
|
|
9
|
+
return {
|
|
10
|
+
id: 'e1',
|
|
11
|
+
entryNumber: 1,
|
|
12
|
+
formId: 'f1',
|
|
13
|
+
formName: 'Contact Us',
|
|
14
|
+
formSlug: 'contact-us',
|
|
15
|
+
data: { name: 'Jordan Avery', email: 'jordan@example.com', message: 'Hello there' },
|
|
16
|
+
senderName: 'Jordan Avery',
|
|
17
|
+
senderEmail: 'jordan@example.com',
|
|
18
|
+
preview: 'Hello there',
|
|
19
|
+
unread: true,
|
|
20
|
+
starred: false,
|
|
21
|
+
archived: false,
|
|
22
|
+
spamStatus: 'clean',
|
|
23
|
+
submittedAt: new Date().toISOString(),
|
|
24
|
+
createdAt: new Date().toISOString(),
|
|
25
|
+
updatedAt: new Date().toISOString(),
|
|
26
|
+
...over,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ENTRIES = [
|
|
31
|
+
makeEntry(),
|
|
32
|
+
makeEntry({
|
|
33
|
+
id: 'e2',
|
|
34
|
+
entryNumber: 2,
|
|
35
|
+
formId: 'f2',
|
|
36
|
+
formName: 'Project Inquiry',
|
|
37
|
+
formSlug: 'project-inquiry',
|
|
38
|
+
senderName: 'Priya Nair',
|
|
39
|
+
senderEmail: 'priya@example.com',
|
|
40
|
+
preview: 'A question about pricing',
|
|
41
|
+
unread: false,
|
|
42
|
+
}),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const fetchForms = vi.fn(async () => [
|
|
46
|
+
{ id: 'f1', name: 'Contact Us', slug: 'contact-us', status: 'active', fields: [] },
|
|
47
|
+
{ id: 'f2', name: 'Project Inquiry', slug: 'project-inquiry', status: 'active', fields: [] },
|
|
48
|
+
])
|
|
49
|
+
const fetchEntries = vi.fn(async (_p?: unknown) => ({
|
|
50
|
+
entries: ENTRIES,
|
|
51
|
+
total: ENTRIES.length,
|
|
52
|
+
page: 1,
|
|
53
|
+
pageSize: 25,
|
|
54
|
+
}))
|
|
55
|
+
const fetchEntryCounts = vi.fn(async (_p?: unknown) => ({
|
|
56
|
+
total: 2,
|
|
57
|
+
unread: 1,
|
|
58
|
+
starred: 0,
|
|
59
|
+
thisWeek: 2,
|
|
60
|
+
byForm: { f1: 1 },
|
|
61
|
+
}))
|
|
62
|
+
const fetchEntry = vi.fn(async (_id?: string) => ({ entry: ENTRIES[0], fields: [] }))
|
|
63
|
+
const updateEntry = vi.fn(async (_id?: string, _patch?: unknown) => ({ data: ENTRIES[0] }))
|
|
64
|
+
|
|
65
|
+
vi.mock('../../lib/forms-service.js', () => ({
|
|
66
|
+
fetchForms: () => fetchForms(),
|
|
67
|
+
fetchEntries: (p?: unknown) => fetchEntries(p),
|
|
68
|
+
fetchEntryCounts: (p?: unknown) => fetchEntryCounts(p),
|
|
69
|
+
fetchEntry: (id?: string) => fetchEntry(id),
|
|
70
|
+
updateEntry: (id?: string, patch?: unknown) => updateEntry(id, patch),
|
|
71
|
+
deleteEntry: vi.fn(async () => ({})),
|
|
72
|
+
bulkUpdateEntries: vi.fn(async () => ({ data: { updated: 1 } })),
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
vi.mock('../../lib/forms-events.js', () => ({
|
|
76
|
+
onFormsChanged: () => () => {},
|
|
77
|
+
emitFormsChanged: vi.fn(),
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
|
|
81
|
+
|
|
82
|
+
const { FormEntries } = await import('../../views/FormEntries.js')
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
fetchForms.mockClear()
|
|
86
|
+
fetchEntries.mockClear()
|
|
87
|
+
fetchEntryCounts.mockClear()
|
|
88
|
+
fetchEntry.mockClear()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('FormEntries (All Entries)', () => {
|
|
92
|
+
it('renders cross-form entries with a Form column and filter', async () => {
|
|
93
|
+
render(<FormEntries onNavigate={() => {}} />)
|
|
94
|
+
|
|
95
|
+
expect(await screen.findByText('Jordan Avery')).toBeTruthy()
|
|
96
|
+
expect(screen.getByText('Priya Nair')).toBeTruthy()
|
|
97
|
+
expect(screen.getByRole('table', { name: 'All entries' })).toBeTruthy()
|
|
98
|
+
// Form names appear (column values) and the filter is populated from fetchForms.
|
|
99
|
+
expect(screen.getByLabelText('Filter by form')).toBeTruthy()
|
|
100
|
+
await waitFor(() => expect(fetchForms).toHaveBeenCalled())
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('refetches scoped to a form when the form filter changes', async () => {
|
|
104
|
+
render(<FormEntries onNavigate={() => {}} />)
|
|
105
|
+
await screen.findByText('Jordan Avery')
|
|
106
|
+
|
|
107
|
+
fireEvent.change(screen.getByLabelText('Filter by form'), { target: { value: 'f2' } })
|
|
108
|
+
|
|
109
|
+
await waitFor(() =>
|
|
110
|
+
expect(fetchEntries).toHaveBeenCalledWith(expect.objectContaining({ formId: 'f2' })),
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('refetches with the unread filter when the pill is clicked', async () => {
|
|
115
|
+
render(<FormEntries onNavigate={() => {}} />)
|
|
116
|
+
await screen.findByText('Jordan Avery')
|
|
117
|
+
|
|
118
|
+
fireEvent.click(screen.getByRole('button', { name: 'Unread', pressed: false }))
|
|
119
|
+
|
|
120
|
+
await waitFor(() =>
|
|
121
|
+
expect(fetchEntries).toHaveBeenCalledWith(expect.objectContaining({ unread: true })),
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('opens the detail drawer when a row is clicked', async () => {
|
|
126
|
+
render(<FormEntries onNavigate={() => {}} />)
|
|
127
|
+
const row = await screen.findByText('Jordan Avery')
|
|
128
|
+
|
|
129
|
+
fireEvent.click(row)
|
|
130
|
+
|
|
131
|
+
await waitFor(() => expect(fetchEntry).toHaveBeenCalledWith('e1'))
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
// Drive the per-form submissions table through a mocked forms-service so the
|
|
6
|
+
// test owns the data and can assert rendering, status-pill refetches, bulk
|
|
7
|
+
// selection, and opening the detail drawer without a real backend.
|
|
8
|
+
function makeEntry(over: Record<string, unknown> = {}) {
|
|
9
|
+
return {
|
|
10
|
+
id: 'e1',
|
|
11
|
+
entryNumber: 1,
|
|
12
|
+
formId: 'f1',
|
|
13
|
+
data: { name: 'Jordan Avery', email: 'jordan@example.com', message: 'Hello there' },
|
|
14
|
+
senderName: 'Jordan Avery',
|
|
15
|
+
senderEmail: 'jordan@example.com',
|
|
16
|
+
preview: 'Hello there',
|
|
17
|
+
unread: true,
|
|
18
|
+
starred: false,
|
|
19
|
+
archived: false,
|
|
20
|
+
spamStatus: 'clean',
|
|
21
|
+
submittedAt: new Date().toISOString(),
|
|
22
|
+
createdAt: new Date().toISOString(),
|
|
23
|
+
updatedAt: new Date().toISOString(),
|
|
24
|
+
...over,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ENTRIES = [
|
|
29
|
+
makeEntry(),
|
|
30
|
+
makeEntry({
|
|
31
|
+
id: 'e2',
|
|
32
|
+
entryNumber: 2,
|
|
33
|
+
senderName: 'Priya Nair',
|
|
34
|
+
senderEmail: 'priya@example.com',
|
|
35
|
+
preview: 'A question about pricing',
|
|
36
|
+
unread: false,
|
|
37
|
+
}),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const fetchFormById = vi.fn(async (_id?: string) => ({
|
|
41
|
+
id: 'f1',
|
|
42
|
+
name: 'Contact Us',
|
|
43
|
+
slug: 'contact-us',
|
|
44
|
+
status: 'active',
|
|
45
|
+
fields: [],
|
|
46
|
+
}))
|
|
47
|
+
const fetchEntries = vi.fn(async (_p?: unknown) => ({
|
|
48
|
+
entries: ENTRIES,
|
|
49
|
+
total: ENTRIES.length,
|
|
50
|
+
page: 1,
|
|
51
|
+
pageSize: 100,
|
|
52
|
+
}))
|
|
53
|
+
const fetchEntryCounts = vi.fn(async (_p?: unknown) => ({
|
|
54
|
+
total: 2,
|
|
55
|
+
unread: 1,
|
|
56
|
+
starred: 0,
|
|
57
|
+
thisWeek: 2,
|
|
58
|
+
byForm: { f1: 1 },
|
|
59
|
+
}))
|
|
60
|
+
const fetchEntry = vi.fn(async (_id?: string) => ({ entry: ENTRIES[0], fields: [] }))
|
|
61
|
+
const updateEntry = vi.fn(async (_id?: string, _patch?: unknown) => ({ data: ENTRIES[0] }))
|
|
62
|
+
|
|
63
|
+
vi.mock('../../lib/forms-service.js', () => ({
|
|
64
|
+
fetchFormById: (id?: string) => fetchFormById(id),
|
|
65
|
+
fetchEntries: (p?: unknown) => fetchEntries(p),
|
|
66
|
+
fetchEntryCounts: (p?: unknown) => fetchEntryCounts(p),
|
|
67
|
+
fetchEntry: (id?: string) => fetchEntry(id),
|
|
68
|
+
updateEntry: (id?: string, patch?: unknown) => updateEntry(id, patch),
|
|
69
|
+
deleteEntry: vi.fn(async () => ({})),
|
|
70
|
+
bulkUpdateEntries: vi.fn(async () => ({ data: { updated: 1 } })),
|
|
71
|
+
}))
|
|
72
|
+
|
|
73
|
+
vi.mock('../../lib/forms-events.js', () => ({
|
|
74
|
+
onFormsChanged: () => () => {},
|
|
75
|
+
emitFormsChanged: vi.fn(),
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
|
|
79
|
+
|
|
80
|
+
const { FormSubmissions } = await import('../../views/FormSubmissions.js')
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
fetchEntries.mockClear()
|
|
84
|
+
fetchEntryCounts.mockClear()
|
|
85
|
+
fetchEntry.mockClear()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('FormSubmissions table', () => {
|
|
89
|
+
it('renders submissions as table rows with chips', async () => {
|
|
90
|
+
render(<FormSubmissions formId="f1" onNavigate={() => {}} />)
|
|
91
|
+
|
|
92
|
+
expect(await screen.findByText('Jordan Avery')).toBeTruthy()
|
|
93
|
+
expect(screen.getByText('Priya Nair')).toBeTruthy()
|
|
94
|
+
// It's a table, not cards.
|
|
95
|
+
expect(screen.getByRole('table', { name: 'Submissions' })).toBeTruthy()
|
|
96
|
+
// Summary chips reflect counts.
|
|
97
|
+
expect(screen.getByText('this week')).toBeTruthy()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('refetches with the unread filter when the pill is clicked', async () => {
|
|
101
|
+
render(<FormSubmissions formId="f1" onNavigate={() => {}} />)
|
|
102
|
+
await screen.findByText('Jordan Avery')
|
|
103
|
+
|
|
104
|
+
fireEvent.click(screen.getByRole('button', { name: 'Unread', pressed: false }))
|
|
105
|
+
|
|
106
|
+
await waitFor(() =>
|
|
107
|
+
expect(fetchEntries).toHaveBeenCalledWith(expect.objectContaining({ unread: true })),
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('shows the bulk action bar when a row is selected', async () => {
|
|
112
|
+
render(<FormSubmissions formId="f1" onNavigate={() => {}} />)
|
|
113
|
+
await screen.findByText('Jordan Avery')
|
|
114
|
+
|
|
115
|
+
fireEvent.click(screen.getByLabelText('Select submission from Jordan Avery'))
|
|
116
|
+
|
|
117
|
+
expect(await screen.findByText('1 selected')).toBeTruthy()
|
|
118
|
+
expect(screen.getByRole('button', { name: 'Mark read' })).toBeTruthy()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('opens the detail drawer when a row is clicked', async () => {
|
|
122
|
+
render(<FormSubmissions formId="f1" onNavigate={() => {}} />)
|
|
123
|
+
const row = await screen.findByText('Jordan Avery')
|
|
124
|
+
|
|
125
|
+
fireEvent.click(row)
|
|
126
|
+
|
|
127
|
+
await waitFor(() => expect(fetchEntry).toHaveBeenCalledWith('e1'))
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared, token-only submissions table used by both the per-form Submissions
|
|
5
|
+
* view and the global All Entries inbox. Presentational only: selection, star,
|
|
6
|
+
* and row-open are delegated to the parent; an optional Form column links each
|
|
7
|
+
* row back to its form when entries span multiple forms.
|
|
8
|
+
*/
|
|
9
|
+
import type { ReactNode } from 'react'
|
|
10
|
+
import { Star } from 'lucide-react'
|
|
11
|
+
import type { FormSubmission } from '../../lib/forms-service.js'
|
|
12
|
+
import { btnGhostIcon } from './primitives.js'
|
|
13
|
+
|
|
14
|
+
export function formatEntryDateTime(iso: string | null | undefined): string {
|
|
15
|
+
if (!iso) return '—'
|
|
16
|
+
const d = new Date(iso)
|
|
17
|
+
if (Number.isNaN(d.getTime())) return '—'
|
|
18
|
+
return d.toLocaleString(undefined, {
|
|
19
|
+
month: 'short',
|
|
20
|
+
day: 'numeric',
|
|
21
|
+
year: 'numeric',
|
|
22
|
+
hour: 'numeric',
|
|
23
|
+
minute: '2-digit',
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EntriesTableProps {
|
|
28
|
+
entries: FormSubmission[]
|
|
29
|
+
selected: Set<string>
|
|
30
|
+
allSelected: boolean
|
|
31
|
+
onToggleSelectAll: () => void
|
|
32
|
+
onToggleSelect: (id: string) => void
|
|
33
|
+
onOpen: (id: string) => void
|
|
34
|
+
onToggleStar: (entry: FormSubmission) => void
|
|
35
|
+
/** Show a Form column (used by the cross-form All Entries inbox). */
|
|
36
|
+
showForm?: boolean
|
|
37
|
+
/** Navigate to a form's submissions when its name is clicked (All Entries). */
|
|
38
|
+
onOpenForm?: (formId: string) => void
|
|
39
|
+
/** Accessible label for the table element. */
|
|
40
|
+
ariaLabel?: string
|
|
41
|
+
/** Footer row (e.g. pagination) rendered inside the bordered card. */
|
|
42
|
+
footer?: ReactNode
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function EntriesTable({
|
|
46
|
+
entries,
|
|
47
|
+
selected,
|
|
48
|
+
allSelected,
|
|
49
|
+
onToggleSelectAll,
|
|
50
|
+
onToggleSelect,
|
|
51
|
+
onOpen,
|
|
52
|
+
onToggleStar,
|
|
53
|
+
showForm = false,
|
|
54
|
+
onOpenForm,
|
|
55
|
+
ariaLabel = 'Submissions',
|
|
56
|
+
footer,
|
|
57
|
+
}: EntriesTableProps) {
|
|
58
|
+
return (
|
|
59
|
+
<div className="border-border bg-card rounded-lg border">
|
|
60
|
+
<div className="overflow-x-auto">
|
|
61
|
+
<table
|
|
62
|
+
className={`w-full ${showForm ? 'min-w-[860px]' : 'min-w-[720px]'} text-sm`}
|
|
63
|
+
aria-label={ariaLabel}
|
|
64
|
+
>
|
|
65
|
+
<thead>
|
|
66
|
+
<tr className="border-border text-muted-foreground border-b text-left text-xs">
|
|
67
|
+
<th scope="col" className="w-10 px-4 py-2.5">
|
|
68
|
+
<input
|
|
69
|
+
type="checkbox"
|
|
70
|
+
checked={allSelected}
|
|
71
|
+
onChange={onToggleSelectAll}
|
|
72
|
+
aria-label="Select all submissions"
|
|
73
|
+
className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
|
|
74
|
+
/>
|
|
75
|
+
</th>
|
|
76
|
+
<th scope="col" className="px-4 py-2.5 font-medium">
|
|
77
|
+
Sender
|
|
78
|
+
</th>
|
|
79
|
+
{showForm && (
|
|
80
|
+
<th scope="col" className="px-4 py-2.5 font-medium whitespace-nowrap">
|
|
81
|
+
Form
|
|
82
|
+
</th>
|
|
83
|
+
)}
|
|
84
|
+
<th scope="col" className="px-4 py-2.5 font-medium">
|
|
85
|
+
Submission
|
|
86
|
+
</th>
|
|
87
|
+
<th scope="col" className="px-4 py-2.5 font-medium whitespace-nowrap">
|
|
88
|
+
Submitted
|
|
89
|
+
</th>
|
|
90
|
+
<th scope="col" className="w-10 px-4 py-2.5 text-center font-medium">
|
|
91
|
+
<span className="sr-only">Starred</span>
|
|
92
|
+
</th>
|
|
93
|
+
</tr>
|
|
94
|
+
</thead>
|
|
95
|
+
<tbody>
|
|
96
|
+
{entries.map((entry) => {
|
|
97
|
+
const isSelected = selected.has(entry.id)
|
|
98
|
+
const name = entry.senderName || 'Anonymous'
|
|
99
|
+
return (
|
|
100
|
+
<tr
|
|
101
|
+
key={entry.id}
|
|
102
|
+
className={`border-border hover:bg-accent/40 group cursor-pointer border-b transition-colors last:border-b-0 ${
|
|
103
|
+
isSelected ? 'bg-accent/30' : ''
|
|
104
|
+
}`}
|
|
105
|
+
onClick={() => onOpen(entry.id)}
|
|
106
|
+
>
|
|
107
|
+
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
108
|
+
<input
|
|
109
|
+
type="checkbox"
|
|
110
|
+
checked={isSelected}
|
|
111
|
+
onChange={() => onToggleSelect(entry.id)}
|
|
112
|
+
aria-label={`Select submission from ${name}`}
|
|
113
|
+
className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
|
|
114
|
+
/>
|
|
115
|
+
</td>
|
|
116
|
+
<td className="px-4 py-3">
|
|
117
|
+
<div className="flex items-center gap-3">
|
|
118
|
+
<span
|
|
119
|
+
className="bg-muted text-muted-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium"
|
|
120
|
+
aria-hidden
|
|
121
|
+
>
|
|
122
|
+
{name.charAt(0).toUpperCase()}
|
|
123
|
+
</span>
|
|
124
|
+
<div className="min-w-0">
|
|
125
|
+
<div className="flex items-center gap-2">
|
|
126
|
+
{entry.unread && (
|
|
127
|
+
<span
|
|
128
|
+
className="bg-primary h-1.5 w-1.5 shrink-0 rounded-full"
|
|
129
|
+
aria-label="Unread"
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
<span
|
|
133
|
+
className={`text-foreground truncate ${entry.unread ? 'font-semibold' : 'font-medium'}`}
|
|
134
|
+
>
|
|
135
|
+
{name}
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
{entry.senderEmail && (
|
|
139
|
+
<span className="text-muted-foreground block truncate text-xs">
|
|
140
|
+
{entry.senderEmail}
|
|
141
|
+
</span>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</td>
|
|
146
|
+
{showForm && (
|
|
147
|
+
<td
|
|
148
|
+
className="px-4 py-3 whitespace-nowrap"
|
|
149
|
+
onClick={(e) => e.stopPropagation()}
|
|
150
|
+
>
|
|
151
|
+
{onOpenForm ? (
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
onClick={() => onOpenForm(entry.formId)}
|
|
155
|
+
className="text-muted-foreground hover:text-primary focus-visible:ring-ring rounded text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
156
|
+
>
|
|
157
|
+
{entry.formName || '—'}
|
|
158
|
+
</button>
|
|
159
|
+
) : (
|
|
160
|
+
<span className="text-muted-foreground text-sm">
|
|
161
|
+
{entry.formName || '—'}
|
|
162
|
+
</span>
|
|
163
|
+
)}
|
|
164
|
+
</td>
|
|
165
|
+
)}
|
|
166
|
+
<td className="px-4 py-3">
|
|
167
|
+
<span className="text-muted-foreground line-clamp-1 max-w-md">
|
|
168
|
+
{entry.preview || '—'}
|
|
169
|
+
</span>
|
|
170
|
+
</td>
|
|
171
|
+
<td className="text-muted-foreground px-4 py-3 whitespace-nowrap">
|
|
172
|
+
{formatEntryDateTime(entry.submittedAt)}
|
|
173
|
+
</td>
|
|
174
|
+
<td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
className={btnGhostIcon}
|
|
178
|
+
aria-pressed={entry.starred}
|
|
179
|
+
aria-label={entry.starred ? 'Unstar submission' : 'Star submission'}
|
|
180
|
+
onClick={() => onToggleStar(entry)}
|
|
181
|
+
>
|
|
182
|
+
<Star
|
|
183
|
+
className={`h-4 w-4 ${entry.starred ? 'fill-warning text-warning' : ''}`}
|
|
184
|
+
aria-hidden
|
|
185
|
+
/>
|
|
186
|
+
</button>
|
|
187
|
+
</td>
|
|
188
|
+
</tr>
|
|
189
|
+
)
|
|
190
|
+
})}
|
|
191
|
+
</tbody>
|
|
192
|
+
</table>
|
|
193
|
+
</div>
|
|
194
|
+
{footer}
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
}
|