@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.
- package/dist/application.css +288 -11
- package/package.json +8 -6
- package/src/handlers/resources/ReportsAPIHandler.js +27 -0
- package/src/handlers/resources/ResourcesHandler.js +16 -0
- package/src/helpers/content_tasks_helper.js +1 -1
- package/src/helpers/reports/cwv.js +217 -0
- package/src/helpers/reports/googleAuth.js +149 -0
- package/src/helpers/reports/quick-insights.js +294 -0
- package/src/helpers/reports/reportsUtils.js +181 -0
- package/src/index.js +8 -0
- package/src/localCache.js +112 -0
- package/src/report/analytics.js +107 -0
- package/src/report/reports.js +390 -0
- package/src/routeAssets.js +74 -0
- package/src/routeResourceModule.js +24 -0
- package/src/routeStandard.js +7 -1
- package/src/styles/application.css +1 -1
- package/src/tests/handlers/RedirectsHandler.test/notes.test.js +56 -0
- package/src/tests/handlers/_shared/SettingsHandler.test/global_variables.test.js +54 -0
- package/src/tests/handlers/categories/update_defaults.test.js +35 -0
- package/src/tests/handlers/content_tasks/ContentTasksHandler.test/editors.test.js +29 -0
- package/src/tests/handlers/resource_revisions/PublishesHandler.test/schedule.test.js +137 -0
- package/src/tests/handlers/resource_revisions/TagHandler.test/default_locale_tags.test.js +162 -0
- package/src/tests/handlers/resource_revisions/WidgetHandler.test/defaults.test.js +61 -0
- package/src/tests/handlers/resources/DeletesHandler.test/no_redirect.test.js +55 -0
- package/src/tests/handlers/resources/ReportsAPIHandler.test/show.test.js +156 -0
- package/src/tests/handlers/resources/ResourcesHandler.test/show.test.js +8 -0
- package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +94 -0
- package/src/tests/handlers/resources/TranslationsHandler.test/edge_cases.test.js +211 -0
- package/src/tests/helpers/CSVExportHelper.branches.test.js +57 -0
- package/src/tests/helpers/FormBuilder.branches.test.js +151 -0
- package/src/tests/helpers/HTMLBuilder.test.js +51 -0
- package/src/tests/helpers/content_tasks_helper.test.js +1 -1
- package/src/tests/helpers/historyUtils.branches.test.js +36 -0
- package/src/tests/helpers/reports/cwv.test.js +239 -0
- package/src/tests/helpers/reports/quick-insights.test.js +593 -0
- package/src/tests/helpers/reports/reportsUtils.test.js +425 -0
- package/src/tests/helpers/utils.test.js +37 -0
- package/src/tests/helpers/view_helpers.test.js +87 -0
- package/src/tests/index.test.js +74 -0
- package/src/tests/localCache.test.js +236 -0
- package/src/tests/report/analytics.test.js +230 -0
- package/src/tests/report/reports.test.js +831 -0
- package/src/tests/routeAssets.test.js +120 -0
- package/src/tests/routeResourceModule.test.js +99 -2
- package/src/tests/routeStandard.test.js +3 -3
- package/src/tests/support/fakeDom.js +164 -0
- package/src/views/content_tasks/index.ejs +2 -2
- package/src/views/resources/_shared/_core_web_vitals.ejs +27 -0
- package/src/views/resources/_shared/_quick_insights.ejs +64 -0
- package/src/views/resources/_shared/_scheduled_revisions.ejs +88 -0
- package/src/views/resources/_shared/_title.ejs +16 -12
- package/src/views/resources/_shared/_toolbar.ejs +11 -21
- package/src/views/resources/_shared/_traffic_chart.ejs +27 -0
- package/src/views/resources/_shared/_translations_strip.ejs +13 -25
- package/src/views/resources/_shared/_vital_meter.ejs +54 -0
- package/src/views/resources/index.ejs +1 -1
- package/src/views/resources/show.ejs +88 -98
- 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,
|
package/src/routeStandard.js
CHANGED
|
@@ -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", {
|
|
36
|
+
routeResourceModule(adapter, "page", "pages", {
|
|
37
|
+
dateFields: [],
|
|
38
|
+
});
|
|
33
39
|
routeResourceModule(adapter, "article", "articles", {
|
|
34
40
|
dateFields: ["publish_at"],
|
|
35
41
|
});
|
|
@@ -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
|
+
});
|