@actuate-media/cms-admin 0.32.0 → 0.33.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__/views/seo-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-settings.render.test.js +152 -0
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -0
- package/dist/__tests__/views/settings-save-footer.render.test.js +16 -9
- package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/MediaPickerModal.d.ts +15 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +17 -1
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/views/Settings.js +2 -2
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/SeoSettingsTab.d.ts +7 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -0
- package/dist/views/settings/SeoSettingsTab.js +126 -0
- package/dist/views/settings/SeoSettingsTab.js.map +1 -0
- package/dist/views/settings/useSeoSettings.d.ts +79 -0
- package/dist/views/settings/useSeoSettings.d.ts.map +1 -0
- package/dist/views/settings/useSeoSettings.js +278 -0
- package/dist/views/settings/useSeoSettings.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/views/seo-settings.render.test.tsx +219 -0
- package/src/__tests__/views/settings-save-footer.render.test.tsx +16 -9
- package/src/components/MediaPickerModal.tsx +39 -2
- package/src/views/Settings.tsx +2 -2
- package/src/views/settings/SeoSettingsTab.tsx +658 -0
- package/src/views/settings/useSeoSettings.ts +374 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { cmsApi } from '../../lib/api.js';
|
|
4
|
+
const EMPTY_FORM = {
|
|
5
|
+
titleTemplate: '',
|
|
6
|
+
defaultMetaDescription: '',
|
|
7
|
+
defaultOgImage: '',
|
|
8
|
+
defaultSocialImageAssetId: '',
|
|
9
|
+
googleVerification: '',
|
|
10
|
+
bingVerification: '',
|
|
11
|
+
yandexVerification: '',
|
|
12
|
+
pinterestVerification: '',
|
|
13
|
+
facebookDomainVerification: '',
|
|
14
|
+
autoGenerateSitemap: true,
|
|
15
|
+
pingOnPublish: true,
|
|
16
|
+
};
|
|
17
|
+
/** Recommended max length for the default meta description (SERP truncation). */
|
|
18
|
+
export const META_DESCRIPTION_MAX = 160;
|
|
19
|
+
/** Below this, the description is likely too thin to be useful. */
|
|
20
|
+
export const META_DESCRIPTION_MIN = 70;
|
|
21
|
+
// Mirror of cms-core's `SITE_TITLE_TOKENS` (kept tiny + stable so the client can
|
|
22
|
+
// validate without importing the full cms-core module graph). The server
|
|
23
|
+
// re-validates on save via `validateTitleTemplate`, so this is a UX aid only.
|
|
24
|
+
const SITE_TITLE_TOKENS = ['page', 'site', 'type', 'separator', 'author', 'date', 'category', 'tag'];
|
|
25
|
+
const TOKEN_RE = /%(\w+)%/g;
|
|
26
|
+
export function validateTitleTemplateClient(template) {
|
|
27
|
+
if (!template || template.trim() === '')
|
|
28
|
+
return { valid: true, unknownTokens: [] };
|
|
29
|
+
const known = new Set(SITE_TITLE_TOKENS);
|
|
30
|
+
const unknown = [];
|
|
31
|
+
let hasKnown = false;
|
|
32
|
+
for (const m of template.matchAll(TOKEN_RE)) {
|
|
33
|
+
const t = m[1];
|
|
34
|
+
if (known.has(t))
|
|
35
|
+
hasKnown = true;
|
|
36
|
+
else if (!unknown.includes(t))
|
|
37
|
+
unknown.push(t);
|
|
38
|
+
}
|
|
39
|
+
const literal = template.replace(TOKEN_RE, '').trim();
|
|
40
|
+
if (unknown.length > 0)
|
|
41
|
+
return { valid: false, unknownTokens: unknown };
|
|
42
|
+
if (!hasKnown && literal === '')
|
|
43
|
+
return { valid: false, unknownTokens: [] };
|
|
44
|
+
return { valid: true, unknownTokens: [] };
|
|
45
|
+
}
|
|
46
|
+
function validate(form) {
|
|
47
|
+
const issues = [];
|
|
48
|
+
const tpl = validateTitleTemplateClient(form.titleTemplate);
|
|
49
|
+
if (!tpl.valid) {
|
|
50
|
+
issues.push({
|
|
51
|
+
field: 'titleTemplate',
|
|
52
|
+
message: tpl.unknownTokens.length > 0
|
|
53
|
+
? `Unsupported token${tpl.unknownTokens.length > 1 ? 's' : ''}: ${tpl.unknownTokens
|
|
54
|
+
.map((t) => `%${t}%`)
|
|
55
|
+
.join(', ')}`
|
|
56
|
+
: 'Template must include at least one token or some literal text.',
|
|
57
|
+
severity: 'error',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const desc = form.defaultMetaDescription.trim();
|
|
61
|
+
if (desc.length > META_DESCRIPTION_MAX) {
|
|
62
|
+
issues.push({
|
|
63
|
+
field: 'defaultMetaDescription',
|
|
64
|
+
message: `Default description is ${desc.length} characters — search engines truncate around ${META_DESCRIPTION_MAX}.`,
|
|
65
|
+
severity: 'warning',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
else if (desc.length > 0 && desc.length < META_DESCRIPTION_MIN) {
|
|
69
|
+
issues.push({
|
|
70
|
+
field: 'defaultMetaDescription',
|
|
71
|
+
message: `Default description is short (${desc.length} characters). Aim for ${META_DESCRIPTION_MIN}–${META_DESCRIPTION_MAX}.`,
|
|
72
|
+
severity: 'warning',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return issues;
|
|
76
|
+
}
|
|
77
|
+
export function useSeoSettings(canEdit) {
|
|
78
|
+
const [loading, setLoading] = useState(true);
|
|
79
|
+
const [loadError, setLoadError] = useState(null);
|
|
80
|
+
const [baseline, setBaseline] = useState(EMPTY_FORM);
|
|
81
|
+
const [form, setForm] = useState(EMPTY_FORM);
|
|
82
|
+
const [overrideSite, setOverrideSite] = useState({});
|
|
83
|
+
const [sources, setSources] = useState({});
|
|
84
|
+
const [siteName, setSiteName] = useState('');
|
|
85
|
+
const [siteUrl, setSiteUrl] = useState('');
|
|
86
|
+
const [isProduction, setIsProduction] = useState(false);
|
|
87
|
+
const [sitemap, setSitemap] = useState(null);
|
|
88
|
+
const [saveState, setSaveState] = useState('idle');
|
|
89
|
+
const [saveError, setSaveError] = useState(null);
|
|
90
|
+
const [regenerating, setRegenerating] = useState(false);
|
|
91
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
let cancelled = false;
|
|
94
|
+
setLoading(true);
|
|
95
|
+
setLoadError(null);
|
|
96
|
+
Promise.all([
|
|
97
|
+
cmsApi('/seo/config'),
|
|
98
|
+
cmsApi('/seo/crawl-settings'),
|
|
99
|
+
cmsApi('/seo/sitemap/status'),
|
|
100
|
+
cmsApi('/seo/index-status'),
|
|
101
|
+
])
|
|
102
|
+
.then(([seoRes, crawlRes, sitemapRes, statusRes]) => {
|
|
103
|
+
if (cancelled)
|
|
104
|
+
return;
|
|
105
|
+
if (seoRes.error && seoRes.status !== 404)
|
|
106
|
+
setLoadError(seoRes.error);
|
|
107
|
+
const seo = seoRes.data ?? {};
|
|
108
|
+
const effectiveSite = seo.effective?.site ?? {};
|
|
109
|
+
const staticSite = seo.static?.site ?? {};
|
|
110
|
+
const overSite = seo.overrides?.site ?? {};
|
|
111
|
+
const verification = effectiveSite.verification ?? {};
|
|
112
|
+
const crawl = crawlRes.data ?? {};
|
|
113
|
+
const loaded = {
|
|
114
|
+
titleTemplate: String(effectiveSite.titleTemplate ?? ''),
|
|
115
|
+
defaultMetaDescription: String(effectiveSite.defaultMetaDescription ?? ''),
|
|
116
|
+
defaultOgImage: String(effectiveSite.defaultOgImage ?? ''),
|
|
117
|
+
defaultSocialImageAssetId: String(effectiveSite.defaultSocialImageAssetId ?? ''),
|
|
118
|
+
googleVerification: String(verification.googleSearchConsole ?? ''),
|
|
119
|
+
bingVerification: String(verification.bingWebmasterTools ?? ''),
|
|
120
|
+
yandexVerification: String(verification.yandex ?? ''),
|
|
121
|
+
pinterestVerification: String(verification.pinterest ?? ''),
|
|
122
|
+
facebookDomainVerification: String(verification.facebookDomain ?? ''),
|
|
123
|
+
autoGenerateSitemap: crawl.autoRegenerateSitemapOnPublish !== false,
|
|
124
|
+
pingOnPublish: crawl.pingSearchEnginesOnPublish !== false,
|
|
125
|
+
};
|
|
126
|
+
// `editable` reflects the field's MANAGEMENT source (config vs database),
|
|
127
|
+
// not the user's role — role gates the inputs via `canEdit`. This keeps
|
|
128
|
+
// the source badge (and the field's accessible label) stable across
|
|
129
|
+
// roles, and only surfaces it when a value is locked by config.
|
|
130
|
+
const configManaged = (key) => staticSite[key]
|
|
131
|
+
? { source: 'config', editable: false, reason: 'Set in actuate.config.ts' }
|
|
132
|
+
: { source: 'database', editable: true };
|
|
133
|
+
setSources({
|
|
134
|
+
titleTemplate: configManaged('titleTemplate'),
|
|
135
|
+
defaultMetaDescription: configManaged('defaultMetaDescription'),
|
|
136
|
+
defaultOgImage: configManaged('defaultOgImage'),
|
|
137
|
+
verification: { source: 'database', editable: true },
|
|
138
|
+
sitemap: { source: 'database', editable: true },
|
|
139
|
+
});
|
|
140
|
+
setBaseline(loaded);
|
|
141
|
+
setForm(loaded);
|
|
142
|
+
setOverrideSite(overSite ?? {});
|
|
143
|
+
setSiteName(String(effectiveSite.siteName ?? ''));
|
|
144
|
+
setSiteUrl(String(effectiveSite.siteUrl ?? ''));
|
|
145
|
+
if (statusRes.data?.environment)
|
|
146
|
+
setIsProduction(statusRes.data.environment === 'production');
|
|
147
|
+
if (!sitemapRes.error && sitemapRes.data)
|
|
148
|
+
setSitemap(sitemapRes.data);
|
|
149
|
+
})
|
|
150
|
+
.catch(() => {
|
|
151
|
+
if (!cancelled)
|
|
152
|
+
setLoadError('Failed to load SEO settings.');
|
|
153
|
+
})
|
|
154
|
+
.finally(() => {
|
|
155
|
+
if (!cancelled)
|
|
156
|
+
setLoading(false);
|
|
157
|
+
});
|
|
158
|
+
return () => {
|
|
159
|
+
cancelled = true;
|
|
160
|
+
};
|
|
161
|
+
}, [reloadKey, canEdit]);
|
|
162
|
+
const setField = useCallback((key, value) => {
|
|
163
|
+
setForm((prev) => ({ ...prev, [key]: value }));
|
|
164
|
+
setSaveState('idle');
|
|
165
|
+
}, []);
|
|
166
|
+
const dirty = useMemo(() => Object.keys(form).some((k) => form[k] !== baseline[k]), [form, baseline]);
|
|
167
|
+
const issues = useMemo(() => validate(form), [form]);
|
|
168
|
+
const hasErrors = issues.some((i) => i.severity === 'error');
|
|
169
|
+
const refetchSitemap = useCallback(async () => {
|
|
170
|
+
const res = await cmsApi('/seo/sitemap/status');
|
|
171
|
+
if (!res.error && res.data)
|
|
172
|
+
setSitemap(res.data);
|
|
173
|
+
}, []);
|
|
174
|
+
const save = useCallback(async () => {
|
|
175
|
+
if (!canEdit || hasErrors)
|
|
176
|
+
return;
|
|
177
|
+
setSaveState('saving');
|
|
178
|
+
setSaveError(null);
|
|
179
|
+
const siteChanged = form.titleTemplate !== baseline.titleTemplate ||
|
|
180
|
+
form.defaultMetaDescription !== baseline.defaultMetaDescription ||
|
|
181
|
+
form.defaultOgImage !== baseline.defaultOgImage ||
|
|
182
|
+
form.defaultSocialImageAssetId !== baseline.defaultSocialImageAssetId ||
|
|
183
|
+
form.googleVerification !== baseline.googleVerification ||
|
|
184
|
+
form.bingVerification !== baseline.bingVerification ||
|
|
185
|
+
form.yandexVerification !== baseline.yandexVerification ||
|
|
186
|
+
form.pinterestVerification !== baseline.pinterestVerification ||
|
|
187
|
+
form.facebookDomainVerification !== baseline.facebookDomainVerification;
|
|
188
|
+
const sitemapChanged = form.autoGenerateSitemap !== baseline.autoGenerateSitemap ||
|
|
189
|
+
form.pingOnPublish !== baseline.pingOnPublish;
|
|
190
|
+
try {
|
|
191
|
+
// 1. Meta defaults + verification → shared /seo/config (merge over the
|
|
192
|
+
// current override site so General's siteUrl/robots aren't wiped).
|
|
193
|
+
if (siteChanged) {
|
|
194
|
+
const nextSite = {
|
|
195
|
+
...overrideSite,
|
|
196
|
+
titleTemplate: form.titleTemplate.trim() || undefined,
|
|
197
|
+
defaultMetaDescription: form.defaultMetaDescription.trim() || undefined,
|
|
198
|
+
defaultOgImage: form.defaultOgImage.trim() || undefined,
|
|
199
|
+
defaultSocialImageAssetId: form.defaultSocialImageAssetId.trim() || undefined,
|
|
200
|
+
verification: {
|
|
201
|
+
...(overrideSite.verification ?? {}),
|
|
202
|
+
googleSearchConsole: form.googleVerification.trim() || undefined,
|
|
203
|
+
bingWebmasterTools: form.bingVerification.trim() || undefined,
|
|
204
|
+
yandex: form.yandexVerification.trim() || undefined,
|
|
205
|
+
pinterest: form.pinterestVerification.trim() || undefined,
|
|
206
|
+
facebookDomain: form.facebookDomainVerification.trim() || undefined,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
const res = await cmsApi('/seo/config', {
|
|
210
|
+
method: 'PUT',
|
|
211
|
+
body: JSON.stringify({ site: nextSite }),
|
|
212
|
+
});
|
|
213
|
+
if (res.error)
|
|
214
|
+
throw new Error(res.error);
|
|
215
|
+
setOverrideSite(nextSite);
|
|
216
|
+
}
|
|
217
|
+
// 2. Sitemap toggles → shared /seo/crawl-settings (`module`).
|
|
218
|
+
if (sitemapChanged) {
|
|
219
|
+
const res = await cmsApi('/seo/crawl-settings', {
|
|
220
|
+
method: 'PUT',
|
|
221
|
+
body: JSON.stringify({
|
|
222
|
+
autoRegenerateSitemapOnPublish: form.autoGenerateSitemap,
|
|
223
|
+
pingSearchEnginesOnPublish: form.pingOnPublish,
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
if (res.error)
|
|
227
|
+
throw new Error(res.error);
|
|
228
|
+
}
|
|
229
|
+
setBaseline(form);
|
|
230
|
+
setSaveState('success');
|
|
231
|
+
if (sitemapChanged)
|
|
232
|
+
await refetchSitemap();
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
setSaveState('error');
|
|
236
|
+
setSaveError(err instanceof Error ? err.message : 'Failed to save SEO settings.');
|
|
237
|
+
}
|
|
238
|
+
}, [canEdit, hasErrors, form, baseline, overrideSite, refetchSitemap]);
|
|
239
|
+
const reset = useCallback(() => {
|
|
240
|
+
setForm(baseline);
|
|
241
|
+
setSaveState('idle');
|
|
242
|
+
setSaveError(null);
|
|
243
|
+
}, [baseline]);
|
|
244
|
+
const refetch = useCallback(() => setReloadKey((k) => k + 1), []);
|
|
245
|
+
const regenerateSitemap = useCallback(async () => {
|
|
246
|
+
setRegenerating(true);
|
|
247
|
+
try {
|
|
248
|
+
const res = await cmsApi('/seo/sitemap/regenerate', { method: 'POST' });
|
|
249
|
+
if (!res.error && res.data)
|
|
250
|
+
setSitemap((prev) => ({ ...(prev ?? res.data), ...res.data }));
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
setRegenerating(false);
|
|
254
|
+
}
|
|
255
|
+
}, []);
|
|
256
|
+
return {
|
|
257
|
+
loading,
|
|
258
|
+
loadError,
|
|
259
|
+
form,
|
|
260
|
+
setField,
|
|
261
|
+
dirty,
|
|
262
|
+
issues,
|
|
263
|
+
hasErrors,
|
|
264
|
+
sources,
|
|
265
|
+
siteName,
|
|
266
|
+
siteUrl,
|
|
267
|
+
isProduction,
|
|
268
|
+
sitemap,
|
|
269
|
+
saveState,
|
|
270
|
+
saveError,
|
|
271
|
+
save,
|
|
272
|
+
reset,
|
|
273
|
+
refetch,
|
|
274
|
+
regenerateSitemap,
|
|
275
|
+
regenerating,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
//# sourceMappingURL=useSeoSettings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useSeoSettings.js","sourceRoot":"","sources":["../../../src/views/settings/useSeoSettings.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"tailwindcss": "^4.0.0",
|
|
89
89
|
"typescript": "^5.7.0",
|
|
90
90
|
"vitest": "^4.1.0",
|
|
91
|
-
"@actuate-media/cms-core": "0.
|
|
91
|
+
"@actuate-media/cms-core": "0.42.0",
|
|
92
92
|
"@actuate-media/component-blocks": "0.2.0"
|
|
93
93
|
},
|
|
94
94
|
"scripts": {
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
// The SEO defaults tab reads four shared backends: /seo/config (meta defaults +
|
|
6
|
+
// verification), /seo/crawl-settings (sitemap toggles), /seo/sitemap/status
|
|
7
|
+
// (read-only summary), and /seo/index-status (environment).
|
|
8
|
+
const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) => {
|
|
9
|
+
const method = options?.method ?? 'GET'
|
|
10
|
+
if (endpoint === '/seo/config' && method === 'GET') {
|
|
11
|
+
return {
|
|
12
|
+
data: {
|
|
13
|
+
static: { site: null },
|
|
14
|
+
overrides: { site: {} },
|
|
15
|
+
effective: {
|
|
16
|
+
site: {
|
|
17
|
+
siteName: 'Acme',
|
|
18
|
+
siteUrl: 'https://acme.test',
|
|
19
|
+
titleTemplate: '%page% — %site%',
|
|
20
|
+
defaultMetaDescription: 'Seed description.',
|
|
21
|
+
verification: { googleSearchConsole: '', bingWebmasterTools: '' },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
collections: [],
|
|
25
|
+
},
|
|
26
|
+
status: 200,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (endpoint === '/seo/crawl-settings' && method === 'GET') {
|
|
30
|
+
return {
|
|
31
|
+
data: {
|
|
32
|
+
autoRegenerateSitemapOnPublish: true,
|
|
33
|
+
pingSearchEnginesOnPublish: true,
|
|
34
|
+
sitemapEnabled: true,
|
|
35
|
+
},
|
|
36
|
+
status: 200,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (endpoint === '/seo/sitemap/status' && method === 'GET') {
|
|
40
|
+
return {
|
|
41
|
+
data: {
|
|
42
|
+
sitemapUrl: 'https://acme.test/api/cms/sitemap.xml',
|
|
43
|
+
lastGeneratedAt: '2026-01-02T03:04:05.000Z',
|
|
44
|
+
pagesIncluded: 12,
|
|
45
|
+
pagesExcluded: 1,
|
|
46
|
+
warnings: [],
|
|
47
|
+
},
|
|
48
|
+
status: 200,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (endpoint === '/seo/index-status' && method === 'GET') {
|
|
52
|
+
return { data: { environment: 'preview' }, status: 200 }
|
|
53
|
+
}
|
|
54
|
+
return { data: {}, status: 200 }
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
vi.mock('../../lib/api.js', () => ({
|
|
58
|
+
cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any),
|
|
59
|
+
ensureCsrfToken: vi.fn(async () => {}),
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
// Stub the Media Center picker: when open, expose a button that selects a known
|
|
63
|
+
// asset (id + url), exercising the asset-id storage path without the real
|
|
64
|
+
// MediaBrowser/network stack.
|
|
65
|
+
vi.mock('../../components/MediaPickerModal.js', () => ({
|
|
66
|
+
MediaPickerModal: ({
|
|
67
|
+
open,
|
|
68
|
+
onSelect,
|
|
69
|
+
onSelectItem,
|
|
70
|
+
}: {
|
|
71
|
+
open: boolean
|
|
72
|
+
onSelect: (url: string) => void
|
|
73
|
+
onSelectItem?: (item: { id: string }) => void
|
|
74
|
+
}) =>
|
|
75
|
+
open ? (
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={() => {
|
|
79
|
+
onSelect('https://cdn.test/og.png')
|
|
80
|
+
onSelectItem?.({ id: 'asset-123' })
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
pick-asset
|
|
84
|
+
</button>
|
|
85
|
+
) : null,
|
|
86
|
+
}))
|
|
87
|
+
|
|
88
|
+
const { SeoSettingsTab } = await import('../../views/settings/SeoSettingsTab.js')
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
cmsApi.mockClear()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('SeoSettingsTab', () => {
|
|
95
|
+
it('renders the workspace callout and loads meta defaults from /seo/config', async () => {
|
|
96
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
97
|
+
|
|
98
|
+
expect(await screen.findByText('Looking for the full SEO workspace?')).toBeTruthy()
|
|
99
|
+
expect((screen.getByLabelText('Title template') as HTMLInputElement).value).toBe(
|
|
100
|
+
'%page% — %site%',
|
|
101
|
+
)
|
|
102
|
+
expect((screen.getByLabelText('Default meta description') as HTMLTextAreaElement).value).toBe(
|
|
103
|
+
'Seed description.',
|
|
104
|
+
)
|
|
105
|
+
// Clean form → Save disabled.
|
|
106
|
+
expect(
|
|
107
|
+
(screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement).disabled,
|
|
108
|
+
).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('routes "Open SEO" to /seo', async () => {
|
|
112
|
+
const onNavigate = vi.fn()
|
|
113
|
+
render(<SeoSettingsTab role="ADMIN" onNavigate={onNavigate} />)
|
|
114
|
+
await screen.findByText('Looking for the full SEO workspace?')
|
|
115
|
+
fireEvent.click(screen.getByRole('button', { name: /Open SEO/ }))
|
|
116
|
+
expect(onNavigate).toHaveBeenCalledWith('/seo')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('rejects an unsupported title-template token and blocks save', async () => {
|
|
120
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
121
|
+
const tpl = (await screen.findByLabelText('Title template')) as HTMLInputElement
|
|
122
|
+
fireEvent.change(tpl, { target: { value: '%page% %bogus%' } })
|
|
123
|
+
|
|
124
|
+
expect((await screen.findAllByText(/Unsupported token/i)).length).toBeGreaterThan(0)
|
|
125
|
+
expect(
|
|
126
|
+
(screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement).disabled,
|
|
127
|
+
).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('shows a character count and warns when the description is too long', async () => {
|
|
131
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
132
|
+
const desc = (await screen.findByLabelText('Default meta description')) as HTMLTextAreaElement
|
|
133
|
+
fireEvent.change(desc, { target: { value: 'x'.repeat(200) } })
|
|
134
|
+
|
|
135
|
+
expect(screen.getByText('200/160')).toBeTruthy()
|
|
136
|
+
expect((await screen.findAllByText(/truncate/i)).length).toBeGreaterThan(0)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('persists meta defaults + verification to the shared /seo/config', async () => {
|
|
140
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
141
|
+
const google = (await screen.findByLabelText('Google Search Console')) as HTMLInputElement
|
|
142
|
+
fireEvent.change(google, { target: { value: 'goog-token' } })
|
|
143
|
+
|
|
144
|
+
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
|
|
145
|
+
expect(save.disabled).toBe(false)
|
|
146
|
+
fireEvent.click(save)
|
|
147
|
+
|
|
148
|
+
await waitFor(() =>
|
|
149
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
150
|
+
'/seo/config',
|
|
151
|
+
expect.objectContaining({ method: 'PUT' }),
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
const putCall = cmsApi.mock.calls.find(
|
|
155
|
+
([e, o]) => e === '/seo/config' && (o as { method?: string })?.method === 'PUT',
|
|
156
|
+
)
|
|
157
|
+
const body = JSON.parse((putCall![1] as { body: string }).body)
|
|
158
|
+
expect(body.site.verification.googleSearchConsole).toBe('goog-token')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('stores the selected social image asset id from the Media Center picker', async () => {
|
|
162
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
163
|
+
await screen.findByLabelText('Title template')
|
|
164
|
+
|
|
165
|
+
fireEvent.click(screen.getByRole('button', { name: 'Choose image' }))
|
|
166
|
+
fireEvent.click(screen.getByRole('button', { name: 'pick-asset' }))
|
|
167
|
+
|
|
168
|
+
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
|
|
169
|
+
expect(save.disabled).toBe(false)
|
|
170
|
+
fireEvent.click(save)
|
|
171
|
+
|
|
172
|
+
await waitFor(() =>
|
|
173
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
174
|
+
'/seo/config',
|
|
175
|
+
expect.objectContaining({ method: 'PUT' }),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
const putCall = cmsApi.mock.calls.find(
|
|
179
|
+
([e, o]) => e === '/seo/config' && (o as { method?: string })?.method === 'PUT',
|
|
180
|
+
)
|
|
181
|
+
const body = JSON.parse((putCall![1] as { body: string }).body)
|
|
182
|
+
expect(body.site.defaultSocialImageAssetId).toBe('asset-123')
|
|
183
|
+
expect(body.site.defaultOgImage).toBe('https://cdn.test/og.png')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('persists sitemap toggles to the shared /seo/crawl-settings', async () => {
|
|
187
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
188
|
+
await screen.findByLabelText('Title template')
|
|
189
|
+
fireEvent.click(screen.getByRole('switch', { name: 'Ping search engines on publish' }))
|
|
190
|
+
|
|
191
|
+
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
|
|
192
|
+
|
|
193
|
+
await waitFor(() =>
|
|
194
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
195
|
+
'/seo/crawl-settings',
|
|
196
|
+
expect.objectContaining({ method: 'PUT' }),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('shows the read-only sitemap summary', async () => {
|
|
202
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
203
|
+
await screen.findByLabelText('Title template')
|
|
204
|
+
expect(screen.getByText('Included URLs')).toBeTruthy()
|
|
205
|
+
expect(screen.getByText('12')).toBeTruthy()
|
|
206
|
+
expect(screen.getByRole('link', { name: /View sitemap/ })).toBeTruthy()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('is read-only for non-admins', async () => {
|
|
210
|
+
render(<SeoSettingsTab role="EDITOR" />)
|
|
211
|
+
expect(await screen.findByText(/read-only access to SEO defaults/i)).toBeTruthy()
|
|
212
|
+
// Scope to the Meta Defaults region: the advanced SEOConfigPanel disclosure
|
|
213
|
+
// also mounts and can briefly produce overlapping label matches.
|
|
214
|
+
const metaCard = screen.getByRole('region', { name: 'Meta Defaults' })
|
|
215
|
+
expect((within(metaCard).getByLabelText('Title template') as HTMLInputElement).disabled).toBe(
|
|
216
|
+
true,
|
|
217
|
+
)
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -3,11 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
3
3
|
import { render, screen, waitFor } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Regression: the shared "Save Changes" footer
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Regression: the shared no-op "Save Changes" footer (driven by the settings
|
|
7
|
+
* global `handleSave`) must only render on tabs that actually persist through
|
|
8
|
+
* it (currently just Layout). On self-managed tabs like SEO it used to render a
|
|
9
|
+
* SECOND footer whose click toasted "Settings saved successfully!" while
|
|
10
|
+
* persisting none of the tab's edits — misleading, no-op UI.
|
|
11
|
+
*
|
|
12
|
+
* The SEO tab now owns a real `SettingsSaveBar` (also labelled "Save Changes")
|
|
13
|
+
* that persists through `/seo/config`, so the contract is: exactly ONE
|
|
14
|
+
* "Save Changes" button on the SEO tab — the tab's own — never the global one
|
|
15
|
+
* stacked on top of it.
|
|
11
16
|
*/
|
|
12
17
|
const SEO_CONFIG = {
|
|
13
18
|
static: { site: null },
|
|
@@ -44,15 +49,17 @@ beforeEach(() => {
|
|
|
44
49
|
})
|
|
45
50
|
|
|
46
51
|
describe('Settings shared save footer', () => {
|
|
47
|
-
it(
|
|
52
|
+
it("renders only the SEO tab's own save bar, not the global footer on top", async () => {
|
|
48
53
|
render(<Settings config={LAYOUT_CONFIG} session={SESSION} initialTab="seo" />)
|
|
49
54
|
|
|
50
|
-
// The
|
|
55
|
+
// The Advanced disclosure still mounts the per-collection SEO panel, which
|
|
56
|
+
// owns its own persistence ("Save SEO defaults").
|
|
51
57
|
expect(
|
|
52
58
|
(await screen.findAllByRole('button', { name: 'Save SEO defaults' })).length,
|
|
53
59
|
).toBeGreaterThan(0)
|
|
54
|
-
// The
|
|
55
|
-
|
|
60
|
+
// The tab's own SettingsSaveBar is the ONLY "Save Changes" button — the
|
|
61
|
+
// no-op global footer must not be stacked on top of it.
|
|
62
|
+
expect(screen.getAllByRole('button', { name: 'Save Changes' })).toHaveLength(1)
|
|
56
63
|
})
|
|
57
64
|
|
|
58
65
|
it('renders the global "Save Changes" footer on the Layout tab', async () => {
|
|
@@ -16,14 +16,35 @@ interface MediaItem {
|
|
|
16
16
|
height?: number
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** Minimal selected-asset shape exposed to callers that need the asset id. */
|
|
20
|
+
export interface SelectedMediaItem {
|
|
21
|
+
id: string
|
|
22
|
+
storageKey: string
|
|
23
|
+
filename: string
|
|
24
|
+
mimeType: string
|
|
25
|
+
width?: number
|
|
26
|
+
height?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
export interface MediaPickerModalProps {
|
|
20
30
|
open: boolean
|
|
21
31
|
onClose: () => void
|
|
22
32
|
onSelect: (url: string, alt?: string) => void
|
|
33
|
+
/**
|
|
34
|
+
* Optional richer callback fired alongside `onSelect` with the full media
|
|
35
|
+
* item, so callers that need to store the asset id (not just the URL) can.
|
|
36
|
+
*/
|
|
37
|
+
onSelectItem?: (item: SelectedMediaItem) => void
|
|
23
38
|
accept?: string
|
|
24
39
|
}
|
|
25
40
|
|
|
26
|
-
export function MediaPickerModal({
|
|
41
|
+
export function MediaPickerModal({
|
|
42
|
+
open,
|
|
43
|
+
onClose,
|
|
44
|
+
onSelect,
|
|
45
|
+
onSelectItem,
|
|
46
|
+
accept,
|
|
47
|
+
}: MediaPickerModalProps) {
|
|
27
48
|
const [tab, setTab] = useState<'library' | 'upload'>('library')
|
|
28
49
|
const [search, setSearch] = useState('')
|
|
29
50
|
const [uploading, setUploading] = useState(false)
|
|
@@ -52,9 +73,17 @@ export function MediaPickerModal({ open, onClose, onSelect, accept }: MediaPicke
|
|
|
52
73
|
if (res.error) {
|
|
53
74
|
toast.error(res.error)
|
|
54
75
|
} else if (res.data) {
|
|
55
|
-
const mediaItem = res.data as
|
|
76
|
+
const mediaItem = res.data as MediaItem
|
|
56
77
|
const url = mediaItem.storageKey ?? ''
|
|
57
78
|
toast.success(`Uploaded ${file.name}`)
|
|
79
|
+
onSelectItem?.({
|
|
80
|
+
id: mediaItem.id,
|
|
81
|
+
storageKey: mediaItem.storageKey,
|
|
82
|
+
filename: mediaItem.filename ?? file.name,
|
|
83
|
+
mimeType: mediaItem.mimeType ?? file.type,
|
|
84
|
+
width: mediaItem.width,
|
|
85
|
+
height: mediaItem.height,
|
|
86
|
+
})
|
|
58
87
|
onSelect(url, file.name)
|
|
59
88
|
onClose()
|
|
60
89
|
}
|
|
@@ -64,6 +93,14 @@ export function MediaPickerModal({ open, onClose, onSelect, accept }: MediaPicke
|
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
function handleSelectItem(item: MediaItem) {
|
|
96
|
+
onSelectItem?.({
|
|
97
|
+
id: item.id,
|
|
98
|
+
storageKey: item.storageKey,
|
|
99
|
+
filename: item.filename,
|
|
100
|
+
mimeType: item.mimeType,
|
|
101
|
+
width: item.width,
|
|
102
|
+
height: item.height,
|
|
103
|
+
})
|
|
67
104
|
onSelect(item.storageKey, item.filename)
|
|
68
105
|
onClose()
|
|
69
106
|
}
|
package/src/views/Settings.tsx
CHANGED
|
@@ -22,8 +22,8 @@ import { toast } from 'sonner'
|
|
|
22
22
|
import { useApiData } from '../lib/useApiData.js'
|
|
23
23
|
import { cmsApi } from '../lib/api.js'
|
|
24
24
|
import { RelationshipField } from '../fields/RelationshipField.js'
|
|
25
|
-
import { SEOConfigPanel } from '../components/SEOConfigPanel.js'
|
|
26
25
|
import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
|
|
26
|
+
import { SeoSettingsTab } from './settings/SeoSettingsTab.js'
|
|
27
27
|
import { AppearanceTab } from './settings/AppearanceTab.js'
|
|
28
28
|
import { SecurityTab } from './settings/SecurityTab.js'
|
|
29
29
|
import { Users as UsersView } from './Users.js'
|
|
@@ -314,7 +314,7 @@ export function Settings({
|
|
|
314
314
|
</Tabs.Content>
|
|
315
315
|
|
|
316
316
|
<Tabs.Content value="seo" className="space-y-4">
|
|
317
|
-
<
|
|
317
|
+
<SeoSettingsTab onNavigate={onNavigate} role={session?.user?.role} />
|
|
318
318
|
</Tabs.Content>
|
|
319
319
|
|
|
320
320
|
<Tabs.Content value="ai" className="space-y-4">
|