@actuate-media/cms-admin 0.16.0 → 0.17.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 +1 -1
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/useApiData.test.d.ts +2 -0
- package/dist/__tests__/lib/useApiData.test.d.ts.map +1 -0
- package/dist/__tests__/lib/useApiData.test.js +111 -0
- package/dist/__tests__/lib/useApiData.test.js.map +1 -0
- package/dist/__tests__/views/dashboard.test.d.ts +2 -0
- package/dist/__tests__/views/dashboard.test.d.ts.map +1 -0
- package/dist/__tests__/views/dashboard.test.js +138 -0
- package/dist/__tests__/views/dashboard.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/lib/useApiData.d.ts +11 -0
- package/dist/lib/useApiData.d.ts.map +1 -1
- package/dist/lib/useApiData.js +41 -7
- package/dist/lib/useApiData.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +99 -60
- package/dist/views/Dashboard.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +1 -0
- package/src/__tests__/lib/useApiData.test.ts +140 -0
- package/src/__tests__/views/dashboard.test.tsx +180 -0
- package/src/lib/useApiData.ts +52 -7
- package/src/styles/theme.css +37 -0
- package/src/views/Dashboard.tsx +201 -144
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
// Drive the dashboard purely through `useApiData`. Each endpoint's result
|
|
6
|
+
// is looked up in `responses`, so tests can set just the data they care
|
|
7
|
+
// about and everything else falls back to a benign empty state.
|
|
8
|
+
const responses: Record<string, unknown> = {}
|
|
9
|
+
const emptyResult = { data: null, loading: false, error: null, exhausted: false, refetch: () => {} }
|
|
10
|
+
|
|
11
|
+
vi.mock('../../lib/useApiData.js', () => ({
|
|
12
|
+
useApiData: (endpoint: string | null) => responses[endpoint ?? ''] ?? emptyResult,
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const { Dashboard } = await import('../../views/Dashboard.js')
|
|
16
|
+
|
|
17
|
+
const emptyStats = {
|
|
18
|
+
totalDocuments: 0,
|
|
19
|
+
totalMedia: 0,
|
|
20
|
+
totalUsers: 0,
|
|
21
|
+
formCount: 0,
|
|
22
|
+
avgSeoScore: 0,
|
|
23
|
+
webhookCount: 0,
|
|
24
|
+
webhookActiveCount: 0,
|
|
25
|
+
collectionCounts: {},
|
|
26
|
+
statusCounts: {},
|
|
27
|
+
recentDocuments: [] as unknown[],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function statsResult(over: Partial<typeof emptyStats> = {}) {
|
|
31
|
+
return {
|
|
32
|
+
data: { ...emptyStats, ...over },
|
|
33
|
+
loading: false,
|
|
34
|
+
error: null,
|
|
35
|
+
exhausted: false,
|
|
36
|
+
refetch: () => {},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
for (const k of Object.keys(responses)) delete responses[k]
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('Dashboard', () => {
|
|
45
|
+
it('renders a spinner while the primary stats request is still loading', () => {
|
|
46
|
+
responses['/stats'] = {
|
|
47
|
+
data: null,
|
|
48
|
+
loading: true,
|
|
49
|
+
error: null,
|
|
50
|
+
exhausted: false,
|
|
51
|
+
refetch: () => {},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<Dashboard config={{ collections: [] }} session={{}} onNavigate={() => {}} />,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
expect(container.querySelector('.animate-spin')).toBeTruthy()
|
|
59
|
+
expect(screen.queryByText('Recent Activity')).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('shows empty states when there is no content', () => {
|
|
63
|
+
responses['/stats'] = statsResult()
|
|
64
|
+
|
|
65
|
+
render(<Dashboard config={{ collections: [] }} session={{}} onNavigate={() => {}} />)
|
|
66
|
+
|
|
67
|
+
expect(screen.getByText('No activity yet')).toBeTruthy()
|
|
68
|
+
expect(screen.getByText('No scheduled content')).toBeTruthy()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('surfaces a retryable banner when the stats request is exhausted', () => {
|
|
72
|
+
const refetch = vi.fn()
|
|
73
|
+
responses['/stats'] = { data: null, loading: false, error: 'down', exhausted: true, refetch }
|
|
74
|
+
|
|
75
|
+
render(<Dashboard config={{ collections: [] }} session={{}} onNavigate={() => {}} />)
|
|
76
|
+
|
|
77
|
+
fireEvent.click(screen.getByText('Retry'))
|
|
78
|
+
expect(refetch).toHaveBeenCalledTimes(1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// ── EI-1 regression: recent-activity rows must open the *correct* editor ──
|
|
82
|
+
|
|
83
|
+
it('routes a recent post to the section-based post editor (not the legacy editor)', () => {
|
|
84
|
+
const onNavigate = vi.fn()
|
|
85
|
+
responses['/stats'] = statsResult({
|
|
86
|
+
recentDocuments: [
|
|
87
|
+
{
|
|
88
|
+
id: 'p1',
|
|
89
|
+
title: 'My Post',
|
|
90
|
+
status: 'DRAFT',
|
|
91
|
+
collection: 'blog',
|
|
92
|
+
updatedAt: new Date().toISOString(),
|
|
93
|
+
author: 'Sam',
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
render(
|
|
99
|
+
<Dashboard
|
|
100
|
+
config={{
|
|
101
|
+
collections: [
|
|
102
|
+
{ slug: 'blog', type: 'post', labels: { singular: 'Blog', plural: 'Blogs' } },
|
|
103
|
+
],
|
|
104
|
+
}}
|
|
105
|
+
session={{}}
|
|
106
|
+
onNavigate={onNavigate}
|
|
107
|
+
/>,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
// EI-2: the row is an accessible button, so it is reachable by role.
|
|
111
|
+
fireEvent.click(screen.getByRole('button', { name: /My Post/ }))
|
|
112
|
+
expect(onNavigate).toHaveBeenCalledWith('/posts/blog/p1/edit')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('routes a recent page to the full page editor', () => {
|
|
116
|
+
const onNavigate = vi.fn()
|
|
117
|
+
responses['/stats'] = statsResult({
|
|
118
|
+
recentDocuments: [
|
|
119
|
+
{
|
|
120
|
+
id: 'pg1',
|
|
121
|
+
title: 'Home',
|
|
122
|
+
status: 'PUBLISHED',
|
|
123
|
+
collection: 'pages',
|
|
124
|
+
updatedAt: new Date().toISOString(),
|
|
125
|
+
author: 'Sam',
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
render(
|
|
131
|
+
<Dashboard
|
|
132
|
+
config={{
|
|
133
|
+
collections: [
|
|
134
|
+
{ slug: 'pages', type: 'page', labels: { singular: 'Page', plural: 'Pages' } },
|
|
135
|
+
],
|
|
136
|
+
}}
|
|
137
|
+
session={{}}
|
|
138
|
+
onNavigate={onNavigate}
|
|
139
|
+
/>,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
fireEvent.click(screen.getByRole('button', { name: /Home/ }))
|
|
143
|
+
expect(onNavigate).toHaveBeenCalledWith('/pages/pg1/edit')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('routes a scheduled queue item through the same editor resolver', () => {
|
|
147
|
+
const onNavigate = vi.fn()
|
|
148
|
+
responses['/stats'] = statsResult({
|
|
149
|
+
recentDocuments: [
|
|
150
|
+
{
|
|
151
|
+
id: 'p9',
|
|
152
|
+
title: 'Scheduled Post',
|
|
153
|
+
status: 'SCHEDULED',
|
|
154
|
+
collection: 'blog',
|
|
155
|
+
updatedAt: new Date().toISOString(),
|
|
156
|
+
author: 'Sam',
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
render(
|
|
162
|
+
<Dashboard
|
|
163
|
+
config={{
|
|
164
|
+
collections: [
|
|
165
|
+
{ slug: 'blog', type: 'post', labels: { singular: 'Blog', plural: 'Blogs' } },
|
|
166
|
+
],
|
|
167
|
+
}}
|
|
168
|
+
session={{}}
|
|
169
|
+
onNavigate={onNavigate}
|
|
170
|
+
/>,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
// The publishing-queue row (right column) — there are two buttons named
|
|
174
|
+
// for the post (activity + queue); the queue "Manage" header button also
|
|
175
|
+
// targets the first scheduled item. Click the queue row directly.
|
|
176
|
+
const queueButtons = screen.getAllByRole('button', { name: /Scheduled Post/ })
|
|
177
|
+
fireEvent.click(queueButtons[queueButtons.length - 1]!)
|
|
178
|
+
expect(onNavigate).toHaveBeenCalledWith('/posts/blog/p9/edit')
|
|
179
|
+
})
|
|
180
|
+
})
|
package/src/lib/useApiData.ts
CHANGED
|
@@ -6,6 +6,34 @@ export interface UseApiDataOptions {
|
|
|
6
6
|
maxRetries?: number
|
|
7
7
|
/** Base delay in ms for exponential backoff (default 1000). Actual delay = base * 2^attempt. */
|
|
8
8
|
retryBaseMs?: number
|
|
9
|
+
/**
|
|
10
|
+
* When set, successful responses are cached in-memory keyed by endpoint
|
|
11
|
+
* for this many ms and reused on the next mount, skipping the network
|
|
12
|
+
* (cache-first). Opt-in — omit to keep the default always-fetch
|
|
13
|
+
* behaviour. `refetch()` always bypasses the cache. Useful for views
|
|
14
|
+
* that remount frequently (e.g. dashboard navigation) so they don't
|
|
15
|
+
* re-issue the same requests on every visit.
|
|
16
|
+
*/
|
|
17
|
+
cacheMs?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Process-wide response cache for `cacheMs` consumers. Keyed by endpoint
|
|
22
|
+
* (note: the locale query param is appended downstream by `cmsApi`, so a
|
|
23
|
+
* locale switch is not reflected here — only enable `cacheMs` for
|
|
24
|
+
* locale-invariant endpoints like `/stats`).
|
|
25
|
+
*/
|
|
26
|
+
const responseCache = new Map<string, { data: unknown; at: number }>()
|
|
27
|
+
|
|
28
|
+
function readCache<T>(endpoint: string, cacheMs: number): { value: T | null } | null {
|
|
29
|
+
const hit = responseCache.get(endpoint)
|
|
30
|
+
if (hit && Date.now() - hit.at < cacheMs) return { value: hit.data as T | null }
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Test-only: drop all cached responses so cases don't bleed into each other. */
|
|
35
|
+
export function __clearApiDataCache(): void {
|
|
36
|
+
responseCache.clear()
|
|
9
37
|
}
|
|
10
38
|
|
|
11
39
|
export interface UseApiDataResult<T> {
|
|
@@ -22,9 +50,12 @@ export function useApiData<T>(
|
|
|
22
50
|
defaultValue: T | null = null,
|
|
23
51
|
options: UseApiDataOptions = {},
|
|
24
52
|
): UseApiDataResult<T> {
|
|
25
|
-
const { maxRetries = 3, retryBaseMs = 1000 } = options
|
|
26
|
-
|
|
27
|
-
|
|
53
|
+
const { maxRetries = 3, retryBaseMs = 1000, cacheMs } = options
|
|
54
|
+
// Read the cache synchronously for the initial state so a warm cache
|
|
55
|
+
// hit paints immediately instead of flashing a loading spinner.
|
|
56
|
+
const cachedInitial = endpoint && cacheMs ? readCache<T>(endpoint, cacheMs) : null
|
|
57
|
+
const [data, setData] = useState<T | null>(cachedInitial ? cachedInitial.value : defaultValue)
|
|
58
|
+
const [loading, setLoading] = useState(endpoint !== null && !cachedInitial)
|
|
28
59
|
const [error, setError] = useState<string | null>(null)
|
|
29
60
|
const [exhausted, setExhausted] = useState(false)
|
|
30
61
|
const abortRef = useRef<AbortController | null>(null)
|
|
@@ -32,12 +63,24 @@ export function useApiData<T>(
|
|
|
32
63
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
33
64
|
|
|
34
65
|
const fetchData = useCallback(
|
|
35
|
-
async (isRetry = false) => {
|
|
66
|
+
async (isRetry = false, forceRefresh = false) => {
|
|
36
67
|
if (!endpoint) {
|
|
37
68
|
setLoading(false)
|
|
38
69
|
return
|
|
39
70
|
}
|
|
40
71
|
|
|
72
|
+
// Cache-first: a fresh cached response short-circuits the network.
|
|
73
|
+
// `refetch()` passes `forceRefresh` to bypass this.
|
|
74
|
+
if (!isRetry && !forceRefresh && cacheMs) {
|
|
75
|
+
const hit = readCache<T>(endpoint, cacheMs)
|
|
76
|
+
if (hit) {
|
|
77
|
+
setData(hit.value)
|
|
78
|
+
setError(null)
|
|
79
|
+
setLoading(false)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
41
84
|
abortRef.current?.abort()
|
|
42
85
|
const controller = new AbortController()
|
|
43
86
|
abortRef.current = controller
|
|
@@ -65,12 +108,14 @@ export function useApiData<T>(
|
|
|
65
108
|
setExhausted(true)
|
|
66
109
|
setLoading(false)
|
|
67
110
|
} else {
|
|
68
|
-
|
|
111
|
+
const value = res.data ?? null
|
|
112
|
+
setData(value)
|
|
69
113
|
setLoading(false)
|
|
70
114
|
retryCountRef.current = 0
|
|
115
|
+
if (cacheMs) responseCache.set(endpoint, { data: value, at: Date.now() })
|
|
71
116
|
}
|
|
72
117
|
},
|
|
73
|
-
[endpoint, maxRetries, retryBaseMs],
|
|
118
|
+
[endpoint, maxRetries, retryBaseMs, cacheMs],
|
|
74
119
|
)
|
|
75
120
|
|
|
76
121
|
const manualRefetch = useCallback(() => {
|
|
@@ -80,7 +125,7 @@ export function useApiData<T>(
|
|
|
80
125
|
}
|
|
81
126
|
retryCountRef.current = 0
|
|
82
127
|
setExhausted(false)
|
|
83
|
-
fetchData(false)
|
|
128
|
+
fetchData(false, true)
|
|
84
129
|
}, [fetchData])
|
|
85
130
|
|
|
86
131
|
useEffect(() => {
|
package/src/styles/theme.css
CHANGED
|
@@ -18,6 +18,27 @@
|
|
|
18
18
|
--accent-foreground: #030213;
|
|
19
19
|
--destructive: #d4183d;
|
|
20
20
|
--destructive-foreground: #ffffff;
|
|
21
|
+
/*
|
|
22
|
+
* Semantic status + brand tokens.
|
|
23
|
+
* ----------------------------------------------------------------
|
|
24
|
+
* `destructive` already covers the "error/danger" state; these add
|
|
25
|
+
* the remaining semantic states the admin needs (success / warning /
|
|
26
|
+
* info) plus the violet brand accent used for links and primary
|
|
27
|
+
* affordances. Values are tuned to read as TEXT on both the card
|
|
28
|
+
* surface and their own `/10` tint (badge backgrounds), and as a
|
|
29
|
+
* solid fill (dots, progress bars). The `*-foreground` variants are
|
|
30
|
+
* for text placed on a SOLID fill. Light values match the previous
|
|
31
|
+
* emerald/amber/blue/violet-600 palette so tokenizing is a no-op
|
|
32
|
+
* visually; dark values track the *-300/*-400 tints used before.
|
|
33
|
+
*/
|
|
34
|
+
--success: oklch(0.5 0.13 155);
|
|
35
|
+
--success-foreground: #ffffff;
|
|
36
|
+
--warning: oklch(0.52 0.13 73);
|
|
37
|
+
--warning-foreground: oklch(0.205 0 0);
|
|
38
|
+
--info: oklch(0.5 0.17 254);
|
|
39
|
+
--info-foreground: #ffffff;
|
|
40
|
+
--brand: oklch(0.54 0.23 293);
|
|
41
|
+
--brand-foreground: #ffffff;
|
|
21
42
|
--border: rgba(0, 0, 0, 0.1);
|
|
22
43
|
--input: transparent;
|
|
23
44
|
--input-background: #f3f3f5;
|
|
@@ -59,6 +80,14 @@
|
|
|
59
80
|
--accent-foreground: oklch(0.985 0 0);
|
|
60
81
|
--destructive: oklch(0.396 0.141 25.723);
|
|
61
82
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
|
83
|
+
--success: oklch(0.72 0.15 155);
|
|
84
|
+
--success-foreground: oklch(0.205 0 0);
|
|
85
|
+
--warning: oklch(0.8 0.13 80);
|
|
86
|
+
--warning-foreground: oklch(0.205 0 0);
|
|
87
|
+
--info: oklch(0.72 0.14 252);
|
|
88
|
+
--info-foreground: oklch(0.205 0 0);
|
|
89
|
+
--brand: oklch(0.72 0.18 293);
|
|
90
|
+
--brand-foreground: oklch(0.205 0 0);
|
|
62
91
|
--border: oklch(0.269 0 0);
|
|
63
92
|
--input: oklch(0.269 0 0);
|
|
64
93
|
--input-background: oklch(0.205 0 0);
|
|
@@ -98,6 +127,14 @@
|
|
|
98
127
|
--color-accent-foreground: var(--accent-foreground);
|
|
99
128
|
--color-destructive: var(--destructive);
|
|
100
129
|
--color-destructive-foreground: var(--destructive-foreground);
|
|
130
|
+
--color-success: var(--success);
|
|
131
|
+
--color-success-foreground: var(--success-foreground);
|
|
132
|
+
--color-warning: var(--warning);
|
|
133
|
+
--color-warning-foreground: var(--warning-foreground);
|
|
134
|
+
--color-info: var(--info);
|
|
135
|
+
--color-info-foreground: var(--info-foreground);
|
|
136
|
+
--color-brand: var(--brand);
|
|
137
|
+
--color-brand-foreground: var(--brand-foreground);
|
|
101
138
|
--color-border: var(--border);
|
|
102
139
|
--color-input: var(--input);
|
|
103
140
|
--color-input-background: var(--input-background);
|