@raketa-cloud/admin 1.8.17 → 1.8.19

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.
Files changed (59) hide show
  1. package/dist/application.css +288 -11
  2. package/package.json +8 -6
  3. package/src/handlers/resources/ReportsAPIHandler.js +27 -0
  4. package/src/handlers/resources/ResourcesHandler.js +16 -0
  5. package/src/helpers/content_tasks_helper.js +1 -1
  6. package/src/helpers/reports/cwv.js +217 -0
  7. package/src/helpers/reports/googleAuth.js +149 -0
  8. package/src/helpers/reports/quick-insights.js +294 -0
  9. package/src/helpers/reports/reportsUtils.js +181 -0
  10. package/src/index.js +8 -0
  11. package/src/localCache.js +112 -0
  12. package/src/report/analytics.js +107 -0
  13. package/src/report/reports.js +390 -0
  14. package/src/routeAssets.js +74 -0
  15. package/src/routeResourceModule.js +24 -0
  16. package/src/routeStandard.js +7 -1
  17. package/src/styles/application.css +1 -1
  18. package/src/tests/handlers/RedirectsHandler.test/notes.test.js +56 -0
  19. package/src/tests/handlers/_shared/SettingsHandler.test/global_variables.test.js +54 -0
  20. package/src/tests/handlers/categories/update_defaults.test.js +35 -0
  21. package/src/tests/handlers/content_tasks/ContentTasksHandler.test/editors.test.js +29 -0
  22. package/src/tests/handlers/resource_revisions/PublishesHandler.test/schedule.test.js +137 -0
  23. package/src/tests/handlers/resource_revisions/TagHandler.test/default_locale_tags.test.js +162 -0
  24. package/src/tests/handlers/resource_revisions/WidgetHandler.test/defaults.test.js +61 -0
  25. package/src/tests/handlers/resources/DeletesHandler.test/no_redirect.test.js +55 -0
  26. package/src/tests/handlers/resources/ReportsAPIHandler.test/show.test.js +156 -0
  27. package/src/tests/handlers/resources/ResourcesHandler.test/show.test.js +8 -0
  28. package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +94 -0
  29. package/src/tests/handlers/resources/TranslationsHandler.test/edge_cases.test.js +211 -0
  30. package/src/tests/helpers/CSVExportHelper.branches.test.js +57 -0
  31. package/src/tests/helpers/FormBuilder.branches.test.js +151 -0
  32. package/src/tests/helpers/HTMLBuilder.test.js +51 -0
  33. package/src/tests/helpers/content_tasks_helper.test.js +1 -1
  34. package/src/tests/helpers/historyUtils.branches.test.js +36 -0
  35. package/src/tests/helpers/reports/cwv.test.js +239 -0
  36. package/src/tests/helpers/reports/quick-insights.test.js +593 -0
  37. package/src/tests/helpers/reports/reportsUtils.test.js +425 -0
  38. package/src/tests/helpers/utils.test.js +37 -0
  39. package/src/tests/helpers/view_helpers.test.js +87 -0
  40. package/src/tests/index.test.js +74 -0
  41. package/src/tests/localCache.test.js +236 -0
  42. package/src/tests/report/analytics.test.js +230 -0
  43. package/src/tests/report/reports.test.js +831 -0
  44. package/src/tests/routeAssets.test.js +120 -0
  45. package/src/tests/routeResourceModule.test.js +99 -2
  46. package/src/tests/routeStandard.test.js +3 -3
  47. package/src/tests/support/fakeDom.js +164 -0
  48. package/src/views/content_tasks/index.ejs +2 -2
  49. package/src/views/resources/_shared/_core_web_vitals.ejs +27 -0
  50. package/src/views/resources/_shared/_quick_insights.ejs +64 -0
  51. package/src/views/resources/_shared/_scheduled_revisions.ejs +88 -0
  52. package/src/views/resources/_shared/_title.ejs +16 -12
  53. package/src/views/resources/_shared/_toolbar.ejs +11 -21
  54. package/src/views/resources/_shared/_traffic_chart.ejs +27 -0
  55. package/src/views/resources/_shared/_translations_strip.ejs +13 -25
  56. package/src/views/resources/_shared/_vital_meter.ejs +54 -0
  57. package/src/views/resources/index.ejs +1 -1
  58. package/src/views/resources/show.ejs +88 -98
  59. package/src/views/resources/translations/create_form.ejs +14 -5
@@ -0,0 +1,390 @@
1
+ // Client-side report loading for the resource Settings page.
2
+ //
3
+ // The page renders skeletons (see _quick_insights.ejs, _traffic_chart.ejs,
4
+ // _core_web_vitals.ejs) and this fetches /:locale/api/:resources/:id/reports —
5
+ // handed over as window.RAKETA_REPORTS = { path } — then fills the values in.
6
+ // Server-side prefetching used to add the full GA + CrUX latency to every page
7
+ // load; this keeps the first paint independent of both.
8
+ (() => {
9
+ let CFG = window.RAKETA_REPORTS || {};
10
+ if (!CFG.path) return;
11
+
12
+ // Kept in step with the same note in _core_web_vitals.ejs.
13
+ let NO_DATA_NOTE =
14
+ "There is not enough data for the past 28 days to generate a report.";
15
+
16
+ // Insights covers the last 30 days (see pageInsights), CrUX a rolling 28.
17
+ let NO_INSIGHTS_NOTE =
18
+ "There is not enough data for the past 30 days to generate a report.";
19
+
20
+ let NO_CHART_NOTE = "Not enough data.";
21
+
22
+ let NO_CHART_ERROR = "Chart unavailable.";
23
+
24
+ // Shown in place of a percentage when the previous period measured nothing:
25
+ // "+∞%" is not a number anyone wants to read.
26
+ let NEW_TREND = "New";
27
+
28
+ // "No data" and "the request failed" look identical on the page unless the
29
+ // reason is spelled out — the server hands one over on every failed side
30
+ // (missing credentials, a GA/CrUX error, a bad response), so show that
31
+ // instead of blaming the data.
32
+ let MAX_NOTE = 160;
33
+
34
+ function failureNote(section, error) {
35
+ if (!error) return section + " could not be loaded.";
36
+
37
+ // The full text can carry an API payload; the page gets a readable slice.
38
+ console.error(section + " report failed:", error);
39
+
40
+ let text = String(error);
41
+ return text.length > MAX_NOTE ? text.slice(0, MAX_NOTE) + "…" : text;
42
+ }
43
+
44
+ // Sits in the same slot as the date range / collection period, so it has to
45
+ // stop reading like one.
46
+ function setFailure(el, section, error) {
47
+ if (!el) return;
48
+ el.textContent = failureNote(section, error);
49
+ el.classList.remove("text-raketa-foreground/50");
50
+ el.classList.add("text-red-600");
51
+ }
52
+
53
+ // Applied to the metric grid, the chart card and the vitals grid when there's
54
+ // nothing measured, so empty numbers can't be mistaken for results.
55
+ let MUTED = ["grayscale", "opacity-50"];
56
+
57
+ let mute = (el) => el && el.classList.add(...MUTED);
58
+
59
+ let RATING_TEXT = {
60
+ good: "text-emerald-600",
61
+ "needs-improvement": "text-amber-500",
62
+ poor: "text-red-600",
63
+ };
64
+
65
+ let ASSESSMENT = {
66
+ good: {
67
+ color: "text-emerald-500",
68
+ label: "Passed",
69
+ glyph:
70
+ "M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z",
71
+ },
72
+ "needs-improvement": {
73
+ color: "text-amber-500",
74
+ label: "Needs work",
75
+ glyph:
76
+ "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.19-1.458-1.517-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z",
77
+ },
78
+ poor: {
79
+ color: "text-red-500",
80
+ label: "Failed",
81
+ glyph:
82
+ "M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z",
83
+ },
84
+ unknown: {
85
+ color: "text-orange-500",
86
+ label: "no real-user data yet",
87
+ glyph:
88
+ "M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z",
89
+ },
90
+ };
91
+
92
+ // Formatters mirrored from the EJS partials so server- and client-rendered
93
+ // values read identically.
94
+ let nfmt = (v) => Number(v || 0).toLocaleString();
95
+
96
+ let fmtDuration = (sec) => {
97
+ sec = Math.round(sec || 0);
98
+ return Math.floor(sec / 60) + "m " + (sec % 60) + "s";
99
+ };
100
+
101
+ let fmtPct = (v) => (Number(v || 0) * 100).toFixed(1) + "%";
102
+
103
+ let fmtValue = (value, unit) =>
104
+ unit === "ms"
105
+ ? value >= 1000
106
+ ? (value / 1000).toFixed(2) + "s"
107
+ : Math.round(value) + "ms"
108
+ : Number(value).toFixed(2);
109
+
110
+ let fmtDay = (iso) =>
111
+ new Date(iso + "T00:00:00Z").toLocaleDateString("en-US", {
112
+ month: "short",
113
+ day: "numeric",
114
+ year: "numeric",
115
+ timeZone: "UTC",
116
+ });
117
+
118
+ // CrUX collectionPeriod uses {year,month,day} objects, not ISO strings.
119
+ let fmtPeriod = (p) => {
120
+ if (!p || !p.firstDate || !p.lastDate) return "";
121
+ let iso = (d) =>
122
+ d.year +
123
+ "-" +
124
+ String(d.month).padStart(2, "0") +
125
+ "-" +
126
+ String(d.day).padStart(2, "0");
127
+ return fmtDay(iso(p.firstDate)) + " → " + fmtDay(iso(p.lastDate));
128
+ };
129
+
130
+ let find = (selector) => document.querySelector(selector);
131
+ let findAll = (selector) => Array.from(document.querySelectorAll(selector));
132
+
133
+ let byMetric = (hook, metric) =>
134
+ find('[data-report="' + hook + '"][data-metric="' + metric + '"]');
135
+
136
+ // Writing text over a placeholder drops the spinner with it. The muted class
137
+ // has to come off first — it and the trend colour have equal specificity, so
138
+ // whichever Tailwind emits later would win and the green/red never showed.
139
+ function setText(el, text, colorClass) {
140
+ if (!el) return;
141
+ el.textContent = text;
142
+ if (!colorClass) return;
143
+ el.classList.remove("text-raketa-foreground/50");
144
+ el.classList.add(colorClass);
145
+ }
146
+
147
+ // A failed request would leave the spinners turning forever, so stop them.
148
+ function clearLoaders() {
149
+ findAll("[data-loader]").forEach((el) => el.remove());
150
+ }
151
+
152
+ // GA answers for a page with no traffic with zeros rather than an error, so
153
+ // "no data" has to be read off the numbers.
154
+ function hasTraffic(insights) {
155
+ if (!insights || insights.error || !insights.summary) return false;
156
+
157
+ let s = insights.summary;
158
+ let views = Number(s.views?.value ?? 0);
159
+ let users = Number(s.users?.value ?? 0);
160
+ let series = (insights.series && insights.series.data) || [];
161
+
162
+ return views > 0 || users > 0 || series.some((value) => Number(value) > 0);
163
+ }
164
+
165
+ function fillInsights(insights) {
166
+ let range = find('[data-report="range"]');
167
+ let grid = find('[data-report="insights-grid"]');
168
+
169
+ if (!insights || insights.error || !insights.summary) {
170
+ setFailure(range, "Insights", insights && insights.error);
171
+ ["views", "users", "avgDuration", "engagement"].forEach((metric) => {
172
+ setText(byMetric("value", metric), "—");
173
+ setText(byMetric("trend", metric), "—");
174
+ });
175
+ mute(grid);
176
+ return;
177
+ }
178
+
179
+ let s = insights.summary;
180
+
181
+ // Zeros are real numbers, so they stay on the cards — the note beside the
182
+ // title and the muted grid say they aren't a result.
183
+ if (!hasTraffic(insights)) {
184
+ mute(grid);
185
+ if (range) range.textContent = NO_INSIGHTS_NOTE;
186
+ } else if (range && insights.dateRange) {
187
+ range.textContent =
188
+ fmtDay(insights.dateRange.startDate) +
189
+ " → " +
190
+ fmtDay(insights.dateRange.endDate);
191
+ }
192
+
193
+ setText(byMetric("value", "views"), nfmt(s.views.value));
194
+ setText(byMetric("value", "users"), nfmt(s.users.value));
195
+ setText(
196
+ byMetric("value", "avgDuration"),
197
+ fmtDuration(s.avgDuration.seconds),
198
+ );
199
+ setText(byMetric("value", "engagement"), fmtPct(s.engagement.rate));
200
+
201
+ [
202
+ ["views", s.views, s.views.value],
203
+ ["users", s.users, s.users.value],
204
+ ["avgDuration", s.avgDuration, s.avgDuration.seconds],
205
+ ["engagement", s.engagement, s.engagement.rate],
206
+ ].forEach(([metric, kpi, current]) => {
207
+ let el = byMetric("trend", metric);
208
+ if (!el) return;
209
+
210
+ let trend = kpi.trend;
211
+
212
+ if (trend === null || trend === undefined) {
213
+ // A zero baseline has no percentage to show, but going from nothing to
214
+ // something is growth, not missing data. A previous value that isn't
215
+ // there at all is unknown, and still gets the dash.
216
+ if (kpi.previous === 0 && Number(current || 0) > 0) {
217
+ setText(el, NEW_TREND, "text-emerald-600");
218
+ return;
219
+ }
220
+
221
+ setText(el, "—");
222
+ return;
223
+ }
224
+
225
+ setText(
226
+ el,
227
+ (trend >= 0 ? "+" : "") + trend.toFixed(1) + "%",
228
+ trend >= 0 ? "text-emerald-600" : "text-red-600",
229
+ );
230
+ });
231
+ }
232
+
233
+ function fillChart(insights) {
234
+ let loader = find('[data-report="chart-loader"]');
235
+ let card = find('[data-report="chart-card"]');
236
+
237
+ // A flat line of zeros reads as data; a note over an empty canvas doesn't.
238
+ if (!hasTraffic(insights)) {
239
+ mute(card);
240
+
241
+ if (loader) {
242
+ loader.innerHTML = "";
243
+ loader.className =
244
+ "absolute inset-0 flex items-center justify-center body-3 text-raketa-foreground/50";
245
+ // The heading carries the reason; the canvas only says whether the
246
+ // chart is missing because of an error or because there's no traffic.
247
+ loader.textContent =
248
+ !insights || insights.error ? NO_CHART_ERROR : NO_CHART_NOTE;
249
+ }
250
+
251
+ return;
252
+ }
253
+
254
+ if (loader) loader.remove();
255
+
256
+ if (!insights.series) return;
257
+
258
+ if (typeof window.gaRenderTrafficChart !== "function") {
259
+ console.error(
260
+ "Traffic chart not drawn: /report/analytics.js must load before reports.js.",
261
+ );
262
+ return;
263
+ }
264
+
265
+ window.gaRenderTrafficChart({
266
+ series: insights.series,
267
+ publishDates: insights.publishDates || [],
268
+ });
269
+ }
270
+
271
+ function fillVitals(webVitals) {
272
+ let period = find('[data-report="cwv-period"]');
273
+ let icon = find('[data-report="cwv-assessment"]');
274
+
275
+ let metrics = (
276
+ (webVitals && !webVitals.error && webVitals.metrics) ||
277
+ []
278
+ ).reduce((acc, m) => {
279
+ acc[m.key] = m;
280
+ return acc;
281
+ }, {});
282
+
283
+ let measured = Object.values(metrics).some(
284
+ (m) => m.rating !== null && m.rating !== undefined,
285
+ );
286
+
287
+ // Same slot carries the collection period or, with nothing measured, why
288
+ // there's no report — mirrors the server-rendered heading. A failed or
289
+ // unconfigured request is not the same as a page CrUX has no data for.
290
+ if (!webVitals || webVitals.error) {
291
+ setFailure(period, "Core Web Vitals", webVitals && webVitals.error);
292
+ } else if (period) {
293
+ period.textContent = measured
294
+ ? fmtPeriod(webVitals.collectionPeriod)
295
+ : NO_DATA_NOTE;
296
+ }
297
+
298
+ if (icon) {
299
+ let state =
300
+ ASSESSMENT[webVitals && webVitals.assessment] || ASSESSMENT.unknown;
301
+ let title = "Core Web Vitals Assessment: " + state.label;
302
+
303
+ icon.classList.remove("invisible", "text-raketa-foreground/50");
304
+ icon.classList.add(state.color);
305
+ icon.setAttribute("aria-label", title);
306
+
307
+ let titleEl = icon.querySelector("title");
308
+ if (titleEl) titleEl.textContent = title;
309
+
310
+ let path = icon.querySelector("path");
311
+ if (path) path.setAttribute("d", state.glyph);
312
+ }
313
+
314
+ // Nothing measured — dim and desaturate the gauges so the colour bands
315
+ // aren't mistaken for a result.
316
+ if (!measured) mute(find('[data-report="vitals-grid"]'));
317
+
318
+ findAll('[data-report="vital"]').forEach((gauge) => {
319
+ let loader = gauge.querySelector('[data-report="vital-loader"]');
320
+ let marker = gauge.querySelector('[data-report="marker"]');
321
+ let metric = metrics[gauge.dataset.metric];
322
+
323
+ // Without a measurement the arrow would have to point somewhere, and any
324
+ // position is a claim — say "no data" over the bands instead.
325
+ if (!metric || metric.rating === null || metric.rating === undefined) {
326
+ if (loader) {
327
+ loader.removeAttribute("data-loader");
328
+ loader.className =
329
+ "absolute top-0 left-0 text-[11px] font-medium text-raketa-foreground/50";
330
+ loader.textContent = "No data";
331
+ }
332
+ return;
333
+ }
334
+
335
+ if (loader) loader.remove();
336
+ if (!marker) return;
337
+
338
+ let scaleMax = Number(gauge.dataset.scaleMax) || 1;
339
+ let share = Math.min(Math.max((metric.p75 / scaleMax) * 100, 1), 99);
340
+ let color = RATING_TEXT[metric.rating] || "text-raketa-foreground/50";
341
+
342
+ marker.style.left = share.toFixed(2) + "%";
343
+ marker.classList.remove("invisible");
344
+
345
+ let value = marker.querySelector('[data-report="vital-value"]');
346
+ let arrow = marker.querySelector('[data-report="vital-arrow"]');
347
+
348
+ if (value) {
349
+ value.textContent = fmtValue(metric.p75, gauge.dataset.unit);
350
+ value.classList.add(color);
351
+ }
352
+ if (arrow) arrow.classList.add(color);
353
+ });
354
+ }
355
+
356
+ async function loadReports() {
357
+ let reports = null;
358
+ let failure = null;
359
+
360
+ try {
361
+ let response = await fetch(CFG.path, {
362
+ headers: { accept: "application/json" },
363
+ credentials: "same-origin",
364
+ });
365
+
366
+ if (response.ok) reports = await response.json();
367
+ else failure = "The reports request failed (" + response.status + ").";
368
+ } catch (err) {
369
+ failure = "The reports request failed: " + (err.message || err);
370
+ }
371
+
372
+ // Both sides went down together, so both say the same thing rather than
373
+ // sitting empty or claiming there was no data.
374
+ if (!reports) {
375
+ clearLoaders();
376
+ let failed = { error: failure };
377
+ fillInsights(failed);
378
+ fillChart(failed);
379
+ fillVitals(failed);
380
+ return;
381
+ }
382
+
383
+ fillInsights(reports.insights);
384
+ fillChart(reports.insights);
385
+ fillVitals(reports.webVitals);
386
+ }
387
+
388
+ if (document.readyState !== "loading") loadReports();
389
+ else document.addEventListener("DOMContentLoaded", loadReports);
390
+ })();
@@ -0,0 +1,74 @@
1
+ // The package ships the report scripts and serves them itself, so a host app
2
+ // only supplies credentials and config — no copying files into its public
3
+ // directory and no build step to keep in sync.
4
+ //
5
+ // They live under /report/* alongside the JSON feed, clear of whatever the
6
+ // host app serves from its own public directory.
7
+ import { readFileSync } from "node:fs";
8
+ import { createRequire } from "node:module";
9
+ import { dirname, join } from "node:path";
10
+ import currentPath from "./currentPath.cjs";
11
+
12
+ const ASSETS_DIR = join(currentPath, "report");
13
+
14
+ // Explicit list — the path never comes from the request.
15
+ const ASSETS = ["analytics.js", "reports.js"];
16
+
17
+ // Chart.js comes from the package's own dependency instead of a CDN: a host
18
+ // app whose CSP restricts script-src to 'self' would never load the external
19
+ // bundle, and the traffic chart would silently stay blank.
20
+ const moduleRequire = createRequire(join(currentPath, "routeAssets.js"));
21
+
22
+ // The UMD build registers every controller and puts Chart on window, which is
23
+ // what analytics.js expects. It is resolved next to the package's main entry —
24
+ // chart.js does not export the file path directly.
25
+ const chartBundlePath = () =>
26
+ join(dirname(moduleRequire.resolve("chart.js")), "chart.umd.js");
27
+
28
+ const JS_HEADERS = {
29
+ "content-type": "application/javascript; charset=utf-8",
30
+ "cache-control": "no-cache",
31
+ };
32
+
33
+ // Reading per request keeps `npm run dev` serving edits without a restart;
34
+ // anywhere else the files never change and a synchronous read on every GET is
35
+ // event-loop time for nothing.
36
+ const isDevelopment = () =>
37
+ (process.env.RAKETA_ENV ?? process.env.NODE_ENV) === "development";
38
+
39
+ function routeAssets(adapter) {
40
+ const sources = new Map();
41
+
42
+ const readAsset = (path) => {
43
+ if (isDevelopment()) return readFileSync(path, "utf8");
44
+
45
+ if (!sources.has(path)) sources.set(path, readFileSync(path, "utf8"));
46
+ return sources.get(path);
47
+ };
48
+
49
+ ASSETS.forEach((file) => {
50
+ adapter.addRoute("GET", `/report/${file}`, async (routerCtx) => {
51
+ const source = readAsset(join(ASSETS_DIR, file));
52
+
53
+ return routerCtx.body(source, 200, JS_HEADERS);
54
+ });
55
+ });
56
+
57
+ // Always read once and held, dev included: it only changes when the
58
+ // dependency does, and it is two orders of magnitude larger than the
59
+ // scripts above.
60
+ let chartBundle = null;
61
+
62
+ adapter.addRoute("GET", "/report/chart.js", async (routerCtx) => {
63
+ if (chartBundle === null) {
64
+ chartBundle = readFileSync(chartBundlePath(), "utf8");
65
+ }
66
+
67
+ return routerCtx.body(chartBundle, 200, {
68
+ ...JS_HEADERS,
69
+ "cache-control": "public, max-age=3600",
70
+ });
71
+ });
72
+ }
73
+
74
+ export default routeAssets;
@@ -15,6 +15,7 @@ import ClonesHandler from "./handlers/resources/ClonesHandler";
15
15
  import DeletesHandler from "./handlers/resources/DeletesHandler";
16
16
  import ExportsHandler from "./handlers/resources/ExportsHandler";
17
17
  import HistoryHandler from "./handlers/resources/HistoryHandler";
18
+ import ReportsAPIHandler from "./handlers/resources/ReportsAPIHandler";
18
19
  import ResourcesAPIHandler from "./handlers/resources/ResourcesAPIHandler";
19
20
  import ResourcesHandler from "./handlers/resources/ResourcesHandler";
20
21
  import TranslationsHandler from "./handlers/resources/TranslationsHandler";
@@ -38,10 +39,14 @@ function routeResourceModule(
38
39
  const viewsRevisions = `${viewsType}_revisions`;
39
40
  const viewsChanges = `${viewsType}_changes`;
40
41
 
42
+ const reportsEnabled =
43
+ adapter.globalConfig?.features?.reports?.[resourceType] || false;
44
+
41
45
  const resourceConfig = {
42
46
  resourceType,
43
47
  resourceTypePlural,
44
48
  dateFields,
49
+ reports: reportsEnabled,
45
50
  };
46
51
 
47
52
  const exportsMapping = [
@@ -61,6 +66,25 @@ function routeResourceModule(
61
66
  viewsFallback: "resources",
62
67
  });
63
68
 
69
+ if (reportsEnabled) {
70
+ // JSON feed for the Settings page, which renders skeletons and fetches the
71
+ // reports client-side instead of blocking the render on GA / CrUX. Sits
72
+ // under /report alongside the scripts that consume it (see routeAssets).
73
+ adapter.routeCollection(
74
+ [
75
+ [
76
+ "GET",
77
+ `/report/${baseResource}/:id`,
78
+ `report/${baseResource}`,
79
+ baseResource,
80
+ "reports",
81
+ ReportsAPIHandler.show,
82
+ ],
83
+ ],
84
+ resourceConfig,
85
+ );
86
+ }
87
+
64
88
  routeNestedCollection(
65
89
  adapter,
66
90
  baseResource,
@@ -2,12 +2,16 @@ import DashboardsHandler from "./handlers/DashboardsHandler";
2
2
  import RedirectsHandler from "./handlers/RedirectsHandler";
3
3
  import TagsAPIHandler from "./handlers/TagsAPIHandler";
4
4
  import TagsHandler from "./handlers/TagsHandler";
5
+ import routeAssets from "./routeAssets";
5
6
  import routeCollection from "./routeCollection";
6
7
  import routeResourceModule from "./routeResourceModule";
7
8
  import routeSettings from "./routeSettings";
8
9
  import ContentTasksHandler from "./handlers/content_tasks/ContentTasksHandler";
9
10
 
10
11
  function routeStandard(adapter) {
12
+ // The package serves its own client scripts (see routeAssets).
13
+ routeAssets(adapter);
14
+
11
15
  adapter.routeCollection(
12
16
  [
13
17
  [
@@ -29,7 +33,9 @@ function routeStandard(adapter) {
29
33
  routeCollection(adapter, "content_tasks", ContentTasksHandler);
30
34
  routeCollection(adapter, "api/tags", TagsAPIHandler);
31
35
  routeSettings(adapter);
32
- routeResourceModule(adapter, "page", "pages", { dateFields: [] });
36
+ routeResourceModule(adapter, "page", "pages", {
37
+ dateFields: [],
38
+ });
33
39
  routeResourceModule(adapter, "article", "articles", {
34
40
  dateFields: ["publish_at"],
35
41
  });
@@ -311,7 +311,7 @@ body {
311
311
  }
312
312
 
313
313
  .page-title {
314
- @apply flex items-center justify-between my-6;
314
+ @apply flex items-center justify-between mt-6;
315
315
  }
316
316
 
317
317
  .actions-toolbar {
@@ -0,0 +1,56 @@
1
+ import RedirectsHandler from "../../../handlers/RedirectsHandler";
2
+ import Redirects from "../../../cloud/Redirects";
3
+
4
+ function buildCtx(redirect, params = {}) {
5
+ return {
6
+ client: { redirects: new Redirects(() => ({}), "token") },
7
+ params,
8
+ resourceParams: { redirect },
9
+ locale: "en",
10
+ };
11
+ }
12
+
13
+ // The note is optional on the form but the API expects a string.
14
+ describe("Redirects without a note", () => {
15
+ const redirect = {
16
+ url: "/old-page",
17
+ destination: "/new-page",
18
+ httpResponseCode: "301",
19
+ };
20
+
21
+ test("create sends an empty note", async () => {
22
+ const ctx = buildCtx(redirect);
23
+
24
+ const mockCreate = jest
25
+ .spyOn(ctx.client.redirects, "create")
26
+ .mockImplementation(() => ({ errors: null }));
27
+
28
+ const result = await RedirectsHandler.create(ctx);
29
+
30
+ expect(mockCreate).toHaveBeenCalledWith({
31
+ url: "/old-page",
32
+ destination: "/new-page",
33
+ httpResponseCode: 301,
34
+ note: "",
35
+ });
36
+ expect(result).toEqual({ redirect: "/en/redirects" });
37
+ });
38
+
39
+ test("update sends an empty note", async () => {
40
+ const ctx = buildCtx(redirect, { id: "42" });
41
+
42
+ const mockUpdate = jest
43
+ .spyOn(ctx.client.redirects, "update")
44
+ .mockImplementation(() => ({ errors: null }));
45
+
46
+ const result = await RedirectsHandler.update(ctx);
47
+
48
+ expect(mockUpdate).toHaveBeenCalledWith("42", {
49
+ url: "/old-page",
50
+ destination: "/new-page",
51
+ httpResponseCode: 301,
52
+ note: "",
53
+ });
54
+ expect(result).toEqual({ redirect: "/en/redirects" });
55
+ });
56
+ });
@@ -0,0 +1,54 @@
1
+ import SettingsHandler from "../../../../handlers/_shared/SettingsHandler";
2
+ import Settings from "../../../../cloud/Settings";
3
+
4
+ function buildCtx(params) {
5
+ return {
6
+ client: { settings: new Settings(() => ({}), "token") },
7
+ locale: "en",
8
+ resourceParams: { resource: params },
9
+ resourceConfig: { resource: "settings/meta" },
10
+ };
11
+ }
12
+
13
+ // Global variables are replaced wholesale rather than deep merged, so removing
14
+ // a variable actually removes it.
15
+ describe("Settings global variables", () => {
16
+ test("update overwrites the stored global variables", async () => {
17
+ const current = {
18
+ meta: { title: "A title" },
19
+ _global_variables: [{ key: "old", value: "1" }],
20
+ };
21
+ const params = {
22
+ _global_variables: [{ key: "new", value: "2" }],
23
+ };
24
+ const ctx = buildCtx(params);
25
+
26
+ jest.spyOn(ctx.client.settings, "find").mockImplementation(() => current);
27
+ const mockUpdate = jest
28
+ .spyOn(ctx.client.settings, "update")
29
+ .mockImplementation(() => ({ errors: null }));
30
+
31
+ const result = await SettingsHandler.update(ctx);
32
+
33
+ expect(mockUpdate).toHaveBeenCalledWith("en", {
34
+ meta: { title: "A title" },
35
+ _global_variables: params._global_variables,
36
+ });
37
+ expect(result).toEqual({ redirect: "/en/settings/meta/edit?success=1" });
38
+ });
39
+
40
+ test("update clears the global variables when an empty list is sent", async () => {
41
+ const ctx = buildCtx({ _global_variables: [] });
42
+
43
+ jest
44
+ .spyOn(ctx.client.settings, "find")
45
+ .mockImplementation(() => ({ _global_variables: [{ key: "old" }] }));
46
+ const mockUpdate = jest
47
+ .spyOn(ctx.client.settings, "update")
48
+ .mockImplementation(() => ({ errors: null }));
49
+
50
+ await SettingsHandler.update(ctx);
51
+
52
+ expect(mockUpdate.mock.calls[0][1]._global_variables).toEqual([]);
53
+ });
54
+ });