@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.
@@ -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
+ })
@@ -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
- const [data, setData] = useState<T | null>(defaultValue)
27
- const [loading, setLoading] = useState(endpoint !== null)
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
- setData(res.data ?? null)
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(() => {
@@ -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);