@metricinsights/pp-dev 1.5.0-beta.2 → 1.5.0-beta.21
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/README.md +54 -11
- package/dist/README.md +54 -11
- package/dist/cjs/auth.provider-CyXyBAm3.js +2 -0
- package/dist/cjs/auth.provider-CyXyBAm3.js.map +1 -0
- package/dist/cjs/cli.js +1 -1
- package/dist/cjs/cli.js.map +1 -1
- package/dist/cjs/index-CeIQtamz.js +2 -0
- package/dist/cjs/index-CeIQtamz.js.map +1 -0
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/migrate-C1svbbPB.js +2 -0
- package/dist/cjs/migrate-C1svbbPB.js.map +1 -0
- package/dist/cjs/package.json +8 -12
- package/dist/cjs/plugin.js +1 -1
- package/dist/cjs/plugin.js.map +1 -1
- package/dist/cjs/request-capture.middleware-Chm27VXb.js +2 -0
- package/dist/cjs/request-capture.middleware-Chm27VXb.js.map +1 -0
- package/dist/cjs/version-manifest-C15hpGGV.js +2 -0
- package/dist/cjs/version-manifest-C15hpGGV.js.map +1 -0
- package/dist/cjs/version-plugin-DtPQBhEK.js +2 -0
- package/dist/cjs/version-plugin-DtPQBhEK.js.map +1 -0
- package/dist/client/client.js +269 -125
- package/dist/client/client.js.map +1 -1
- package/dist/client/request-inspector.css +3 -0
- package/dist/client/request-inspector.css.map +1 -0
- package/dist/client/request-inspector.html +87 -0
- package/dist/client/request-inspector.js +527 -0
- package/dist/client/request-inspector.js.map +1 -0
- package/dist/client/variables-editor.css +3 -0
- package/dist/client/variables-editor.css.map +1 -0
- package/dist/client/variables-editor.html +44 -0
- package/dist/client/variables-editor.js +3419 -0
- package/dist/client/variables-editor.js.map +1 -0
- package/dist/esm/auth.provider-Cq0EuReh.js +2 -0
- package/dist/esm/auth.provider-Cq0EuReh.js.map +1 -0
- package/dist/esm/cli.js +1 -1
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/index-DioJ4R68.js +2 -0
- package/dist/esm/index-DioJ4R68.js.map +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/migrate-FpJKfYgz.js +2 -0
- package/dist/esm/{migrate-CCFtF5bt.js.map → migrate-FpJKfYgz.js.map} +1 -1
- package/dist/esm/package.json +8 -12
- package/dist/esm/plugin.js +1 -1
- package/dist/esm/plugin.js.map +1 -1
- package/dist/esm/request-capture.middleware-BNCdPdkF.js +2 -0
- package/dist/esm/request-capture.middleware-BNCdPdkF.js.map +1 -0
- package/dist/esm/version-manifest-qxz5FXuG.js +2 -0
- package/dist/esm/version-manifest-qxz5FXuG.js.map +1 -0
- package/dist/esm/version-plugin-b2xMXK9M.js +2 -0
- package/dist/esm/version-plugin-b2xMXK9M.js.map +1 -0
- package/dist/types/index.d.ts +46 -9
- package/docs/features.md +4 -1
- package/package.json +21 -28
- package/dist/cjs/index-CNJgcIGX.js +0 -2
- package/dist/cjs/index-CNJgcIGX.js.map +0 -1
- package/dist/cjs/migrate-DkUysGJq.js +0 -2
- package/dist/cjs/migrate-DkUysGJq.js.map +0 -1
- package/dist/cjs/plugin-BZ6G8J6V.js +0 -2
- package/dist/cjs/plugin-BZ6G8J6V.js.map +0 -1
- package/dist/cjs/version-plugin-433yyuTq.js +0 -2
- package/dist/cjs/version-plugin-433yyuTq.js.map +0 -1
- package/dist/esm/index-BeF76QPw.js +0 -2
- package/dist/esm/index-BeF76QPw.js.map +0 -1
- package/dist/esm/migrate-CCFtF5bt.js +0 -2
- package/dist/esm/plugin-l4fgvJz0.js +0 -2
- package/dist/esm/plugin-l4fgvJz0.js.map +0 -1
- package/dist/esm/version-plugin-Bqy1V1SQ.js +0 -2
- package/dist/esm/version-plugin-Bqy1V1SQ.js.map +0 -1
|
@@ -0,0 +1,3419 @@
|
|
|
1
|
+
// localStorage helpers guarded against environments where storage is unavailable
|
|
2
|
+
// (e.g. sandboxed iframes). Keys follow the original `pp-dev-info-closed` naming.
|
|
3
|
+
const STORAGE_KEYS = {
|
|
4
|
+
/** Shared with the standalone Inspector/Variables Editor pages — same key, same values. */
|
|
5
|
+
theme: 'pp-dev-info-theme',
|
|
6
|
+
};
|
|
7
|
+
function checkLocalStorage() {
|
|
8
|
+
try {
|
|
9
|
+
localStorage.setItem('test', 'test');
|
|
10
|
+
localStorage.removeItem('test');
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function setStorageItem(key, value) {
|
|
18
|
+
if (checkLocalStorage()) {
|
|
19
|
+
localStorage.setItem(key, value);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function getStorageItem(key) {
|
|
23
|
+
if (checkLocalStorage()) {
|
|
24
|
+
return localStorage.getItem(key);
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Theme override (Auto/Dark/Light), shared across the dev panel and the standalone
|
|
30
|
+
// Inspector/Variables Editor pages via the same localStorage key (see STORAGE_KEYS.theme) and
|
|
31
|
+
// the same `data-pp-dev-theme` attribute convention (prefixed to avoid colliding with a host
|
|
32
|
+
// Portal Page's own theme attribute, since the panel is injected into pages pp-dev doesn't own).
|
|
33
|
+
const THEME_ATTR = 'data-pp-dev-theme';
|
|
34
|
+
function getStoredTheme() {
|
|
35
|
+
const value = getStorageItem(STORAGE_KEYS.theme);
|
|
36
|
+
return value === 'dark' || value === 'light' ? value : 'auto';
|
|
37
|
+
}
|
|
38
|
+
function applyTheme(theme) {
|
|
39
|
+
if (theme === 'dark' || theme === 'light') {
|
|
40
|
+
document.documentElement.setAttribute(THEME_ATTR, theme);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
document.documentElement.removeAttribute(THEME_ATTR);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function setTheme(theme) {
|
|
47
|
+
setStorageItem(STORAGE_KEYS.theme, theme);
|
|
48
|
+
applyTheme(theme);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function escapeHtml(s) {
|
|
52
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* ` data-action="…"` and the other `data-*` attributes the editor's delegated listeners read
|
|
56
|
+
* (camelCase keys become kebab-case; `null`/`undefined` ones are left out), escaped.
|
|
57
|
+
*/
|
|
58
|
+
function actionAttrs(action, data = {}) {
|
|
59
|
+
let attrs = ' data-action="' + escapeHtml(action) + '"';
|
|
60
|
+
for (const [key, value] of Object.entries(data)) {
|
|
61
|
+
if (value != null) {
|
|
62
|
+
attrs += ' data-' + key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()) + '="' + escapeHtml(value) + '"';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return attrs;
|
|
66
|
+
}
|
|
67
|
+
/** What a caught error says. */
|
|
68
|
+
function errorMessage(e) {
|
|
69
|
+
return e instanceof Error ? e.message : String(e);
|
|
70
|
+
}
|
|
71
|
+
// Shown in place of a tab's content while it's never been loaded yet (first visit, or right
|
|
72
|
+
// after a hard refresh of the page) — as opposed to ve-loading-indicator, which is for a
|
|
73
|
+
// background reload of a tab that already has cached content on screen.
|
|
74
|
+
function renderSkeleton() {
|
|
75
|
+
let rows = '';
|
|
76
|
+
for (let i = 0; i < 5; i++) {
|
|
77
|
+
rows += '<div class="ve-skeleton-row"></div>';
|
|
78
|
+
}
|
|
79
|
+
return '<div class="ve-skeleton">' + rows + '</div>';
|
|
80
|
+
}
|
|
81
|
+
// A plain string entry uses itself as both id and text; an object entry may carry either/both —
|
|
82
|
+
// shared by the tag-level static option list and each list-column's own "options" (see
|
|
83
|
+
// listItemFieldsHtml()), since both follow the same { id, text } | string shape.
|
|
84
|
+
function normalizeOptionEntry(o) {
|
|
85
|
+
return typeof o === 'string'
|
|
86
|
+
? { id: o, text: o }
|
|
87
|
+
: { id: String(o.id), text: String(o.text != null ? o.text : o.id) };
|
|
88
|
+
}
|
|
89
|
+
function sourceBadgeHtml(source) {
|
|
90
|
+
if (!source) {
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
const kind = String(source).toLowerCase();
|
|
94
|
+
const extra = kind === 'dataset' ? ' badge-source-dataset' : kind === 'static' ? ' badge-source-static' : '';
|
|
95
|
+
return '<span class="badge' + extra + '">' + escapeHtml(source) + '</span>';
|
|
96
|
+
}
|
|
97
|
+
function typeBadgeHtml(type) {
|
|
98
|
+
if (!type) {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
return '<span class="badge">' + escapeHtml(type) + '</span>';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The HTML of the Values tab's "Import from page…" dialog: a page picker, then the review of what
|
|
105
|
+
// that page's values would change. editor.ts loads the data and handles the `data-action`s.
|
|
106
|
+
const closeButtonHtml = '<button type="button" class="btn btn-sm ve-modal-close" title="Close" aria-label="Close"' +
|
|
107
|
+
actionAttrs('close-import-modal') +
|
|
108
|
+
'>✕</button>';
|
|
109
|
+
/** The picker step, its body loading. */
|
|
110
|
+
function importPickerStepHtml() {
|
|
111
|
+
return ('<div class="ve-modal ve-modal-wide" role="dialog" aria-modal="true">' +
|
|
112
|
+
'<div class="ve-modal-title-row">' +
|
|
113
|
+
'<div class="ve-modal-title">Import values from another page</div>' +
|
|
114
|
+
closeButtonHtml +
|
|
115
|
+
'</div>' +
|
|
116
|
+
'<div class="ve-modal-body"><span class="ve-loading-indicator"><span class="ve-loading-dot"></span>Loading pages…</span></div>' +
|
|
117
|
+
'</div>');
|
|
118
|
+
}
|
|
119
|
+
/** The review step of a page, its body loading. */
|
|
120
|
+
function importReviewStepHtml(pageName) {
|
|
121
|
+
return ('<div class="ve-modal ve-modal-wide" role="dialog" aria-modal="true">' +
|
|
122
|
+
'<div class="ve-modal-title-row">' +
|
|
123
|
+
'<div style="flex:1;min-width:0">' +
|
|
124
|
+
'<button type="button" class="ve-modal-back"' +
|
|
125
|
+
actionAttrs('back-to-import-picker') +
|
|
126
|
+
'>← Back</button>' +
|
|
127
|
+
'<div class="ve-modal-title" style="margin-bottom:0">Import values from "' +
|
|
128
|
+
escapeHtml(pageName) +
|
|
129
|
+
'"</div>' +
|
|
130
|
+
'</div>' +
|
|
131
|
+
closeButtonHtml +
|
|
132
|
+
'</div>' +
|
|
133
|
+
'<div class="ve-modal-body"><span class="ve-loading-indicator"><span class="ve-loading-dot"></span>Loading values…</span></div>' +
|
|
134
|
+
'<div class="ve-modal-actions" id="ve-import-actions"></div>' +
|
|
135
|
+
'</div>');
|
|
136
|
+
}
|
|
137
|
+
function importErrorHtml(message) {
|
|
138
|
+
return '<div class="hint" style="color:var(--red)">' + escapeHtml(message) + '</div>';
|
|
139
|
+
}
|
|
140
|
+
function importPickerBodyHtml(pages) {
|
|
141
|
+
if (!pages.length) {
|
|
142
|
+
return '<div class="ve-import-empty">No other pages found on this instance.</div>';
|
|
143
|
+
}
|
|
144
|
+
const sameTemplate = pages.filter((p) => p.sameTemplate);
|
|
145
|
+
const other = pages.filter((p) => !p.sameTemplate);
|
|
146
|
+
return ('<div class="ve-import-search"><input type="text" id="ve-import-filter" placeholder="Filter pages…"' +
|
|
147
|
+
actionAttrs('filter-import-pages') +
|
|
148
|
+
' autocomplete="off" /></div>' +
|
|
149
|
+
importPageGroupHtml('Pages using this template', sameTemplate) +
|
|
150
|
+
importPageGroupHtml('Other pages', other) +
|
|
151
|
+
'<div class="ve-import-empty" id="ve-import-page-empty" style="display:none">No pages match your filter.</div>');
|
|
152
|
+
}
|
|
153
|
+
function importPageGroupHtml(title, pages) {
|
|
154
|
+
if (!pages.length) {
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
const rows = pages
|
|
158
|
+
.map((p) => '<button type="button" class="ve-import-page-row" data-search="' +
|
|
159
|
+
escapeHtml((p.name + ' ' + p.internal_name).toLowerCase()) +
|
|
160
|
+
'"' +
|
|
161
|
+
actionAttrs('select-import-page', { pageId: p.id, pageName: p.name }) +
|
|
162
|
+
'>' +
|
|
163
|
+
'<span class="ve-import-page-name">' +
|
|
164
|
+
escapeHtml(p.name) +
|
|
165
|
+
'</span>' +
|
|
166
|
+
'<span class="ve-import-page-slug">' +
|
|
167
|
+
escapeHtml(p.internal_name) +
|
|
168
|
+
'</span>' +
|
|
169
|
+
'</button>')
|
|
170
|
+
.join('');
|
|
171
|
+
return ('<div class="ve-import-page-group">' +
|
|
172
|
+
'<div class="ve-import-section-title">' +
|
|
173
|
+
escapeHtml(title) +
|
|
174
|
+
'</div>' +
|
|
175
|
+
'<div class="ve-import-page-list">' +
|
|
176
|
+
rows +
|
|
177
|
+
'</div>' +
|
|
178
|
+
'</div>');
|
|
179
|
+
}
|
|
180
|
+
function importReviewBodyHtml(plan) {
|
|
181
|
+
const importable = plan.importable || [];
|
|
182
|
+
const skipped = plan.skipped || [];
|
|
183
|
+
if (!importable.length && !skipped.length) {
|
|
184
|
+
return '<div class="ve-import-empty">This page has no live variable values to import.</div>';
|
|
185
|
+
}
|
|
186
|
+
let html = '';
|
|
187
|
+
if (importable.length) {
|
|
188
|
+
html +=
|
|
189
|
+
'<div class="ve-import-list-head">' +
|
|
190
|
+
'<span class="badge">Importable (' +
|
|
191
|
+
importable.length +
|
|
192
|
+
')</span>' +
|
|
193
|
+
'<label class="ve-import-select-all"><input type="checkbox" id="ve-import-select-all" checked' +
|
|
194
|
+
actionAttrs('toggle-import-all') +
|
|
195
|
+
' /> select all</label>' +
|
|
196
|
+
'</div>' +
|
|
197
|
+
'<div class="ve-import-list">' +
|
|
198
|
+
importable.map((c, i) => importRowHtml(c, i)).join('') +
|
|
199
|
+
'</div>';
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
html += '<div class="hint">Nothing on this page matches a variable name in the current schema.</div>';
|
|
203
|
+
}
|
|
204
|
+
if (skipped.length) {
|
|
205
|
+
html +=
|
|
206
|
+
'<div class="ve-import-section-title">Skipped (' +
|
|
207
|
+
skipped.length +
|
|
208
|
+
')</div>' +
|
|
209
|
+
'<div class="ve-import-list">' +
|
|
210
|
+
skipped
|
|
211
|
+
.map((s) => '<div class="ve-import-skip-row">' +
|
|
212
|
+
'<span class="ve-import-row-name">' +
|
|
213
|
+
escapeHtml(s.name) +
|
|
214
|
+
'</span>' +
|
|
215
|
+
'<span class="ve-import-skip-reason">' +
|
|
216
|
+
escapeHtml(s.reason) +
|
|
217
|
+
'</span>' +
|
|
218
|
+
'</div>')
|
|
219
|
+
.join('') +
|
|
220
|
+
'</div>';
|
|
221
|
+
}
|
|
222
|
+
return html;
|
|
223
|
+
}
|
|
224
|
+
function importRowHtml(candidate, i) {
|
|
225
|
+
return ('<label class="ve-import-row">' +
|
|
226
|
+
'<input type="checkbox" class="ve-import-check" data-index="' +
|
|
227
|
+
i +
|
|
228
|
+
'" checked' +
|
|
229
|
+
actionAttrs('update-import-count') +
|
|
230
|
+
' />' +
|
|
231
|
+
'<span class="ve-import-row-main">' +
|
|
232
|
+
'<span class="ve-import-row-top"><span class="ve-import-row-name">' +
|
|
233
|
+
escapeHtml(candidate.name) +
|
|
234
|
+
'</span>' +
|
|
235
|
+
typeBadgeHtml(candidate.tagType) +
|
|
236
|
+
'</span>' +
|
|
237
|
+
'<span class="ve-import-row-value">' +
|
|
238
|
+
importValuePreviewHtml(candidate) +
|
|
239
|
+
'</span>' +
|
|
240
|
+
'</span>' +
|
|
241
|
+
'</label>');
|
|
242
|
+
}
|
|
243
|
+
function importValuePreviewHtml(candidate) {
|
|
244
|
+
const raw = candidate.sourceValue || '';
|
|
245
|
+
if (candidate.tagType === 'color' && /^#[0-9a-fA-F]{3,8}$/.test(raw)) {
|
|
246
|
+
return '<span class="ve-import-swatch" style="background:' + raw + '"></span>' + escapeHtml(raw);
|
|
247
|
+
}
|
|
248
|
+
const preview = raw.length > 80 ? raw.slice(0, 80) + '…' : raw;
|
|
249
|
+
return escapeHtml(preview || '(empty)');
|
|
250
|
+
}
|
|
251
|
+
function importReviewActionsHtml(plan) {
|
|
252
|
+
const count = (plan.importable || []).length;
|
|
253
|
+
const close = actionAttrs('close-import-modal');
|
|
254
|
+
if (!count) {
|
|
255
|
+
return '<button type="button" class="btn btn-sm"' + close + '>Close</button>';
|
|
256
|
+
}
|
|
257
|
+
return ('<span class="hint" id="ve-import-count" style="margin-right:auto">' +
|
|
258
|
+
count +
|
|
259
|
+
' of ' +
|
|
260
|
+
count +
|
|
261
|
+
' selected</span>' +
|
|
262
|
+
'<button type="button" class="btn btn-sm"' +
|
|
263
|
+
close +
|
|
264
|
+
'>Cancel</button>' +
|
|
265
|
+
'<button type="button" class="btn btn-sm btn-primary" id="ve-import-apply-btn"' +
|
|
266
|
+
actionAttrs('apply-import-plan') +
|
|
267
|
+
'>Apply ' +
|
|
268
|
+
count +
|
|
269
|
+
' selected</button>');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Line diff of the JSON the Variables Editor is about to save, for its confirmation dialog.
|
|
273
|
+
function lcsLineMatrix(a, b) {
|
|
274
|
+
const m = a.length;
|
|
275
|
+
const n = b.length;
|
|
276
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
277
|
+
for (let i = m - 1; i >= 0; i--) {
|
|
278
|
+
for (let j = n - 1; j >= 0; j--) {
|
|
279
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return dp;
|
|
283
|
+
}
|
|
284
|
+
function computeLineDiff(beforeText, afterText) {
|
|
285
|
+
const before = String(beforeText || '').split('\n');
|
|
286
|
+
const after = String(afterText || '').split('\n');
|
|
287
|
+
// The lines both sides start and end with are equal: only what lies between goes through the
|
|
288
|
+
// O(m·n) LCS table, which for two whole files (a few thousand lines each) took seconds before
|
|
289
|
+
// the modal showed, for a change of a line or two.
|
|
290
|
+
let start = 0;
|
|
291
|
+
let endBefore = before.length;
|
|
292
|
+
let endAfter = after.length;
|
|
293
|
+
while (start < endBefore && start < endAfter && before[start] === after[start]) {
|
|
294
|
+
start++;
|
|
295
|
+
}
|
|
296
|
+
while (endBefore > start && endAfter > start && before[endBefore - 1] === after[endAfter - 1]) {
|
|
297
|
+
endBefore--;
|
|
298
|
+
endAfter--;
|
|
299
|
+
}
|
|
300
|
+
const a = before.slice(start, endBefore);
|
|
301
|
+
const b = after.slice(start, endAfter);
|
|
302
|
+
const dp = lcsLineMatrix(a, b);
|
|
303
|
+
const lines = before.slice(0, start).map((text) => ({ type: 'equal', text }));
|
|
304
|
+
let i = 0;
|
|
305
|
+
let j = 0;
|
|
306
|
+
while (i < a.length && j < b.length) {
|
|
307
|
+
if (a[i] === b[j]) {
|
|
308
|
+
lines.push({ type: 'equal', text: a[i] });
|
|
309
|
+
i++;
|
|
310
|
+
j++;
|
|
311
|
+
}
|
|
312
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
313
|
+
lines.push({ type: 'remove', text: a[i] });
|
|
314
|
+
i++;
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
lines.push({ type: 'add', text: b[j] });
|
|
318
|
+
j++;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
while (i < a.length) {
|
|
322
|
+
lines.push({ type: 'remove', text: a[i++] });
|
|
323
|
+
}
|
|
324
|
+
while (j < b.length) {
|
|
325
|
+
lines.push({ type: 'add', text: b[j++] });
|
|
326
|
+
}
|
|
327
|
+
for (const text of before.slice(endBefore)) {
|
|
328
|
+
lines.push({ type: 'equal', text });
|
|
329
|
+
}
|
|
330
|
+
// Pair adjacent remove+add as inline-highlighted changes.
|
|
331
|
+
const paired = [];
|
|
332
|
+
for (let k = 0; k < lines.length; k++) {
|
|
333
|
+
const cur = lines[k];
|
|
334
|
+
const next = lines[k + 1];
|
|
335
|
+
if (cur.type === 'remove' && next && next.type === 'add') {
|
|
336
|
+
paired.push({ type: 'remove', text: cur.text, pair: next.text });
|
|
337
|
+
paired.push({ type: 'add', text: next.text, pair: cur.text });
|
|
338
|
+
k++;
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
paired.push(cur);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return paired;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// The Variables Editor's dialogs: custom HTML modals instead of window.prompt()/confirm() — some
|
|
348
|
+
// embedded webviews (e.g. Cursor's built-in browser) don't implement those native dialogs at all,
|
|
349
|
+
// so they just return null immediately with no dialog ever shown. These work identically everywhere.
|
|
350
|
+
/** Appends a `.ve-modal-overlay` with `html` inside to the page. */
|
|
351
|
+
function openOverlay(html) {
|
|
352
|
+
const overlay = document.createElement('div');
|
|
353
|
+
overlay.className = 've-modal-overlay';
|
|
354
|
+
overlay.innerHTML = html;
|
|
355
|
+
document.body.appendChild(overlay);
|
|
356
|
+
return overlay;
|
|
357
|
+
}
|
|
358
|
+
const find = (root, selector) => root.querySelector(selector);
|
|
359
|
+
// Asks for a value (in place of window.prompt()); `onDone` gets `null` when the user cancels.
|
|
360
|
+
function showCustomValueModal(title, initialValue, onDone) {
|
|
361
|
+
const overlay = openOverlay('<div class="ve-modal">' +
|
|
362
|
+
'<div class="ve-modal-title">' +
|
|
363
|
+
escapeHtml(title) +
|
|
364
|
+
'</div>' +
|
|
365
|
+
'<input type="text" class="ve-modal-input" value="' +
|
|
366
|
+
escapeHtml(initialValue) +
|
|
367
|
+
'" />' +
|
|
368
|
+
'<div class="ve-modal-actions">' +
|
|
369
|
+
'<button type="button" class="btn btn-sm ve-modal-cancel">Cancel</button>' +
|
|
370
|
+
'<button type="button" class="btn btn-sm btn-primary ve-modal-ok">OK</button>' +
|
|
371
|
+
'</div>' +
|
|
372
|
+
'</div>');
|
|
373
|
+
const inputEl = find(overlay, '.ve-modal-input');
|
|
374
|
+
inputEl.focus();
|
|
375
|
+
inputEl.select();
|
|
376
|
+
function close(value) {
|
|
377
|
+
overlay.remove();
|
|
378
|
+
onDone(value);
|
|
379
|
+
}
|
|
380
|
+
find(overlay, '.ve-modal-ok').addEventListener('click', () => close(inputEl.value));
|
|
381
|
+
find(overlay, '.ve-modal-cancel').addEventListener('click', () => close(null));
|
|
382
|
+
overlay.addEventListener('click', (ev) => {
|
|
383
|
+
if (ev.target === overlay) {
|
|
384
|
+
close(null);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
inputEl.addEventListener('keydown', (ev) => {
|
|
388
|
+
if (ev.key === 'Enter') {
|
|
389
|
+
ev.preventDefault();
|
|
390
|
+
close(inputEl.value);
|
|
391
|
+
}
|
|
392
|
+
else if (ev.key === 'Escape') {
|
|
393
|
+
ev.preventDefault();
|
|
394
|
+
close(null);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
// Same rationale as showCustomValueModal() — window.confirm() is unreliable in some embedded
|
|
399
|
+
// webviews, so any "discard unsaved changes?" gate needs to go through this instead.
|
|
400
|
+
function showConfirmModal(message, onConfirm) {
|
|
401
|
+
const overlay = openOverlay('<div class="ve-modal">' +
|
|
402
|
+
'<div class="ve-modal-title">' +
|
|
403
|
+
escapeHtml(message) +
|
|
404
|
+
'</div>' +
|
|
405
|
+
'<div class="ve-modal-actions">' +
|
|
406
|
+
'<button type="button" class="btn btn-sm ve-modal-cancel">Cancel</button>' +
|
|
407
|
+
'<button type="button" class="btn btn-sm btn-primary ve-modal-ok">OK</button>' +
|
|
408
|
+
'</div>' +
|
|
409
|
+
'</div>');
|
|
410
|
+
find(overlay, '.ve-modal-ok').focus();
|
|
411
|
+
function close(confirmed) {
|
|
412
|
+
overlay.remove();
|
|
413
|
+
if (confirmed) {
|
|
414
|
+
onConfirm();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
find(overlay, '.ve-modal-ok').addEventListener('click', () => close(true));
|
|
418
|
+
find(overlay, '.ve-modal-cancel').addEventListener('click', () => close(false));
|
|
419
|
+
overlay.addEventListener('click', (ev) => {
|
|
420
|
+
if (ev.target === overlay) {
|
|
421
|
+
close(false);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
overlay.addEventListener('keydown', (ev) => {
|
|
425
|
+
if (ev.key === 'Enter') {
|
|
426
|
+
ev.preventDefault();
|
|
427
|
+
close(true);
|
|
428
|
+
}
|
|
429
|
+
else if (ev.key === 'Escape') {
|
|
430
|
+
ev.preventDefault();
|
|
431
|
+
close(false);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
// A plain info modal (no input, single "Close" action) — used to keep long explanations
|
|
436
|
+
// (e.g. additional_options' full per-tag_source breakdown) out of the inline hint text.
|
|
437
|
+
function showInfoModal(title, bodyHtml) {
|
|
438
|
+
const overlay = openOverlay('<div class="ve-modal ve-modal-wide">' +
|
|
439
|
+
'<div class="ve-modal-title">' +
|
|
440
|
+
escapeHtml(title) +
|
|
441
|
+
'</div>' +
|
|
442
|
+
'<div class="ve-modal-body">' +
|
|
443
|
+
bodyHtml +
|
|
444
|
+
'</div>' +
|
|
445
|
+
'<div class="ve-modal-actions">' +
|
|
446
|
+
'<button type="button" class="btn btn-sm btn-primary ve-modal-ok">Close</button>' +
|
|
447
|
+
'</div>' +
|
|
448
|
+
'</div>');
|
|
449
|
+
find(overlay, '.ve-modal-ok').focus();
|
|
450
|
+
function close() {
|
|
451
|
+
overlay.remove();
|
|
452
|
+
}
|
|
453
|
+
find(overlay, '.ve-modal-ok').addEventListener('click', close);
|
|
454
|
+
overlay.addEventListener('click', (ev) => {
|
|
455
|
+
if (ev.target === overlay) {
|
|
456
|
+
close();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
overlay.addEventListener('keydown', (ev) => {
|
|
460
|
+
if (ev.key === 'Enter' || ev.key === 'Escape') {
|
|
461
|
+
ev.preventDefault();
|
|
462
|
+
close();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
/** `text` as JSON with 2-space indentation, or as it is when it isn't JSON. */
|
|
467
|
+
function normalizeJsonText(text) {
|
|
468
|
+
try {
|
|
469
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
return String(text || '');
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// A changed line with the part that differs from its pair (the line it replaced) highlighted.
|
|
476
|
+
function inlineDiffHtml(text, pair, mode) {
|
|
477
|
+
if (pair == null || pair === text) {
|
|
478
|
+
return escapeHtml(text);
|
|
479
|
+
}
|
|
480
|
+
let start = 0;
|
|
481
|
+
const minLen = Math.min(text.length, pair.length);
|
|
482
|
+
while (start < minLen && text.charAt(start) === pair.charAt(start)) {
|
|
483
|
+
start++;
|
|
484
|
+
}
|
|
485
|
+
let endOld = text.length;
|
|
486
|
+
let endNew = pair.length;
|
|
487
|
+
while (endOld > start && endNew > start && text.charAt(endOld - 1) === pair.charAt(endNew - 1)) {
|
|
488
|
+
endOld--;
|
|
489
|
+
endNew--;
|
|
490
|
+
}
|
|
491
|
+
const before = escapeHtml(text.slice(0, start));
|
|
492
|
+
const mid = escapeHtml(text.slice(start, endOld));
|
|
493
|
+
const after = escapeHtml(text.slice(endOld));
|
|
494
|
+
const cls = mode === 'add' ? 've-diff-inline-add' : 've-diff-inline-remove';
|
|
495
|
+
return before + (mid ? '<span class="' + cls + '">' + mid + '</span>' : '') + after;
|
|
496
|
+
}
|
|
497
|
+
function renderJsonDiffHtml(beforeText, afterText) {
|
|
498
|
+
const before = normalizeJsonText(beforeText);
|
|
499
|
+
const after = normalizeJsonText(afterText);
|
|
500
|
+
if (before === after) {
|
|
501
|
+
return { html: '<div class="ve-diff-empty">No differences in the JSON file.</div>', changeCount: 0 };
|
|
502
|
+
}
|
|
503
|
+
const lines = computeLineDiff(before, after);
|
|
504
|
+
let changeCount = 0;
|
|
505
|
+
let inHunk = false;
|
|
506
|
+
const html = '<div class="ve-diff">' +
|
|
507
|
+
lines
|
|
508
|
+
.map((line) => {
|
|
509
|
+
if (line.type === 'equal') {
|
|
510
|
+
inHunk = false;
|
|
511
|
+
return ('<div class="ve-diff-line"><span class="ve-diff-prefix"> </span><span>' +
|
|
512
|
+
escapeHtml(line.text) +
|
|
513
|
+
'</span></div>');
|
|
514
|
+
}
|
|
515
|
+
const attrs = !inHunk ? ' data-change-index="' + changeCount + '"' : '';
|
|
516
|
+
if (!inHunk) {
|
|
517
|
+
changeCount++;
|
|
518
|
+
inHunk = true;
|
|
519
|
+
}
|
|
520
|
+
if (line.type === 'remove') {
|
|
521
|
+
return ('<div class="ve-diff-line is-remove"' +
|
|
522
|
+
attrs +
|
|
523
|
+
'><span class="ve-diff-prefix">-</span><span>' +
|
|
524
|
+
inlineDiffHtml(line.text, line.pair, 'remove') +
|
|
525
|
+
'</span></div>');
|
|
526
|
+
}
|
|
527
|
+
return ('<div class="ve-diff-line is-add"' +
|
|
528
|
+
attrs +
|
|
529
|
+
'><span class="ve-diff-prefix">+</span><span>' +
|
|
530
|
+
inlineDiffHtml(line.text, line.pair, 'add') +
|
|
531
|
+
'</span></div>');
|
|
532
|
+
})
|
|
533
|
+
.join('') +
|
|
534
|
+
'</div>';
|
|
535
|
+
return { html: html, changeCount: changeCount };
|
|
536
|
+
}
|
|
537
|
+
// The save confirmation: the JSON file's changes, GitHub-style, with ←/→ to step through them.
|
|
538
|
+
function showJsonDiffModal(beforeText, afterText, onConfirm) {
|
|
539
|
+
const diff = renderJsonDiffHtml(beforeText, afterText);
|
|
540
|
+
const hasChanges = diff.changeCount > 0;
|
|
541
|
+
const overlay = openOverlay('<div class="ve-modal ve-modal-diff" role="dialog" aria-modal="true">' +
|
|
542
|
+
'<div class="ve-modal-title-row">' +
|
|
543
|
+
'<div class="ve-modal-title">JSON changes</div>' +
|
|
544
|
+
'<button type="button" class="btn btn-sm ve-modal-close" title="Close" aria-label="Close">✕</button>' +
|
|
545
|
+
'</div>' +
|
|
546
|
+
'<div class="ve-modal-body">' +
|
|
547
|
+
(hasChanges
|
|
548
|
+
? '<div class="ve-diff-nav">' +
|
|
549
|
+
'<button type="button" class="btn btn-sm ve-diff-prev" title="Previous change" aria-label="Previous change">←</button>' +
|
|
550
|
+
'<button type="button" class="btn btn-sm ve-diff-next" title="Next change" aria-label="Next change">→</button>' +
|
|
551
|
+
'<span class="ve-diff-nav-count">1 / ' +
|
|
552
|
+
diff.changeCount +
|
|
553
|
+
'</span>' +
|
|
554
|
+
'</div>'
|
|
555
|
+
: '') +
|
|
556
|
+
diff.html +
|
|
557
|
+
'</div>' +
|
|
558
|
+
'<div class="ve-modal-actions">' +
|
|
559
|
+
'<div class="hint">Review added (+) and removed (−) lines, including inline highlights for changed segments.</div>' +
|
|
560
|
+
'<button type="button" class="btn btn-sm ve-modal-cancel">Cancel</button>' +
|
|
561
|
+
'<button type="button" class="btn btn-sm btn-primary ve-modal-ok">Save Changes</button>' +
|
|
562
|
+
'</div>' +
|
|
563
|
+
'</div>');
|
|
564
|
+
find(overlay, '.ve-modal-ok').focus();
|
|
565
|
+
let currentChange = 0;
|
|
566
|
+
function updateChangeNav() {
|
|
567
|
+
const countEl = overlay.querySelector('.ve-diff-nav-count');
|
|
568
|
+
const prevBtn = overlay.querySelector('.ve-diff-prev');
|
|
569
|
+
const nextBtn = overlay.querySelector('.ve-diff-next');
|
|
570
|
+
const diffEl = overlay.querySelector('.ve-diff');
|
|
571
|
+
if (!hasChanges || !diffEl) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
diffEl.querySelectorAll('.ve-diff-line.is-current-change').forEach((el) => {
|
|
575
|
+
el.classList.remove('is-current-change');
|
|
576
|
+
});
|
|
577
|
+
const target = diffEl.querySelector('.ve-diff-line[data-change-index="' + currentChange + '"]');
|
|
578
|
+
if (target) {
|
|
579
|
+
target.classList.add('is-current-change');
|
|
580
|
+
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
581
|
+
}
|
|
582
|
+
if (countEl) {
|
|
583
|
+
countEl.textContent = currentChange + 1 + ' / ' + diff.changeCount;
|
|
584
|
+
}
|
|
585
|
+
if (prevBtn) {
|
|
586
|
+
prevBtn.disabled = currentChange <= 0;
|
|
587
|
+
}
|
|
588
|
+
if (nextBtn) {
|
|
589
|
+
nextBtn.disabled = currentChange >= diff.changeCount - 1;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (hasChanges) {
|
|
593
|
+
find(overlay, '.ve-diff-prev').addEventListener('click', function () {
|
|
594
|
+
if (currentChange > 0) {
|
|
595
|
+
currentChange--;
|
|
596
|
+
updateChangeNav();
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
find(overlay, '.ve-diff-next').addEventListener('click', function () {
|
|
600
|
+
if (currentChange < diff.changeCount - 1) {
|
|
601
|
+
currentChange++;
|
|
602
|
+
updateChangeNav();
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
// Wait a frame so the modal layout is ready before scrolling.
|
|
606
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
607
|
+
requestAnimationFrame(function () {
|
|
608
|
+
updateChangeNav();
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function close(confirmed) {
|
|
613
|
+
overlay.remove();
|
|
614
|
+
if (confirmed && typeof onConfirm === 'function') {
|
|
615
|
+
onConfirm();
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
find(overlay, '.ve-modal-ok').addEventListener('click', () => close(true));
|
|
619
|
+
find(overlay, '.ve-modal-cancel').addEventListener('click', () => close(false));
|
|
620
|
+
find(overlay, '.ve-modal-close').addEventListener('click', () => close(false));
|
|
621
|
+
overlay.addEventListener('click', (ev) => {
|
|
622
|
+
if (ev.target === overlay) {
|
|
623
|
+
close(false);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
overlay.addEventListener('keydown', (ev) => {
|
|
627
|
+
if (ev.key === 'ArrowLeft' && hasChanges) {
|
|
628
|
+
ev.preventDefault();
|
|
629
|
+
if (currentChange > 0) {
|
|
630
|
+
currentChange--;
|
|
631
|
+
updateChangeNav();
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (ev.key === 'ArrowRight' && hasChanges) {
|
|
636
|
+
ev.preventDefault();
|
|
637
|
+
if (currentChange < diff.changeCount - 1) {
|
|
638
|
+
currentChange++;
|
|
639
|
+
updateChangeNav();
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (ev.key === 'Enter') {
|
|
644
|
+
ev.preventDefault();
|
|
645
|
+
close(true);
|
|
646
|
+
}
|
|
647
|
+
else if (ev.key === 'Escape') {
|
|
648
|
+
ev.preventDefault();
|
|
649
|
+
close(false);
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// The Schema tab's HTML (the `__template_variables.json` tags as a table) and the rules it shows
|
|
655
|
+
// fields by. Controls say what they do in `data-action` (with `data-index`, `data-field`, …);
|
|
656
|
+
// editor.ts handles their events in one place.
|
|
657
|
+
const KNOWN_TYPES = ['text', 'select', 'multiselect', 'file', 'list', 'color', 'boolean'];
|
|
658
|
+
const KNOWN_TAG_SOURCES = [
|
|
659
|
+
'static',
|
|
660
|
+
'page',
|
|
661
|
+
'element',
|
|
662
|
+
'folder',
|
|
663
|
+
'segment',
|
|
664
|
+
'dataset',
|
|
665
|
+
'dataset_data',
|
|
666
|
+
'announcement',
|
|
667
|
+
'group',
|
|
668
|
+
'category',
|
|
669
|
+
'custom_attribute',
|
|
670
|
+
'page_entity',
|
|
671
|
+
];
|
|
672
|
+
// select/multiselect sources whose options are hand-entered via additional_options rather than
|
|
673
|
+
// loaded live from MI (the rest — dataset/announcement/group/category/custom_attribute/page/
|
|
674
|
+
// page_entity — populate their own option list, so additional_options doesn't apply there).
|
|
675
|
+
const HAND_ENTERED_OPTION_SOURCES = ['static', 'segment', 'element', 'dataset_data'];
|
|
676
|
+
// list-tag column editor: field types + sources allowed on a select/multi-select column.
|
|
677
|
+
const LIST_COLUMN_TYPES = ['textarea', 'select', 'multi-select', 'color', 'file'];
|
|
678
|
+
const LIST_COLUMN_SOURCES = ['static', 'dataset', 'group', 'category', 'custom_attribute'];
|
|
679
|
+
// Matches MI's own "Create/Edit Variable" form: letters, digits, underscore, whitespace, hyphen.
|
|
680
|
+
const NAME_FORMAT_REGEX = /^[A-Za-z0-9_\s-]+$/;
|
|
681
|
+
// A styled <select> (not a native <datalist>, whose popup/selected-state styling browsers
|
|
682
|
+
// won't let us theme) with an escape hatch: picking "Custom…" prompts for any string, so a
|
|
683
|
+
// tag_type/tag_source MI adds in the future can be configured here without a pp-dev code change.
|
|
684
|
+
const CUSTOM_TYPE_SENTINEL = '__custom__';
|
|
685
|
+
function customSelectCell(field, knownValues, fallback, tag, i) {
|
|
686
|
+
const currentValue = tag[field] || fallback;
|
|
687
|
+
const isKnown = knownValues.includes(currentValue);
|
|
688
|
+
const knownOptions = knownValues
|
|
689
|
+
.map((v) => '<option value="' + v + '"' + (currentValue === v ? ' selected' : '') + '>' + v + '</option>')
|
|
690
|
+
.join('');
|
|
691
|
+
const customOption = !currentValue || isKnown
|
|
692
|
+
? ''
|
|
693
|
+
: '<option value="' + escapeHtml(currentValue) + '" selected>' + escapeHtml(currentValue) + ' (custom)</option>';
|
|
694
|
+
return ('<select' +
|
|
695
|
+
actionAttrs('custom-select', { index: i, field }) +
|
|
696
|
+
'>' +
|
|
697
|
+
knownOptions +
|
|
698
|
+
customOption +
|
|
699
|
+
'<option value="' +
|
|
700
|
+
CUSTOM_TYPE_SENTINEL +
|
|
701
|
+
'">Custom…</option>' +
|
|
702
|
+
'</select>');
|
|
703
|
+
}
|
|
704
|
+
function typeSelectCell(tag, i) {
|
|
705
|
+
return customSelectCell('tag_type', KNOWN_TYPES, 'text', tag, i);
|
|
706
|
+
}
|
|
707
|
+
function tagSourceSelectCell(tag, i) {
|
|
708
|
+
return customSelectCell('tag_source', KNOWN_TAG_SOURCES, '', tag, i);
|
|
709
|
+
}
|
|
710
|
+
// Short, one-line version for inline display next to the field — click "?" for the full
|
|
711
|
+
// per-tag_source breakdown (additionalOptionsDetails()) with examples, shown in a modal.
|
|
712
|
+
function additionalOptionsHint(tag) {
|
|
713
|
+
const tagType = tag.tag_type;
|
|
714
|
+
if (tagType === 'select' || tagType === 'multiselect') {
|
|
715
|
+
const source = tag.tag_source || 'static';
|
|
716
|
+
if (source === 'static') {
|
|
717
|
+
return 'The option list itself.';
|
|
718
|
+
}
|
|
719
|
+
if (source === 'dataset_data') {
|
|
720
|
+
return 'Which dataset + columns to pull options from.';
|
|
721
|
+
}
|
|
722
|
+
if (source === 'element') {
|
|
723
|
+
return 'Optional filter by element type.';
|
|
724
|
+
}
|
|
725
|
+
if (source === 'segment') {
|
|
726
|
+
return "Accepted by MI's form, but not read for this source.";
|
|
727
|
+
}
|
|
728
|
+
return 'Not used for tag_source "' + escapeHtml(source) + '" — MI loads its options live.';
|
|
729
|
+
}
|
|
730
|
+
if (tagType === 'list') {
|
|
731
|
+
return 'Column definitions for list-of-objects items, or "" for a flat list of strings.';
|
|
732
|
+
}
|
|
733
|
+
if (tagType === 'boolean') {
|
|
734
|
+
return 'Not used for tag_type "boolean".';
|
|
735
|
+
}
|
|
736
|
+
return 'No confirmed runtime use for tag_type "' + escapeHtml(tagType || 'text') + '".';
|
|
737
|
+
}
|
|
738
|
+
// Full breakdown with examples, reverse-engineered from MI's own options-loading logic —
|
|
739
|
+
// shown in a modal (the "?" next to the field) so the inline hint above can stay short.
|
|
740
|
+
function additionalOptionsDetails(tag) {
|
|
741
|
+
const tagType = tag.tag_type;
|
|
742
|
+
if (tagType === 'select' || tagType === 'multiselect') {
|
|
743
|
+
const source = tag.tag_source || 'static';
|
|
744
|
+
if (source === 'static') {
|
|
745
|
+
return ('<p>The option list itself. Each entry is a plain string, or an object with <code>id</code>/<code>text</code>:</p>' +
|
|
746
|
+
'<pre>[{"id":"1","text":"One"},"Two"]</pre>' +
|
|
747
|
+
'<p>A plain string entry uses itself as both the stored value and the label.</p>');
|
|
748
|
+
}
|
|
749
|
+
if (source === 'dataset_data') {
|
|
750
|
+
return ('<p>Pulls options live from a dataset, by column name:</p>' +
|
|
751
|
+
'<pre>{"dataset_id":123,"key_column":"id_col","text_column":"name_col"}</pre>' +
|
|
752
|
+
'<p><code>key_column</code> supplies the option value, <code>text_column</code> the label.</p>');
|
|
753
|
+
}
|
|
754
|
+
if (source === 'element') {
|
|
755
|
+
return ('<p>Optional — restricts the dashboard-element list to one type:</p>' +
|
|
756
|
+
'<pre>{"type":"metric"}</pre>' +
|
|
757
|
+
'<p>Accepted values: <code>metric</code>, <code>multi-metric chart</code>, <code>internal report</code>, <code>external report</code>, <code>other external content</code>. Leave empty to list all elements.</p>');
|
|
758
|
+
}
|
|
759
|
+
if (source === 'segment') {
|
|
760
|
+
return '<p>MI\'s own "Create/Edit Variable" form shows this field for tag_source "segment", but its options-loading endpoint never actually reads it — the segment list always comes back unfiltered. Best left empty.</p>';
|
|
761
|
+
}
|
|
762
|
+
return ('<p>Not used — for tag_source "' +
|
|
763
|
+
escapeHtml(source) +
|
|
764
|
+
'", MI loads options live from its own data (e.g. the dataset/announcement/group/category/page list) and never consults this field.</p>');
|
|
765
|
+
}
|
|
766
|
+
if (tagType === 'list') {
|
|
767
|
+
return ('<p>An array of column definitions — each list item becomes an object keyed by these names:</p>' +
|
|
768
|
+
'<pre>[{"name":"id","type":"textarea"},{"name":"color","type":"color"}]</pre>' +
|
|
769
|
+
'<p>A bare string entry is shorthand for <code>{"name": <that string>, "type": "textarea"}</code>. Leave as <code>""</code> (empty string) instead for a flat list of plain strings.</p>' +
|
|
770
|
+
'<p>Supported field <code>type</code>s: <code>textarea</code>, <code>color</code>, <code>select</code>, <code>multi-select</code>, <code>file</code>.</p>' +
|
|
771
|
+
'<p>For a <code>select</code>/<code>multi-select</code> column, add <code>source</code> (defaults to <code>"static"</code>) and <code>options</code> — same per-source rules as the tag-level <code>tag_source</code>/<code>additional_options</code> above, just scoped to this one column and fed by <code>options</code> instead:</p>' +
|
|
772
|
+
'<pre>{"name":"country","type":"select","options":["USA","Canada"]}</pre>' +
|
|
773
|
+
"<p>MI's own list-item editor never reads this column's own <code>additional_options</code> — only <code>options</code> — so leave it out.</p>");
|
|
774
|
+
}
|
|
775
|
+
if (tagType === 'boolean') {
|
|
776
|
+
return "<p>MI's own editor doesn't show this field for tag_type \"boolean\", and there's no known runtime use for it — leave empty.</p>";
|
|
777
|
+
}
|
|
778
|
+
return ('<p>MI\'s own editor lets you fill this in for tag_type "' +
|
|
779
|
+
escapeHtml(tagType || 'text') +
|
|
780
|
+
'", but there\'s no confirmed runtime consumer for it — treat it as unused/reserved.</p>');
|
|
781
|
+
}
|
|
782
|
+
const LIST_COLUMNS_HELP_HTML = '<p>An array of column definitions — each list item becomes an object keyed by these names. Supported field <code>types</code>: <code>textarea</code>, <code>color</code>, <code>select</code>, <code>multi-select</code>, <code>file</code>.</p>' +
|
|
783
|
+
'<p>Edited below as a column editor — reordering/adding/removing there is the same thing as hand-editing this JSON array, just without needing to write JSON.</p>';
|
|
784
|
+
// Mirrors MI's own "Create/Edit Variable" form: which fields it shows/hides depends on
|
|
785
|
+
// tag_type (and, for the tag_source picker and additional_options, tag_source too). For a
|
|
786
|
+
// tag_type MI hasn't defined yet (i.e. a custom one entered via typeSelectCell's escape hatch),
|
|
787
|
+
// there's no such logic to mirror, so give full access to everything instead of guessing.
|
|
788
|
+
function fieldVisibility(tag) {
|
|
789
|
+
const type = tag.tag_type;
|
|
790
|
+
if (!KNOWN_TYPES.includes(type)) {
|
|
791
|
+
return {
|
|
792
|
+
tagSource: true,
|
|
793
|
+
useHtml: true,
|
|
794
|
+
rawHtml: true,
|
|
795
|
+
useJson: true,
|
|
796
|
+
defaultValue: true,
|
|
797
|
+
additionalOptions: true,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
const isTextOrList = type === 'text' || type === 'list';
|
|
801
|
+
const isSelectLike = type === 'select' || type === 'multiselect';
|
|
802
|
+
const useHtmlOn = tag.use_hmtl_editor_ind === 'Y';
|
|
803
|
+
const source = tag.tag_source || 'static';
|
|
804
|
+
return {
|
|
805
|
+
tagSource: isSelectLike,
|
|
806
|
+
useHtml: type === 'text',
|
|
807
|
+
rawHtml: isTextOrList && !useHtmlOn,
|
|
808
|
+
useJson: (type === 'text' && !useHtmlOn) || type === 'list',
|
|
809
|
+
defaultValue: isTextOrList,
|
|
810
|
+
additionalOptions: type === 'text' ||
|
|
811
|
+
type === 'file' ||
|
|
812
|
+
type === 'list' ||
|
|
813
|
+
type === 'color' ||
|
|
814
|
+
(isSelectLike && HAND_ENTERED_OPTION_SOURCES.indexOf(source) !== -1),
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
function checkboxField(label, checked, attrs) {
|
|
818
|
+
return ('<label class="checkbox-label"><input type="checkbox" ' +
|
|
819
|
+
(checked ? 'checked' : '') +
|
|
820
|
+
' ' +
|
|
821
|
+
attrs +
|
|
822
|
+
' />' +
|
|
823
|
+
escapeHtml(label) +
|
|
824
|
+
'</label>');
|
|
825
|
+
}
|
|
826
|
+
function editorFlagsRow(tag, i, vis) {
|
|
827
|
+
if (!vis.useHtml && !vis.rawHtml && !vis.useJson) {
|
|
828
|
+
return '';
|
|
829
|
+
}
|
|
830
|
+
const parts = [];
|
|
831
|
+
const flag = (label, field) => checkboxField(label, tag[field] === 'Y', actionAttrs('editor-flag', { index: i, field }).trim());
|
|
832
|
+
if (vis.useHtml) {
|
|
833
|
+
parts.push(flag('Use WYSIWYG Editor', 'use_hmtl_editor_ind'));
|
|
834
|
+
}
|
|
835
|
+
if (vis.rawHtml) {
|
|
836
|
+
parts.push(flag('Raw HTML', 'use_raw_html_ind'));
|
|
837
|
+
}
|
|
838
|
+
if (vis.useJson) {
|
|
839
|
+
parts.push(flag('Use JSON Editor', 'use_json_editor_ind'));
|
|
840
|
+
}
|
|
841
|
+
return '<div style="grid-column:1/-1" class="checkbox-row">' + parts.join('') + '</div>';
|
|
842
|
+
}
|
|
843
|
+
function nameWarningHint(name) {
|
|
844
|
+
if (!name) {
|
|
845
|
+
return '<div class="hint" style="color:var(--red)">Name is required.</div>';
|
|
846
|
+
}
|
|
847
|
+
if (!NAME_FORMAT_REGEX.test(name)) {
|
|
848
|
+
return '<div class="hint" style="color:var(--red)">Contains a character MI\'s own editor doesn\'t allow — only letters, digits, underscore, hyphen, and whitespace.</div>';
|
|
849
|
+
}
|
|
850
|
+
return '';
|
|
851
|
+
}
|
|
852
|
+
// Normalize list-tag additional_options into editable column objects. Non-array values
|
|
853
|
+
// (flat-list "" / invalid raw text) become an empty column list — editing via the UI replaces them.
|
|
854
|
+
function listColumnsFor(tag) {
|
|
855
|
+
const opts = tag && tag.additional_options;
|
|
856
|
+
if (!Array.isArray(opts)) {
|
|
857
|
+
return [];
|
|
858
|
+
}
|
|
859
|
+
return opts.map((c) => typeof c === 'string' ? { name: c, type: 'textarea' } : Object.assign({}, c));
|
|
860
|
+
}
|
|
861
|
+
// Persist select/multi-select columns without a redundant source when options are present —
|
|
862
|
+
// source is only kept when there is no options list (non-static / empty options).
|
|
863
|
+
function normalizeListColumn(col) {
|
|
864
|
+
const out = Object.assign({ name: '', type: 'textarea' }, col);
|
|
865
|
+
const type = out.type || 'textarea';
|
|
866
|
+
const isSelect = type === 'select' || type === 'multi-select';
|
|
867
|
+
if (!isSelect) {
|
|
868
|
+
delete out.source;
|
|
869
|
+
delete out.options;
|
|
870
|
+
return out;
|
|
871
|
+
}
|
|
872
|
+
if (Array.isArray(out.options) && out.options.length) {
|
|
873
|
+
delete out.source;
|
|
874
|
+
}
|
|
875
|
+
else {
|
|
876
|
+
delete out.options;
|
|
877
|
+
if (!out.source) {
|
|
878
|
+
out.source = 'static';
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
883
|
+
function listColumnSelectOptions(values, selected) {
|
|
884
|
+
return values
|
|
885
|
+
.map((v) => '<option value="' +
|
|
886
|
+
escapeHtml(v) +
|
|
887
|
+
'"' +
|
|
888
|
+
(v === selected ? ' selected' : '') +
|
|
889
|
+
'>' +
|
|
890
|
+
escapeHtml(v) +
|
|
891
|
+
'</option>')
|
|
892
|
+
.join('');
|
|
893
|
+
}
|
|
894
|
+
function listColumnItemHtml(col, i, colIndex) {
|
|
895
|
+
const name = col.name != null ? String(col.name) : '';
|
|
896
|
+
const type = col.type || 'textarea';
|
|
897
|
+
const isSelect = type === 'select' || type === 'multi-select';
|
|
898
|
+
const source = col.source || 'static';
|
|
899
|
+
// Same { id, text } | string shape as the Values tab's select widget (see listItemFieldsHtml())
|
|
900
|
+
// — show the readable text, not "[object Object]", for a column with object-shaped options.
|
|
901
|
+
// Editing this comma-separated field still only ever writes back plain strings (id === text) —
|
|
902
|
+
// this UI has never round-tripped distinct id/text pairs, only raw JSON editing does.
|
|
903
|
+
const optionsText = Array.isArray(col.options) ? col.options.map((o) => normalizeOptionEntry(o).text).join(', ') : '';
|
|
904
|
+
const at = { index: i, col: colIndex };
|
|
905
|
+
const typeOptions = listColumnSelectOptions(LIST_COLUMN_TYPES.indexOf(type) !== -1 ? LIST_COLUMN_TYPES : LIST_COLUMN_TYPES.concat([type]), type);
|
|
906
|
+
let bottom = '<div class="ve-ao-column-field">' +
|
|
907
|
+
'<label>type</label>' +
|
|
908
|
+
'<select' +
|
|
909
|
+
actionAttrs('list-column-field', { ...at, field: 'type' }) +
|
|
910
|
+
'>' +
|
|
911
|
+
typeOptions +
|
|
912
|
+
'</select>' +
|
|
913
|
+
'</div>';
|
|
914
|
+
if (isSelect) {
|
|
915
|
+
const sourceOptions = listColumnSelectOptions(LIST_COLUMN_SOURCES.indexOf(source) !== -1 ? LIST_COLUMN_SOURCES : LIST_COLUMN_SOURCES.concat([source]), source);
|
|
916
|
+
bottom +=
|
|
917
|
+
'<div class="ve-ao-column-field">' +
|
|
918
|
+
'<label>source</label>' +
|
|
919
|
+
'<select' +
|
|
920
|
+
actionAttrs('list-column-field', { ...at, field: 'source' }) +
|
|
921
|
+
'>' +
|
|
922
|
+
sourceOptions +
|
|
923
|
+
'</select>' +
|
|
924
|
+
'</div>';
|
|
925
|
+
if (source === 'static') {
|
|
926
|
+
bottom +=
|
|
927
|
+
'<div class="ve-ao-column-field">' +
|
|
928
|
+
'<label>options</label>' +
|
|
929
|
+
'<input type="text" value="' +
|
|
930
|
+
escapeHtml(optionsText) +
|
|
931
|
+
'" placeholder="Options (comma-separated)"' +
|
|
932
|
+
actionAttrs('list-column-options', at) +
|
|
933
|
+
' />' +
|
|
934
|
+
'</div>';
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return ('<div class="ve-ao-column" data-row="' +
|
|
938
|
+
i +
|
|
939
|
+
'" data-col-index="' +
|
|
940
|
+
colIndex +
|
|
941
|
+
'" data-drop="list-column">' +
|
|
942
|
+
'<span class="ve-ao-column-handle" draggable="true" data-drag="list-column" title="Drag to reorder" aria-label="Drag to reorder" role="button" tabindex="0">⠿</span>' +
|
|
943
|
+
'<div class="ve-ao-column-fields">' +
|
|
944
|
+
'<div class="ve-ao-column-top">' +
|
|
945
|
+
'<div class="ve-ao-column-field">' +
|
|
946
|
+
'<label>name</label>' +
|
|
947
|
+
'<div class="ve-ao-column-name">' +
|
|
948
|
+
'<input type="text" value="' +
|
|
949
|
+
escapeHtml(name) +
|
|
950
|
+
'"' +
|
|
951
|
+
actionAttrs('list-column-name', at) +
|
|
952
|
+
' placeholder="Column name" />' +
|
|
953
|
+
'<button type="button" class="btn btn-sm ve-ao-column-remove"' +
|
|
954
|
+
actionAttrs('remove-list-column', at) +
|
|
955
|
+
' title="Remove column">✕</button>' +
|
|
956
|
+
'</div>' +
|
|
957
|
+
'</div>' +
|
|
958
|
+
'</div>' +
|
|
959
|
+
'<div class="ve-ao-column-bottom">' +
|
|
960
|
+
bottom +
|
|
961
|
+
'</div>' +
|
|
962
|
+
'</div>' +
|
|
963
|
+
'</div>');
|
|
964
|
+
}
|
|
965
|
+
function listColumnsEditorHtml(tag, i) {
|
|
966
|
+
const cols = listColumnsFor(tag);
|
|
967
|
+
const itemsHtml = cols.map((col, colIndex) => listColumnItemHtml(col, i, colIndex)).join('');
|
|
968
|
+
return ('<div class="box-style" style="grid-column:1/-1">' +
|
|
969
|
+
'<label>additional_options (UI version)<button type="button" class="btn btn-sm" style="text-transform:none;padding:0 6px;margin-left:4px"' +
|
|
970
|
+
actionAttrs('show-list-columns-help') +
|
|
971
|
+
' title="What is additional_options for list?">?</button></label>' +
|
|
972
|
+
'<div class="ve-ao-columns">' +
|
|
973
|
+
itemsHtml +
|
|
974
|
+
'</div>' +
|
|
975
|
+
'<button type="button" class="btn btn-sm" style="margin-top:8px"' +
|
|
976
|
+
actionAttrs('add-list-column', { index: i }) +
|
|
977
|
+
'>+ Add column</button>' +
|
|
978
|
+
'</div>');
|
|
979
|
+
}
|
|
980
|
+
function schemaRowHtml(tag, i) {
|
|
981
|
+
const vis = fieldVisibility(tag);
|
|
982
|
+
// An empty string means "not set" (the flat-list/no-options convention) — show it as an
|
|
983
|
+
// empty field instead of the literal two-character JSON string '""'.
|
|
984
|
+
const additionalOptionsRaw = tag.additional_options !== undefined && tag.additional_options !== '' ? JSON.stringify(tag.additional_options) : '';
|
|
985
|
+
const field = (name) => actionAttrs('schema-field', { index: i, field: name });
|
|
986
|
+
const defaultValueCell = vis.defaultValue
|
|
987
|
+
? '<textarea class="cell-textarea"' +
|
|
988
|
+
field('default_value') +
|
|
989
|
+
' data-autosize>' +
|
|
990
|
+
escapeHtml(tag.default_value || '') +
|
|
991
|
+
'</textarea>'
|
|
992
|
+
: '<textarea class="cell-textarea" disabled title="MI\'s own editor has no Default Value field for this tag_type.">' +
|
|
993
|
+
escapeHtml(tag.default_value || '') +
|
|
994
|
+
'</textarea>';
|
|
995
|
+
const tagSourceCell = vis.tagSource
|
|
996
|
+
? tagSourceSelectCell(tag, i)
|
|
997
|
+
: '<span class="badge">' +
|
|
998
|
+
escapeHtml(tag.tag_source || 'static') +
|
|
999
|
+
'</span> <div class="hint">Only configurable for select/multiselect in MI\'s own editor.</div>';
|
|
1000
|
+
const additionalOptionsCell = vis.additionalOptions
|
|
1001
|
+
? '<textarea id="ao-raw-' +
|
|
1002
|
+
i +
|
|
1003
|
+
'"' +
|
|
1004
|
+
actionAttrs('schema-additional-options', { index: i }) +
|
|
1005
|
+
'>' +
|
|
1006
|
+
escapeHtml(additionalOptionsRaw) +
|
|
1007
|
+
'</textarea>'
|
|
1008
|
+
: '<textarea id="ao-raw-' +
|
|
1009
|
+
i +
|
|
1010
|
+
'" disabled title="Not used for this tag_type/tag_source combination in MI\'s own editor.">' +
|
|
1011
|
+
escapeHtml(additionalOptionsRaw) +
|
|
1012
|
+
'</textarea>';
|
|
1013
|
+
const listColumnsEditor = tag.tag_type === 'list' ? listColumnsEditorHtml(tag, i) : '';
|
|
1014
|
+
return `
|
|
1015
|
+
<tr class="schema-row" data-row-index="${i}" data-drop="schema-row">
|
|
1016
|
+
<td class="col-expand">
|
|
1017
|
+
<div class="ve-expand-cell">
|
|
1018
|
+
<span class="ve-schema-row-handle" draggable="true" data-drag="schema-row" title="Drag to reorder" aria-label="Drag to reorder" role="button" tabindex="0">⠿</span>
|
|
1019
|
+
<button class="btn btn-sm ve-expand-btn" id="expand-btn-${i}"${actionAttrs('toggle-schema-advanced', { index: i })} aria-expanded="false" title="Advanced fields (uid, tag_source, additional_options, editor flags)">▸</button>
|
|
1020
|
+
</div>
|
|
1021
|
+
</td>
|
|
1022
|
+
<td class="col-name">
|
|
1023
|
+
<input type="text" value="${escapeHtml(tag.name || '')}"${field('name')} />
|
|
1024
|
+
${nameWarningHint(tag.name)}
|
|
1025
|
+
</td>
|
|
1026
|
+
<td class="col-type">${typeSelectCell(tag, i)}</td>
|
|
1027
|
+
<td>${defaultValueCell}</td>
|
|
1028
|
+
<td><textarea class="cell-textarea"${field('description')} data-autosize>${escapeHtml(tag.description || '')}</textarea></td>
|
|
1029
|
+
<td class="col-actions">
|
|
1030
|
+
<button class="btn btn-sm"${actionAttrs('remove-schema-row', { index: i })}>✕</button>
|
|
1031
|
+
</td>
|
|
1032
|
+
</tr>
|
|
1033
|
+
<tr class="details-row" id="advanced-${i}" data-row-index="${i}" style="display:none;" data-drop="schema-row">
|
|
1034
|
+
<td colspan="6" style="border:none;">
|
|
1035
|
+
<div class="details-grid">
|
|
1036
|
+
<div class="details-grid-item-wrapper">
|
|
1037
|
+
<div class="box-style"><label>uid</label><input type="text" value="${escapeHtml(tag.uid || '')}"${field('uid')} /></div>
|
|
1038
|
+
<div class="box-style"><label>tag_source</label>${tagSourceCell}</div>
|
|
1039
|
+
<div class="box-style" style="height:fit-content;">${editorFlagsRow(tag, i, vis)}</div>
|
|
1040
|
+
</div>
|
|
1041
|
+
<div class="box-style" style="grid-column:1/-1">
|
|
1042
|
+
<label>additional_options (raw JSON) <button type="button" class="btn btn-sm" style="text-transform:none;padding:0 6px;margin-left:4px"${actionAttrs('show-additional-options-help', { index: i })} title="What goes here for this type/source?">?</button></label>
|
|
1043
|
+
${additionalOptionsCell}
|
|
1044
|
+
<div class="hint">${additionalOptionsHint(tag)}</div>
|
|
1045
|
+
|
|
1046
|
+
</div>
|
|
1047
|
+
${listColumnsEditor}
|
|
1048
|
+
</div>
|
|
1049
|
+
</td>
|
|
1050
|
+
</tr>
|
|
1051
|
+
`;
|
|
1052
|
+
}
|
|
1053
|
+
function renderSchemaTabHtml(schemaState, rawMode, schemaRows) {
|
|
1054
|
+
if (!schemaState) {
|
|
1055
|
+
return renderSkeleton();
|
|
1056
|
+
}
|
|
1057
|
+
if (rawMode) {
|
|
1058
|
+
return `
|
|
1059
|
+
<div class="raw-toggle"><button class="btn btn-sm"${actionAttrs('toggle-raw-mode')}>Switch to table view</button></div>
|
|
1060
|
+
${schemaState.parseError ? '<div class="banner banner-error">The file is not a valid schema: ' + escapeHtml(schemaState.parseError) + '</div>' : ''}
|
|
1061
|
+
<textarea class="raw-editor" id="raw-editor"${actionAttrs('set-dirty')}>${escapeHtml(schemaState.raw || '')}</textarea>
|
|
1062
|
+
<div class="hint">Editing the raw file text directly. Must be valid JSON with a top-level "tags" array.</div>
|
|
1063
|
+
`;
|
|
1064
|
+
}
|
|
1065
|
+
if (!schemaState.exists) {
|
|
1066
|
+
return `<div class="empty-state">No local __template_variables.json found.<br><button class="btn btn-sm" style="margin-top:10px"${actionAttrs('add-schema-row')}>Create one — add first variable</button></div>`;
|
|
1067
|
+
}
|
|
1068
|
+
const rows = schemaRows.map(schemaRowHtml).join('');
|
|
1069
|
+
return `
|
|
1070
|
+
<div class="raw-toggle"><button class="btn btn-sm"${actionAttrs('toggle-raw-mode')}>View/edit raw JSON</button></div>
|
|
1071
|
+
<table>
|
|
1072
|
+
<thead><tr><th class="col-expand"></th><th class="col-name">Name</th><th class="col-type">Type</th><th>Default value</th><th>Description</th><th class="col-actions"></th></tr></thead>
|
|
1073
|
+
<tbody>${rows || '<tr><td colspan="6" class="empty-state">No variables yet.</td></tr>'}</tbody>
|
|
1074
|
+
</table>
|
|
1075
|
+
<button class="btn btn-sm" style="margin-top:10px"${actionAttrs('add-schema-row')}>+ Add variable</button>
|
|
1076
|
+
<div class="hint">Pick a known type, or choose "Custom…" to enter any value if MI adds a new tag_type.</div>
|
|
1077
|
+
`;
|
|
1078
|
+
}
|
|
1079
|
+
// A new variable's uid: 32 hex digits, the shape of the MD5-based uids MI gives its own.
|
|
1080
|
+
function newUid() {
|
|
1081
|
+
const bytes = new Uint8Array(16);
|
|
1082
|
+
crypto.getRandomValues(bytes);
|
|
1083
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Markdown for page variable descriptions in the Variables Editor's Values tab. MI renders them
|
|
1088
|
+
* with showdown's default options (see MI's `PageVariable.tsx`); this is the subset that matters
|
|
1089
|
+
* there, rendered the same way:
|
|
1090
|
+
*
|
|
1091
|
+
* - `**bold**` and `__bold__`, `*italic*` and `_italic_` (not inside words, like `snake_case`);
|
|
1092
|
+
* - inline code with one or more backticks (``` `` a `b` `` ```), and fenced ```` ``` ```` blocks;
|
|
1093
|
+
* - `-`, `*`, `+` and `1.` lists;
|
|
1094
|
+
* - underline and strikethrough the way showdown lets them through: as `<u>`, `<s>`, `<del>` and
|
|
1095
|
+
* `<strike>` tags. (Showdown's defaults don't turn `~~text~~` into strikethrough.)
|
|
1096
|
+
*
|
|
1097
|
+
* Everything else is escaped, so a description can't inject markup. Line breaks are kept, as the
|
|
1098
|
+
* plain-text hint did before.
|
|
1099
|
+
*/
|
|
1100
|
+
function renderDescriptionMarkdown(text) {
|
|
1101
|
+
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1102
|
+
const inline = (line) => {
|
|
1103
|
+
const codes = [];
|
|
1104
|
+
// Code spans first, so nothing inside them is formatted: a whole run of backticks up to the
|
|
1105
|
+
// same run. Mismatched runs stay as they are.
|
|
1106
|
+
let out = line.replace(/(?<!\x60)(\x60+)([^\x60]|[^\x60][\s\S]*?[^\x60])\1(?!\x60)/g, (_, ticks, code) => {
|
|
1107
|
+
codes.push('<code>' + escape(code.replace(/\n/g, ' ').trim()) + '</code>');
|
|
1108
|
+
return '\u0000' + (codes.length - 1) + '\u0000';
|
|
1109
|
+
});
|
|
1110
|
+
out = escape(out)
|
|
1111
|
+
.replace(/<(\/?)(u|s|del|strike)>/gi, '<$1$2>')
|
|
1112
|
+
.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>')
|
|
1113
|
+
.replace(/(^|[^\w])__(?=\S)([\s\S]*?\S)__(?=[^\w]|$)/g, '$1<strong>$2</strong>')
|
|
1114
|
+
.replace(/\*(?=\S)([\s\S]*?\S)\*/g, '<em>$1</em>')
|
|
1115
|
+
.replace(/(^|[^\w])_(?=\S)([\s\S]*?\S)_(?=[^\w]|$)/g, '$1<em>$2</em>');
|
|
1116
|
+
return out.replace(/\u0000(\d+)\u0000/g, (_, i) => codes[Number(i)]);
|
|
1117
|
+
};
|
|
1118
|
+
const lines = String(text).replace(/\r\n?/g, '\n').split('\n');
|
|
1119
|
+
const blocks = [];
|
|
1120
|
+
let paragraph = [];
|
|
1121
|
+
let list = null;
|
|
1122
|
+
const flushParagraph = () => {
|
|
1123
|
+
if (paragraph.length) {
|
|
1124
|
+
// One paragraph at a time, so code and emphasis can span its line breaks, which stay.
|
|
1125
|
+
blocks.push('<p>' + inline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
|
|
1126
|
+
paragraph = [];
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
const flushList = () => {
|
|
1130
|
+
if (list) {
|
|
1131
|
+
// A numbered list goes on from the number its first item has: "2." after a blank line is 2.
|
|
1132
|
+
const start = list.start && list.start !== 1 ? ' start="' + list.start + '"' : '';
|
|
1133
|
+
blocks.push('<' +
|
|
1134
|
+
list.tag +
|
|
1135
|
+
start +
|
|
1136
|
+
'>' +
|
|
1137
|
+
list.items.map((item) => '<li>' + inline(item) + '</li>').join('') +
|
|
1138
|
+
'</' +
|
|
1139
|
+
list.tag +
|
|
1140
|
+
'>');
|
|
1141
|
+
list = null;
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1145
|
+
const line = lines[i];
|
|
1146
|
+
// An opening fence: three or more backticks and an info string without any (a line with three
|
|
1147
|
+
// backticks, "a", three backticks again is inline code instead).
|
|
1148
|
+
const fence = /^\s*(\x60{3,})[^\x60]*$/.exec(line);
|
|
1149
|
+
if (fence) {
|
|
1150
|
+
flushParagraph();
|
|
1151
|
+
flushList();
|
|
1152
|
+
const code = [];
|
|
1153
|
+
// Closed by a line of only backticks, at least as many as opened it.
|
|
1154
|
+
const close = new RegExp('^\\s*\\x60{' + fence[1].length + ',}\\s*$');
|
|
1155
|
+
for (i++; i < lines.length && !close.test(lines[i]); i++) {
|
|
1156
|
+
code.push(lines[i]);
|
|
1157
|
+
}
|
|
1158
|
+
blocks.push('<pre><code>' + escape(code.join('\n')) + '</code></pre>');
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
const item = /^\s*(?:([-*+])|(\d+)[.)])\s+(.*)$/.exec(line);
|
|
1162
|
+
if (item) {
|
|
1163
|
+
const tag = item[1] ? 'ul' : 'ol';
|
|
1164
|
+
flushParagraph();
|
|
1165
|
+
if (list && list.tag !== tag) {
|
|
1166
|
+
flushList();
|
|
1167
|
+
}
|
|
1168
|
+
list = list || { tag, items: [], start: item[2] ? Number(item[2]) : undefined };
|
|
1169
|
+
list.items.push(item[3]);
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
if (!line.trim()) {
|
|
1173
|
+
flushParagraph();
|
|
1174
|
+
flushList();
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
// A line right after a list item, indented, continues it.
|
|
1178
|
+
if (list && /^\s+\S/.test(line)) {
|
|
1179
|
+
list.items[list.items.length - 1] += ' ' + line.trim();
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
flushList();
|
|
1183
|
+
paragraph.push(line);
|
|
1184
|
+
}
|
|
1185
|
+
flushParagraph();
|
|
1186
|
+
flushList();
|
|
1187
|
+
return blocks.join('');
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// The Values tab's HTML (a widget per variable, by its schema type) and its JSON mode's
|
|
1191
|
+
// conversions. Controls say what they do in `data-action` (with `data-index`, `data-item`,
|
|
1192
|
+
// `data-column`, …); editor.ts handles their events in one place.
|
|
1193
|
+
function schemaTagFor(state, name) {
|
|
1194
|
+
const tags = (state?.schema && state.schema.tags) || [];
|
|
1195
|
+
return tags.find((t) => t.name === name);
|
|
1196
|
+
}
|
|
1197
|
+
// MI renders descriptions as markdown; the part of it that matters there (see
|
|
1198
|
+
// markdown.ts), with everything else escaped.
|
|
1199
|
+
function descriptionHint(tag) {
|
|
1200
|
+
if (!tag || !tag.description) {
|
|
1201
|
+
return '';
|
|
1202
|
+
}
|
|
1203
|
+
return '<div class="hint ve-md" style="margin-bottom: 8px;">' + renderDescriptionMarkdown(tag.description) + '</div>';
|
|
1204
|
+
}
|
|
1205
|
+
// The plain-text default already matches MI's own fallback (a multiline input); on top of
|
|
1206
|
+
// that, mirror the three editor-flag checkboxes from the Schema tab's Advanced panel — MI's
|
|
1207
|
+
// own value editor swaps in a rich/code editor for the first two (we use a plain textarea for
|
|
1208
|
+
// both, since embedding a real WYSIWYG editor is out of scope for this lightweight tool). The
|
|
1209
|
+
// third (use_json_editor_ind) is accepted by MI's create form but never actually acted upon by
|
|
1210
|
+
// its value editor either — shown here only as an FYI, no widget change.
|
|
1211
|
+
function textValueCell(tag, row, i) {
|
|
1212
|
+
const widget = '<textarea class="cell-textarea"' +
|
|
1213
|
+
actionAttrs('value', { index: i }) +
|
|
1214
|
+
' data-autosize>' +
|
|
1215
|
+
escapeHtml(row.value) +
|
|
1216
|
+
'</textarea>';
|
|
1217
|
+
let note = '';
|
|
1218
|
+
if (tag.use_hmtl_editor_ind === 'Y') {
|
|
1219
|
+
note = 'WYSIWYG HTML in MI — edited as raw HTML here.';
|
|
1220
|
+
}
|
|
1221
|
+
else if (tag.use_raw_html_ind === 'Y') {
|
|
1222
|
+
note = 'Raw HTML — MI skips XSS-encoding this value on save.';
|
|
1223
|
+
}
|
|
1224
|
+
else if (tag.use_json_editor_ind === 'Y') {
|
|
1225
|
+
note = "JSON Editor flag is set, though MI's value editor doesn't actually act on it.";
|
|
1226
|
+
}
|
|
1227
|
+
return note ? widget + '<div class="hint">' + note + '</div>' : widget;
|
|
1228
|
+
}
|
|
1229
|
+
function normalizeStaticOptions(tag) {
|
|
1230
|
+
const opts = tag && tag.additional_options;
|
|
1231
|
+
if (!Array.isArray(opts)) {
|
|
1232
|
+
return [];
|
|
1233
|
+
}
|
|
1234
|
+
return opts.map(normalizeOptionEntry);
|
|
1235
|
+
}
|
|
1236
|
+
function labelForOption(options, id) {
|
|
1237
|
+
const found = options.find((o) => o.id === id);
|
|
1238
|
+
return found ? found.text : id;
|
|
1239
|
+
}
|
|
1240
|
+
// The options of a combobox: picked on mousedown (see editor.ts), before the input loses focus.
|
|
1241
|
+
function comboOptionsHtml(options) {
|
|
1242
|
+
return options
|
|
1243
|
+
.map((o) => '<div class="ve-combo-option" data-id="' +
|
|
1244
|
+
escapeHtml(o.id) +
|
|
1245
|
+
'" data-text="' +
|
|
1246
|
+
escapeHtml(o.text) +
|
|
1247
|
+
'"' +
|
|
1248
|
+
actionAttrs('pick-combo-option') +
|
|
1249
|
+
'>' +
|
|
1250
|
+
(o.text !== o.id
|
|
1251
|
+
? escapeHtml(o.text) + ' <span class="hint">(' + escapeHtml(o.id) + ')</span>'
|
|
1252
|
+
: escapeHtml(o.id)) +
|
|
1253
|
+
'</div>')
|
|
1254
|
+
.join('');
|
|
1255
|
+
}
|
|
1256
|
+
function chipHtml(id, text) {
|
|
1257
|
+
return ('<span class="ve-chip" data-id="' +
|
|
1258
|
+
escapeHtml(id) +
|
|
1259
|
+
'">' +
|
|
1260
|
+
escapeHtml(text) +
|
|
1261
|
+
'<button type="button"' +
|
|
1262
|
+
actionAttrs('remove-chip') +
|
|
1263
|
+
'>✕</button></span>');
|
|
1264
|
+
}
|
|
1265
|
+
// A search-as-you-type combobox — not a native <select>/<datalist> (see the Schema tab's
|
|
1266
|
+
// tag_type picker for why: unstylable popups, broken selected-state colors). Only viable for
|
|
1267
|
+
// tag_source "static", since that's the only source whose options live in additional_options
|
|
1268
|
+
// itself; every other source needs MI's own internal editor-only options endpoint, which we
|
|
1269
|
+
// deliberately don't call (no public API for it — see TEMPLATE_VARIABLES.md).
|
|
1270
|
+
function selectValueWidget(tag, row, i) {
|
|
1271
|
+
const options = normalizeStaticOptions(tag);
|
|
1272
|
+
const isMulti = tag.tag_type === 'multiselect';
|
|
1273
|
+
if (isMulti) {
|
|
1274
|
+
const ids = row.value ? row.value.split(',').filter(Boolean) : [];
|
|
1275
|
+
const chips = ids.map((id) => chipHtml(id, labelForOption(options, id))).join('');
|
|
1276
|
+
return ('<div class="ve-combo ve-combo-multi">' +
|
|
1277
|
+
'<div class="ve-combo-chips" data-row="' +
|
|
1278
|
+
i +
|
|
1279
|
+
'" id="combo-chips-' +
|
|
1280
|
+
i +
|
|
1281
|
+
'">' +
|
|
1282
|
+
chips +
|
|
1283
|
+
'</div>' +
|
|
1284
|
+
'<input type="text" class="ve-combo-input" placeholder="Search by id or value…"' +
|
|
1285
|
+
actionAttrs('combo-filter', { index: i }) +
|
|
1286
|
+
' />' +
|
|
1287
|
+
'<div class="ve-combo-list" id="combo-list-' +
|
|
1288
|
+
i +
|
|
1289
|
+
'" style="display:none">' +
|
|
1290
|
+
comboOptionsHtml(options) +
|
|
1291
|
+
'</div>' +
|
|
1292
|
+
'</div>');
|
|
1293
|
+
}
|
|
1294
|
+
return ('<div class="ve-combo">' +
|
|
1295
|
+
'<input type="text" class="ve-combo-input" value="' +
|
|
1296
|
+
escapeHtml(row.value || '') +
|
|
1297
|
+
'" placeholder="Search by id or value…"' +
|
|
1298
|
+
actionAttrs('single-combo', { index: i }) +
|
|
1299
|
+
' />' +
|
|
1300
|
+
'<div class="ve-combo-list" id="combo-list-' +
|
|
1301
|
+
i +
|
|
1302
|
+
'" style="display:none">' +
|
|
1303
|
+
comboOptionsHtml(options) +
|
|
1304
|
+
'</div>' +
|
|
1305
|
+
'</div>');
|
|
1306
|
+
}
|
|
1307
|
+
// Non-empty additional_options (array) means column-defined objects; empty/absent means a flat
|
|
1308
|
+
// list of strings. A bare string entry is shorthand for { name: <string>, type: 'textarea' }.
|
|
1309
|
+
function listConfigFor(tag) {
|
|
1310
|
+
const opts = tag && tag.additional_options;
|
|
1311
|
+
return Array.isArray(opts) && opts.length
|
|
1312
|
+
? opts.map((c) => (typeof c === 'string' ? { name: c, type: 'textarea' } : c))
|
|
1313
|
+
: null;
|
|
1314
|
+
}
|
|
1315
|
+
function parseListItems(row) {
|
|
1316
|
+
try {
|
|
1317
|
+
const parsed = row.value ? JSON.parse(row.value) : [];
|
|
1318
|
+
// A stored "null" (MI's own encoding for "no value yet") means an empty list, not invalid
|
|
1319
|
+
// JSON — only reject genuinely non-array, non-null JSON (e.g. an object or a number).
|
|
1320
|
+
if (parsed === null) {
|
|
1321
|
+
return [];
|
|
1322
|
+
}
|
|
1323
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
1324
|
+
}
|
|
1325
|
+
catch {
|
|
1326
|
+
return null;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
function listFieldLabelHtml(colName, source) {
|
|
1330
|
+
return ('<div class="ve-values-field-label"><span>' + escapeHtml(colName) + '</span>' + sourceBadgeHtml(source) + '</div>');
|
|
1331
|
+
}
|
|
1332
|
+
function listValueWidget(tag, row, i) {
|
|
1333
|
+
const items = parseListItems(row);
|
|
1334
|
+
if (items === null) {
|
|
1335
|
+
return ('<div class="ve-values-field is-wide"><div class="ve-values-field-label"><span>value</span></div>' +
|
|
1336
|
+
'<textarea class="cell-textarea"' +
|
|
1337
|
+
actionAttrs('value', { index: i }) +
|
|
1338
|
+
' data-autosize>' +
|
|
1339
|
+
escapeHtml(row.value) +
|
|
1340
|
+
'</textarea>' +
|
|
1341
|
+
'<div class="hint" style="color:var(--red)">Not a valid JSON array — edit as raw text above, or fix it via "View/edit raw JSON".</div></div>');
|
|
1342
|
+
}
|
|
1343
|
+
const config = listConfigFor(tag);
|
|
1344
|
+
const itemsHtml = items
|
|
1345
|
+
.map((item, itemIndex) => {
|
|
1346
|
+
return ('<div class="ve-values-list-item" data-value-index="' +
|
|
1347
|
+
i +
|
|
1348
|
+
'" data-item-index="' +
|
|
1349
|
+
itemIndex +
|
|
1350
|
+
'" data-drop="list-item">' +
|
|
1351
|
+
'<span class="ve-values-list-item-handle" draggable="true" data-drag="list-item" title="Drag to reorder" aria-label="Drag to reorder" role="button" tabindex="0">⠿</span>' +
|
|
1352
|
+
listItemFieldsHtml(config, item, i, itemIndex) +
|
|
1353
|
+
'<button type="button" class="btn btn-sm ve-values-list-item-remove" title="Remove item" aria-label="Remove item"' +
|
|
1354
|
+
actionAttrs('remove-list-item', { index: i, item: itemIndex }) +
|
|
1355
|
+
'>✕</button>' +
|
|
1356
|
+
'</div>');
|
|
1357
|
+
})
|
|
1358
|
+
.join('');
|
|
1359
|
+
return ('<div class="ve-values-list-items">' +
|
|
1360
|
+
(itemsHtml || '<div class="ve-values-empty">No items yet.</div>') +
|
|
1361
|
+
'</div>' +
|
|
1362
|
+
'<button type="button" class="ve-values-nav-item ve-values-nav-add" style="margin-top:12px;width:auto"' +
|
|
1363
|
+
actionAttrs('add-list-item', { index: i }) +
|
|
1364
|
+
'>+ Add item</button>');
|
|
1365
|
+
}
|
|
1366
|
+
function listItemFieldsHtml(config, item, i, itemIndex) {
|
|
1367
|
+
if (!config) {
|
|
1368
|
+
const val = typeof item === 'string' ? item : String(item != null ? item : '');
|
|
1369
|
+
return ('<div class="ve-values-fields"><div class="ve-values-field is-wide">' +
|
|
1370
|
+
'<textarea class="cell-textarea"' +
|
|
1371
|
+
actionAttrs('list-item-field', { index: i, item: itemIndex }) +
|
|
1372
|
+
' data-autosize>' +
|
|
1373
|
+
escapeHtml(val) +
|
|
1374
|
+
'</textarea>' +
|
|
1375
|
+
'</div></div>');
|
|
1376
|
+
}
|
|
1377
|
+
const fieldsHtml = config
|
|
1378
|
+
.map((col) => {
|
|
1379
|
+
const colName = String(col.name);
|
|
1380
|
+
const field = (action) => actionAttrs(action, { index: i, item: itemIndex, column: colName });
|
|
1381
|
+
const colType = col.type || 'textarea';
|
|
1382
|
+
const colSource = col.source ? col.source : colType === 'select' || colType === 'multi-select' ? 'static' : '';
|
|
1383
|
+
const rawVal = typeof item === 'object' && item !== null ? item[colName] : '';
|
|
1384
|
+
const val = rawVal != null ? String(rawVal) : '';
|
|
1385
|
+
const wide = colType === 'textarea' || colType === 'file' || val.length > 48 ? ' is-wide' : '';
|
|
1386
|
+
if (colType === 'color') {
|
|
1387
|
+
const safe = /^#[0-9a-fA-F]{3,8}$/.test(val) ? val : '#000000';
|
|
1388
|
+
return ('<div class="ve-values-field">' +
|
|
1389
|
+
listFieldLabelHtml(colName, colSource) +
|
|
1390
|
+
'<input type="color" value="' +
|
|
1391
|
+
safe +
|
|
1392
|
+
'"' +
|
|
1393
|
+
field('list-item-field') +
|
|
1394
|
+
' /></div>');
|
|
1395
|
+
}
|
|
1396
|
+
if (colType === 'select' || colType === 'multi-select') {
|
|
1397
|
+
const isMulti = colType === 'multi-select';
|
|
1398
|
+
// Only "static" ships its option list locally (col.options); every other source is
|
|
1399
|
+
// populated live by MI's own editor (see additionalOptionsDetails()) and pp-dev has no
|
|
1400
|
+
// API to fetch it here — fall back to free text, same as the tag-level select does.
|
|
1401
|
+
if (colSource !== 'static') {
|
|
1402
|
+
return ('<div class="ve-values-field">' +
|
|
1403
|
+
listFieldLabelHtml(colName, colSource) +
|
|
1404
|
+
'<input type="text" value="' +
|
|
1405
|
+
escapeHtml(val) +
|
|
1406
|
+
'"' +
|
|
1407
|
+
field('list-item-field') +
|
|
1408
|
+
' placeholder="source: ' +
|
|
1409
|
+
escapeHtml(colSource) +
|
|
1410
|
+
' — no local option list" /></div>');
|
|
1411
|
+
}
|
|
1412
|
+
// Each entry may be a plain string or a { id, text } object (e.g. {"text":"Yes","id":"Y"}) —
|
|
1413
|
+
// normalize before use, or an object entry stringifies to "[object Object]" for both the
|
|
1414
|
+
// <option> value and its label.
|
|
1415
|
+
const opts = (Array.isArray(col.options) ? col.options : []).map(normalizeOptionEntry);
|
|
1416
|
+
const selected = isMulti ? val.split(',').filter(Boolean) : [val];
|
|
1417
|
+
const optionsHtml = opts
|
|
1418
|
+
.map((o) => '<option value="' +
|
|
1419
|
+
escapeHtml(o.id) +
|
|
1420
|
+
'"' +
|
|
1421
|
+
(selected.indexOf(o.id) !== -1 ? ' selected' : '') +
|
|
1422
|
+
'>' +
|
|
1423
|
+
escapeHtml(o.text) +
|
|
1424
|
+
'</option>')
|
|
1425
|
+
.join('');
|
|
1426
|
+
return ('<div class="ve-values-field">' +
|
|
1427
|
+
listFieldLabelHtml(colName, colSource) +
|
|
1428
|
+
'<select ' +
|
|
1429
|
+
(isMulti ? 'multiple ' : '') +
|
|
1430
|
+
field('list-item-select').trim() +
|
|
1431
|
+
' data-autosize>' +
|
|
1432
|
+
optionsHtml +
|
|
1433
|
+
'</select></div>');
|
|
1434
|
+
}
|
|
1435
|
+
return ('<div class="ve-values-field' +
|
|
1436
|
+
wide +
|
|
1437
|
+
'">' +
|
|
1438
|
+
listFieldLabelHtml(colName, colSource) +
|
|
1439
|
+
'<textarea class="cell-textarea"' +
|
|
1440
|
+
field('list-item-field') +
|
|
1441
|
+
' data-autosize>' +
|
|
1442
|
+
escapeHtml(val) +
|
|
1443
|
+
'</textarea></div>');
|
|
1444
|
+
})
|
|
1445
|
+
.join('');
|
|
1446
|
+
return '<div class="ve-values-fields">' + fieldsHtml + '</div>';
|
|
1447
|
+
}
|
|
1448
|
+
function valueEditorCell(tag, row, i) {
|
|
1449
|
+
const type = tag ? tag.tag_type : null;
|
|
1450
|
+
const value = actionAttrs('value', { index: i });
|
|
1451
|
+
if (type === 'boolean') {
|
|
1452
|
+
// MI's own page-variable UI writes literal 'true'/'false' strings, not 'Y'/'N' — the
|
|
1453
|
+
// extra accepted values below are just legacy/lenient reads, not something we write.
|
|
1454
|
+
const checked = ['true', 'Y', '1'].includes(row.value);
|
|
1455
|
+
return ('<input type="checkbox" ' +
|
|
1456
|
+
(checked ? 'checked' : '') +
|
|
1457
|
+
' ' +
|
|
1458
|
+
actionAttrs('value-boolean', { index: i }).trim() +
|
|
1459
|
+
' />');
|
|
1460
|
+
}
|
|
1461
|
+
if (type === 'color') {
|
|
1462
|
+
const safe = /^#[0-9a-fA-F]{3,8}$/.test(row.value) ? row.value : '#000000';
|
|
1463
|
+
return '<input type="color" value="' + safe + '"' + value + ' />';
|
|
1464
|
+
}
|
|
1465
|
+
if ((type === 'select' || type === 'multiselect') && tag) {
|
|
1466
|
+
const source = tag.tag_source || 'static';
|
|
1467
|
+
if (source === 'static') {
|
|
1468
|
+
return selectValueWidget(tag, row, i);
|
|
1469
|
+
}
|
|
1470
|
+
return ('<input type="text" value="' +
|
|
1471
|
+
escapeHtml(row.value) +
|
|
1472
|
+
'"' +
|
|
1473
|
+
value +
|
|
1474
|
+
' placeholder="tag_source: ' +
|
|
1475
|
+
escapeHtml(source) +
|
|
1476
|
+
' — no local option list" />');
|
|
1477
|
+
}
|
|
1478
|
+
if (type === 'file') {
|
|
1479
|
+
// No public MI API exists for listing/uploading a page's individual file assets (only
|
|
1480
|
+
// whole-bundle zip download/upload, which doesn't fit a single value) — manual path only.
|
|
1481
|
+
// MI's own upload control restricts these to images: jpg/jpeg/png/gif/svg.
|
|
1482
|
+
return ('<input type="text" value="' +
|
|
1483
|
+
escapeHtml(row.value) +
|
|
1484
|
+
'"' +
|
|
1485
|
+
value +
|
|
1486
|
+
' placeholder="Existing image filename (jpg/jpeg/png/gif/svg)" />');
|
|
1487
|
+
}
|
|
1488
|
+
if (type === 'list') {
|
|
1489
|
+
return listValueWidget(tag, row, i);
|
|
1490
|
+
}
|
|
1491
|
+
if (type === 'text' && tag) {
|
|
1492
|
+
return textValueCell(tag, row, i);
|
|
1493
|
+
}
|
|
1494
|
+
return '<input type="text" value="' + escapeHtml(row.value) + '"' + value + ' />';
|
|
1495
|
+
}
|
|
1496
|
+
// Same convention as the old "Export variables…"/"Setup variables…" flow (now retired in
|
|
1497
|
+
// favor of this JSON mode): a list-type value is shown/accepted as a native JSON array
|
|
1498
|
+
// instead of a JSON-string-in-a-string, converting only at this display/parse boundary —
|
|
1499
|
+
// the PUT /@api/variables/values endpoint itself always sees plain strings.
|
|
1500
|
+
function toExportableValueRows(rows, state) {
|
|
1501
|
+
return rows.map((row) => {
|
|
1502
|
+
const tag = schemaTagFor(state, row.name);
|
|
1503
|
+
if (tag && tag.tag_type === 'list') {
|
|
1504
|
+
try {
|
|
1505
|
+
const parsed = JSON.parse(row.value);
|
|
1506
|
+
if (Array.isArray(parsed)) {
|
|
1507
|
+
return { name: row.name, value: parsed };
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
catch {
|
|
1511
|
+
// not valid JSON — keep the raw string below
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
return { name: row.name, value: row.value };
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
function fromExportableValueRows(entries) {
|
|
1518
|
+
return entries.map(({ name, value }) => ({
|
|
1519
|
+
name,
|
|
1520
|
+
value: typeof value === 'string' ? value : JSON.stringify(value),
|
|
1521
|
+
}));
|
|
1522
|
+
}
|
|
1523
|
+
// Cheap, best-effort lint pass over the JSON textarea's current text — no textarea-internal
|
|
1524
|
+
// highlighting (native hover tooltips can't reach through an editable textarea sitting on top
|
|
1525
|
+
// of an overlay), just a small badge per flagged entry with a native title attribute explanation.
|
|
1526
|
+
function computeJsonIssues(text, state) {
|
|
1527
|
+
let entries;
|
|
1528
|
+
try {
|
|
1529
|
+
entries = JSON.parse(text);
|
|
1530
|
+
}
|
|
1531
|
+
catch {
|
|
1532
|
+
return [];
|
|
1533
|
+
}
|
|
1534
|
+
if (!Array.isArray(entries)) {
|
|
1535
|
+
return [];
|
|
1536
|
+
}
|
|
1537
|
+
const baseline = new Map(toExportableValueRows(state && state.combined ? state.combined : [], state).map((e) => [
|
|
1538
|
+
e.name,
|
|
1539
|
+
JSON.stringify(e.value),
|
|
1540
|
+
]));
|
|
1541
|
+
const issues = [];
|
|
1542
|
+
entries.forEach((entry) => {
|
|
1543
|
+
if (!entry || typeof entry.name !== 'string') {
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
const messages = [];
|
|
1547
|
+
const tag = schemaTagFor(state, entry.name);
|
|
1548
|
+
if (!tag) {
|
|
1549
|
+
messages.push('Not found in the current schema (__template_variables.json) — MI has no matching variable.');
|
|
1550
|
+
}
|
|
1551
|
+
if (tag &&
|
|
1552
|
+
(tag.tag_type === 'select' || tag.tag_type === 'multiselect') &&
|
|
1553
|
+
(tag.tag_source || 'static') === 'static') {
|
|
1554
|
+
const options = normalizeStaticOptions(tag);
|
|
1555
|
+
if (options.length && typeof entry.value === 'string') {
|
|
1556
|
+
const tokens = tag.tag_type === 'multiselect'
|
|
1557
|
+
? entry.value
|
|
1558
|
+
.split(',')
|
|
1559
|
+
.map((v) => v.trim())
|
|
1560
|
+
.filter(Boolean)
|
|
1561
|
+
: [entry.value].filter(Boolean);
|
|
1562
|
+
const unmatched = tokens.filter((t) => !options.some((o) => o.id === t || o.text === t));
|
|
1563
|
+
if (unmatched.length) {
|
|
1564
|
+
messages.push('Value "' + unmatched.join(', ') + "\" doesn't match any option in this variable's static list.");
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
const canonicalValue = JSON.stringify(entry.value);
|
|
1569
|
+
if (baseline.has(entry.name)) {
|
|
1570
|
+
if (baseline.get(entry.name) !== canonicalValue) {
|
|
1571
|
+
messages.push('Changed since this tab was last loaded/refreshed.');
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
else if (tag) {
|
|
1575
|
+
messages.push('New — this variable had no value when this tab was last loaded/refreshed.');
|
|
1576
|
+
}
|
|
1577
|
+
if (messages.length) {
|
|
1578
|
+
issues.push({ name: entry.name, message: messages.join(' ') });
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1581
|
+
return issues;
|
|
1582
|
+
}
|
|
1583
|
+
function renderJsonIssuesBar(text, state) {
|
|
1584
|
+
const issues = computeJsonIssues(text, state);
|
|
1585
|
+
if (!issues.length) {
|
|
1586
|
+
return '';
|
|
1587
|
+
}
|
|
1588
|
+
return ('<div class="ve-json-issues">' +
|
|
1589
|
+
issues
|
|
1590
|
+
.map((issue) => '<span class="ve-json-issue-badge" title="' +
|
|
1591
|
+
escapeHtml(issue.message) +
|
|
1592
|
+
'">⚠ ' +
|
|
1593
|
+
escapeHtml(issue.name) +
|
|
1594
|
+
'</span>')
|
|
1595
|
+
.join('') +
|
|
1596
|
+
'</div>');
|
|
1597
|
+
}
|
|
1598
|
+
function valuesNavItemHtml(tag, row, i, isActive) {
|
|
1599
|
+
const type = tag ? tag.tag_type || 'text' : 'unknown';
|
|
1600
|
+
const showsSource = tag && (tag.tag_type === 'select' || tag.tag_type === 'multiselect' || tag.tag_type === 'list');
|
|
1601
|
+
const source = showsSource ? tag.tag_source || 'static' : '';
|
|
1602
|
+
return ('<button type="button" class="ve-values-nav-item' +
|
|
1603
|
+
(isActive ? ' is-active' : '') +
|
|
1604
|
+
'" data-value-index="' +
|
|
1605
|
+
i +
|
|
1606
|
+
'"' +
|
|
1607
|
+
actionAttrs('select-value-row') +
|
|
1608
|
+
'>' +
|
|
1609
|
+
'<span class="ve-values-nav-dot"></span>' +
|
|
1610
|
+
'<span class="ve-values-nav-label">' +
|
|
1611
|
+
escapeHtml(row.name || '(unnamed)') +
|
|
1612
|
+
'</span>' +
|
|
1613
|
+
'<span class="ve-values-nav-badges">' +
|
|
1614
|
+
typeBadgeHtml(type) +
|
|
1615
|
+
sourceBadgeHtml(source) +
|
|
1616
|
+
'</span>' +
|
|
1617
|
+
'</button>');
|
|
1618
|
+
}
|
|
1619
|
+
function valuesDetailHtml(tag, row, i) {
|
|
1620
|
+
const type = tag ? tag.tag_type || 'text' : '';
|
|
1621
|
+
const source = tag && tag.tag_source ? tag.tag_source : '';
|
|
1622
|
+
const isList = type === 'list';
|
|
1623
|
+
const header = '<div class="ve-values-detail-header">' +
|
|
1624
|
+
'<div class="ve-values-detail-title">' +
|
|
1625
|
+
escapeHtml(row.name || '(unnamed)') +
|
|
1626
|
+
'</div>' +
|
|
1627
|
+
listFieldLabelHtml(type || 'value', source) +
|
|
1628
|
+
'<button type="button" class="btn btn-sm"' +
|
|
1629
|
+
actionAttrs('remove-value-row', { index: i }) +
|
|
1630
|
+
' title="Delete variable">✕</button>' +
|
|
1631
|
+
'</div>';
|
|
1632
|
+
if (isList) {
|
|
1633
|
+
return header + descriptionHint(tag) + listValueWidget(tag, row, i);
|
|
1634
|
+
}
|
|
1635
|
+
return (header +
|
|
1636
|
+
descriptionHint(tag) +
|
|
1637
|
+
'<div class="ve-values-fields"><div class="ve-values-field is-wide">' +
|
|
1638
|
+
valueEditorCell(tag, row, i) +
|
|
1639
|
+
'</div></div>');
|
|
1640
|
+
}
|
|
1641
|
+
/** The Values tab; `selectedIndex` must be a row of `rows` when there are any. */
|
|
1642
|
+
function renderValuesTabHtml(state, rows, rawMode, selectedIndex) {
|
|
1643
|
+
if (!state) {
|
|
1644
|
+
return renderSkeleton();
|
|
1645
|
+
}
|
|
1646
|
+
if (rawMode) {
|
|
1647
|
+
return `
|
|
1648
|
+
<div class="raw-toggle">
|
|
1649
|
+
<button class="btn btn-sm"${actionAttrs('toggle-values-raw-mode')}>Switch to table view</button>
|
|
1650
|
+
<button class="btn btn-sm"${actionAttrs('download-values-json')}>Save to JSON file…</button>
|
|
1651
|
+
<button class="btn btn-sm"${actionAttrs('import-values-json')}>Import from JSON file…</button>
|
|
1652
|
+
<input type="file" id="import-values-file" accept="application/json,.json" style="display:none"${actionAttrs('import-values-file')} />
|
|
1653
|
+
</div>
|
|
1654
|
+
<textarea class="raw-editor" id="values-raw-editor" spellcheck="false"${actionAttrs('values-raw-input')}>${escapeHtml(JSON.stringify(toExportableValueRows(rows, state), null, 2))}</textarea>
|
|
1655
|
+
<div id="values-raw-issues"></div>
|
|
1656
|
+
<div class="hint">Editing the raw JSON directly. Must be an array of { name, value } entries — value may be a native JSON array for list-type variables (converted to MI's plain-string form on save).</div>
|
|
1657
|
+
`;
|
|
1658
|
+
}
|
|
1659
|
+
if (!rows.length) {
|
|
1660
|
+
return `
|
|
1661
|
+
<div class="raw-toggle">
|
|
1662
|
+
<button class="btn btn-sm"${actionAttrs('toggle-values-raw-mode')}>View/edit raw JSON</button>
|
|
1663
|
+
<button class="btn btn-sm"${actionAttrs('open-import-modal')}>⇄ Import from page…</button>
|
|
1664
|
+
</div>
|
|
1665
|
+
<div class="ve-values-layout">
|
|
1666
|
+
<div class="ve-values-nav">
|
|
1667
|
+
<div class="ve-values-nav-list"></div>
|
|
1668
|
+
<button type="button" class="ve-values-nav-item ve-values-nav-add"${actionAttrs('add-value-row')}>+ Add item</button>
|
|
1669
|
+
</div>
|
|
1670
|
+
<div class="ve-values-detail box-style"><div class="ve-values-empty">No variables yet.</div></div>
|
|
1671
|
+
</div>
|
|
1672
|
+
`;
|
|
1673
|
+
}
|
|
1674
|
+
const tags = rows.map((row) => schemaTagFor(state, row.name));
|
|
1675
|
+
const navHtml = rows.map((row, i) => valuesNavItemHtml(tags[i], row, i, i === selectedIndex)).join('');
|
|
1676
|
+
const detailHtml = rows
|
|
1677
|
+
.map((row, i) => '<div class="ve-values-anchor box-style' +
|
|
1678
|
+
(i === selectedIndex ? ' is-active' : '') +
|
|
1679
|
+
'" id="value-item-' +
|
|
1680
|
+
i +
|
|
1681
|
+
'" data-value-index="' +
|
|
1682
|
+
i +
|
|
1683
|
+
'">' +
|
|
1684
|
+
valuesDetailHtml(tags[i], row, i) +
|
|
1685
|
+
'</div>')
|
|
1686
|
+
.join('');
|
|
1687
|
+
return `
|
|
1688
|
+
<div class="raw-toggle">
|
|
1689
|
+
<button class="btn btn-sm"${actionAttrs('toggle-values-raw-mode')}>View/edit raw JSON</button>
|
|
1690
|
+
<button class="btn btn-sm"${actionAttrs('open-import-modal')}>⇄ Import from page…</button>
|
|
1691
|
+
</div>
|
|
1692
|
+
<div class="ve-values-layout">
|
|
1693
|
+
<div class="ve-values-nav">
|
|
1694
|
+
<div class="ve-values-nav-list">${navHtml}</div>
|
|
1695
|
+
</div>
|
|
1696
|
+
<div class="ve-values-detail">${detailHtml}</div>
|
|
1697
|
+
</div>
|
|
1698
|
+
`;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// The Variables Editor page (`/@pp-dev/variables-editor`): the page's template variables
|
|
1702
|
+
// (Schema tab, `__template_variables.json`) and their live values in MI (Values tab). main.ts
|
|
1703
|
+
// starts it on variables-editor.html. The HTML comes from schema-tab.ts, values-tab.ts and
|
|
1704
|
+
// import-modal.ts; its controls say what they do in `data-action`, handled here in one place.
|
|
1705
|
+
const TEMPLATE_LESS_MESSAGE = '<div class="empty-state">This page has no associated template — there\'s no <code>__template_variables.json</code> and no page variables to edit here.</div>';
|
|
1706
|
+
const AUTO_LOAD_DEBOUNCE_MS = 200;
|
|
1707
|
+
// Lets a link like /@pp-dev/variables-editor?tab=values open directly on that tab.
|
|
1708
|
+
function tabFromUrl() {
|
|
1709
|
+
return new URLSearchParams(window.location.search).get('tab') === 'values' ? 'values' : 'schema';
|
|
1710
|
+
}
|
|
1711
|
+
// Values tab's raw/JSON mode is also linkable — /@pp-dev/variables-editor?tab=values&mode=json.
|
|
1712
|
+
function valuesJsonModeFromUrl() {
|
|
1713
|
+
return new URLSearchParams(window.location.search).get('mode') === 'json';
|
|
1714
|
+
}
|
|
1715
|
+
function setUrlState(tab, valuesJsonMode) {
|
|
1716
|
+
const url = new URL(window.location.href);
|
|
1717
|
+
url.searchParams.set('tab', tab);
|
|
1718
|
+
if (tab === 'values' && valuesJsonMode) {
|
|
1719
|
+
url.searchParams.set('mode', 'json');
|
|
1720
|
+
}
|
|
1721
|
+
else {
|
|
1722
|
+
url.searchParams.delete('mode');
|
|
1723
|
+
}
|
|
1724
|
+
history.replaceState(null, '', url);
|
|
1725
|
+
}
|
|
1726
|
+
function autosizeCellTextarea(el) {
|
|
1727
|
+
const type = el.type;
|
|
1728
|
+
if (type === 'color' || type === 'checkbox') {
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
el.style.height = '0';
|
|
1732
|
+
el.style.height = Math.max(30, el.scrollHeight) + 'px';
|
|
1733
|
+
}
|
|
1734
|
+
function autosizeAllCellTextareas() {
|
|
1735
|
+
document
|
|
1736
|
+
.querySelectorAll('.cell-textarea, .ve-list-field textarea, .ve-list-field input[type=text], .ve-list-field select, .ve-values-field textarea, .ve-values-field input[type=text], .ve-values-field select')
|
|
1737
|
+
.forEach((el) => autosizeCellTextarea(el));
|
|
1738
|
+
}
|
|
1739
|
+
/** Starts the editor on the current document; returns a function that stops it (its listeners). */
|
|
1740
|
+
function startVariablesEditor({ templateLess }) {
|
|
1741
|
+
const byId = (id) => document.getElementById(id);
|
|
1742
|
+
// ── Theme (Auto/Dark/Light) ────────────────────────────────────────────────
|
|
1743
|
+
// Runs unconditionally — before the templateLess early-return below — so the switcher works
|
|
1744
|
+
// even on the info-only page. The <head> inline script already set data-pp-dev-theme before
|
|
1745
|
+
// first paint; this keeps the toolbar buttons in step. Shared with the dev panel and the Request
|
|
1746
|
+
// Inspector (see ../theme.ts), so picking a theme in any of them carries over to the others.
|
|
1747
|
+
const showTheme = (theme) => {
|
|
1748
|
+
applyTheme(theme);
|
|
1749
|
+
for (const button of Array.from(byId('theme-switch')?.children ?? [])) {
|
|
1750
|
+
const isActive = button.dataset.themeChoice === theme;
|
|
1751
|
+
button.classList.toggle('active', isActive);
|
|
1752
|
+
button.setAttribute('aria-pressed', String(isActive));
|
|
1753
|
+
}
|
|
1754
|
+
};
|
|
1755
|
+
const onThemeClick = (event) => {
|
|
1756
|
+
const button = event.target instanceof Element ? event.target.closest('[data-theme-choice]') : null;
|
|
1757
|
+
if (button) {
|
|
1758
|
+
const theme = button.dataset.themeChoice;
|
|
1759
|
+
setTheme(theme);
|
|
1760
|
+
showTheme(theme);
|
|
1761
|
+
}
|
|
1762
|
+
};
|
|
1763
|
+
showTheme(getStoredTheme());
|
|
1764
|
+
document.addEventListener('click', onThemeClick);
|
|
1765
|
+
const contentEl = byId('content');
|
|
1766
|
+
// No template: no tabs, no Refresh/Save (hidden by the page's CSS), just the message.
|
|
1767
|
+
if (templateLess) {
|
|
1768
|
+
contentEl.innerHTML = TEMPLATE_LESS_MESSAGE;
|
|
1769
|
+
return () => document.removeEventListener('click', onThemeClick);
|
|
1770
|
+
}
|
|
1771
|
+
let activeTab = tabFromUrl();
|
|
1772
|
+
let schemaState = null;
|
|
1773
|
+
let valuesState = null;
|
|
1774
|
+
let schemaRows = []; // working copy of tags, edited in place
|
|
1775
|
+
const invalidAdditionalOptionsRows = new WeakSet();
|
|
1776
|
+
let valueRows = []; // working copy of {name, value}
|
|
1777
|
+
let selectedValueIndex = 0;
|
|
1778
|
+
let importReviewCandidates = []; // last-fetched import-plan's "importable" list, for applyImportPlan()
|
|
1779
|
+
let importReviewPageName = ''; // source page name, for the post-apply banner
|
|
1780
|
+
let valuesRawMode = activeTab === 'values' && valuesJsonModeFromUrl();
|
|
1781
|
+
let rawMode = false;
|
|
1782
|
+
let dirty = false;
|
|
1783
|
+
let editSeq = 0;
|
|
1784
|
+
let banner = null;
|
|
1785
|
+
// Last-known saved JSON for each tab — used to build the post-save GitHub-style diff.
|
|
1786
|
+
const jsonBaseline = { schema: '', values: '' };
|
|
1787
|
+
// Background-refresh bookkeeping, one pair of (loading flag, sequence counter) per tab: a
|
|
1788
|
+
// stale response (superseded by a newer load for the SAME tab before it resolved) is dropped
|
|
1789
|
+
// rather than clobbering fresher data — see loadTabData().
|
|
1790
|
+
let schemaLoading = false;
|
|
1791
|
+
let valuesLoading = false;
|
|
1792
|
+
let schemaSeq = 0;
|
|
1793
|
+
let valuesSeq = 0;
|
|
1794
|
+
let autoLoadTimer = null;
|
|
1795
|
+
// Set by stop(): a load, save or timer that completes afterwards must not paint over the markup,
|
|
1796
|
+
// which a new instance may have taken over meanwhile.
|
|
1797
|
+
let stopped = false;
|
|
1798
|
+
const onBeforeUnload = (e) => {
|
|
1799
|
+
if (dirty) {
|
|
1800
|
+
e.preventDefault();
|
|
1801
|
+
e.returnValue = '';
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1804
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
1805
|
+
function setDirty(v) {
|
|
1806
|
+
dirty = v;
|
|
1807
|
+
if (v) {
|
|
1808
|
+
editSeq += 1;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
function showBanner(type, html, preserveContent) {
|
|
1812
|
+
if (stopped) {
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
banner = { type, html };
|
|
1816
|
+
if (preserveContent) {
|
|
1817
|
+
const existingBanner = contentEl.querySelector(':scope > .banner');
|
|
1818
|
+
const bannerEl = document.createElement('div');
|
|
1819
|
+
if (existingBanner) {
|
|
1820
|
+
existingBanner.remove();
|
|
1821
|
+
}
|
|
1822
|
+
bannerEl.className = 'banner banner-' + type;
|
|
1823
|
+
bannerEl.innerHTML = html;
|
|
1824
|
+
contentEl.prepend(bannerEl);
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
render();
|
|
1828
|
+
}
|
|
1829
|
+
function clearBanner() {
|
|
1830
|
+
banner = null;
|
|
1831
|
+
}
|
|
1832
|
+
// ── Data loading ─────────────────────────────────────────────────────────────
|
|
1833
|
+
async function loadSchema(seq) {
|
|
1834
|
+
let r;
|
|
1835
|
+
let data;
|
|
1836
|
+
try {
|
|
1837
|
+
r = await fetch('/@api/variables/schema');
|
|
1838
|
+
data = (await r.json());
|
|
1839
|
+
}
|
|
1840
|
+
catch (e) {
|
|
1841
|
+
if (seq !== schemaSeq) {
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
showBanner('error', 'Failed to load the schema: ' + escapeHtml(errorMessage(e)));
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
if (seq !== schemaSeq) {
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
// An error answer (e.g. 503 with no dist service) is not a schema: kept as one, it rendered as
|
|
1851
|
+
// "No local __template_variables.json found" and offered to create a file over the real one.
|
|
1852
|
+
if (!r.ok) {
|
|
1853
|
+
schemaState = null;
|
|
1854
|
+
schemaRows = [];
|
|
1855
|
+
showBanner('error', 'Failed to load the schema: ' + escapeHtml((data && data.error) || 'HTTP ' + r.status));
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
schemaState = data;
|
|
1859
|
+
schemaRows =
|
|
1860
|
+
data.schema && Array.isArray(data.schema.tags) ? data.schema.tags.map((t) => Object.assign({}, t)) : [];
|
|
1861
|
+
rawMode = data.exists && !data.schema;
|
|
1862
|
+
jsonBaseline.schema = snapshotSchemaJson();
|
|
1863
|
+
}
|
|
1864
|
+
async function loadValues(seq) {
|
|
1865
|
+
let r;
|
|
1866
|
+
try {
|
|
1867
|
+
r = await fetch('/@api/variables/values');
|
|
1868
|
+
}
|
|
1869
|
+
catch (e) {
|
|
1870
|
+
if (seq !== valuesSeq) {
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
valuesState = null;
|
|
1874
|
+
valueRows = [];
|
|
1875
|
+
showBanner('error', 'Failed to load values: ' + escapeHtml(errorMessage(e)));
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
if (!r.ok) {
|
|
1879
|
+
const err = (await r.json().catch(() => ({})));
|
|
1880
|
+
if (seq !== valuesSeq) {
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
// A save is a full replacement. Keep the state explicitly unavailable after a failed
|
|
1884
|
+
// read so the user cannot accidentally submit an empty replacement set.
|
|
1885
|
+
valuesState = null;
|
|
1886
|
+
valueRows = [];
|
|
1887
|
+
showBanner('error', escapeHtml(err.error || 'Failed to load values.'));
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
let data;
|
|
1891
|
+
try {
|
|
1892
|
+
data = (await r.json());
|
|
1893
|
+
}
|
|
1894
|
+
catch (e) {
|
|
1895
|
+
if (seq !== valuesSeq) {
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
valuesState = null;
|
|
1899
|
+
valueRows = [];
|
|
1900
|
+
showBanner('error', 'Failed to parse the values response: ' + escapeHtml(errorMessage(e)));
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (seq !== valuesSeq) {
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
valuesState = data;
|
|
1907
|
+
valueRows = (data.combined || []).map((e) => Object.assign({}, e));
|
|
1908
|
+
jsonBaseline.values = snapshotValuesJson();
|
|
1909
|
+
}
|
|
1910
|
+
function snapshotSchemaJson() {
|
|
1911
|
+
if (!schemaState) {
|
|
1912
|
+
return '';
|
|
1913
|
+
}
|
|
1914
|
+
if (rawMode) {
|
|
1915
|
+
const editorEl = byId('raw-editor');
|
|
1916
|
+
return editorEl ? editorEl.value : schemaState.raw || '';
|
|
1917
|
+
}
|
|
1918
|
+
if (schemaState.raw && (!schemaState.schema || !Array.isArray(schemaRows))) {
|
|
1919
|
+
return schemaState.raw;
|
|
1920
|
+
}
|
|
1921
|
+
try {
|
|
1922
|
+
return JSON.stringify(Object.assign({}, schemaState.schema || {}, { tags: schemaRows }), null, 2);
|
|
1923
|
+
}
|
|
1924
|
+
catch {
|
|
1925
|
+
return schemaState.raw || '';
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
function snapshotValuesJson(rows) {
|
|
1929
|
+
try {
|
|
1930
|
+
return JSON.stringify(toExportableValueRows(rows || valueRows, valuesState), null, 2);
|
|
1931
|
+
}
|
|
1932
|
+
catch {
|
|
1933
|
+
return '[]';
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
// Fetches fresh data for one tab, tracking a per-tab "loading" flag and sequence number so a
|
|
1937
|
+
// response that's superseded by a newer load for the SAME tab (started before this one
|
|
1938
|
+
// resolved) gets dropped instead of clobbering fresher data. Toggles the tab's own loading
|
|
1939
|
+
// flag but deliberately doesn't call render() itself — callers decide when to repaint.
|
|
1940
|
+
function loadTabData(tab) {
|
|
1941
|
+
if (tab === 'schema') {
|
|
1942
|
+
const seq = ++schemaSeq;
|
|
1943
|
+
schemaLoading = true;
|
|
1944
|
+
return loadSchema(seq).then(() => {
|
|
1945
|
+
if (seq === schemaSeq) {
|
|
1946
|
+
schemaLoading = false;
|
|
1947
|
+
}
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
const seq = ++valuesSeq;
|
|
1951
|
+
valuesLoading = true;
|
|
1952
|
+
return loadValues(seq).then(() => {
|
|
1953
|
+
if (seq === valuesSeq) {
|
|
1954
|
+
valuesLoading = false;
|
|
1955
|
+
}
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
// The manual "↻ Refresh" button — deliberately NOT debounced, so it's always the fast path
|
|
1959
|
+
// for "I just changed something in MI and want it now" (see scheduleAutoLoad for the
|
|
1960
|
+
// debounced, tab-switch-driven path).
|
|
1961
|
+
async function doRefresh() {
|
|
1962
|
+
clearBanner();
|
|
1963
|
+
setDirty(false);
|
|
1964
|
+
// loadTabData() flips the tab's loading flag synchronously before its fetch resolves, so
|
|
1965
|
+
// render() has to run AFTER that call starts (not before) for the indicator to reflect it.
|
|
1966
|
+
const pending = loadTabData(activeTab);
|
|
1967
|
+
render();
|
|
1968
|
+
await pending;
|
|
1969
|
+
render();
|
|
1970
|
+
}
|
|
1971
|
+
function refresh() {
|
|
1972
|
+
if (dirty) {
|
|
1973
|
+
showConfirmModal('Discard unsaved changes?', () => void doRefresh());
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
void doRefresh();
|
|
1977
|
+
}
|
|
1978
|
+
// Debounces the network round-trip that automatically follows a tab switch: rapidly flipping
|
|
1979
|
+
// between tabs (or landing on one only to immediately leave it) only fires the load that's
|
|
1980
|
+
// still pending once things settle, rather than one request per click.
|
|
1981
|
+
function scheduleAutoLoad(tab) {
|
|
1982
|
+
if (autoLoadTimer) {
|
|
1983
|
+
clearTimeout(autoLoadTimer);
|
|
1984
|
+
}
|
|
1985
|
+
autoLoadTimer = setTimeout(() => {
|
|
1986
|
+
autoLoadTimer = null;
|
|
1987
|
+
// loadTabData() flips the tab's loading flag synchronously before its fetch resolves, so
|
|
1988
|
+
// render() has to run AFTER this call starts (not before) for the indicator to reflect it.
|
|
1989
|
+
const pending = loadTabData(tab);
|
|
1990
|
+
if (activeTab === tab) {
|
|
1991
|
+
render();
|
|
1992
|
+
}
|
|
1993
|
+
void pending.then(() => {
|
|
1994
|
+
if (activeTab === tab) {
|
|
1995
|
+
render();
|
|
1996
|
+
}
|
|
1997
|
+
});
|
|
1998
|
+
}, AUTO_LOAD_DEBOUNCE_MS);
|
|
1999
|
+
}
|
|
2000
|
+
function doSwitchTab(tab) {
|
|
2001
|
+
activeTab = tab;
|
|
2002
|
+
setDirty(false);
|
|
2003
|
+
clearBanner();
|
|
2004
|
+
// Switching tabs by hand always starts the Values tab in table view — only a direct link
|
|
2005
|
+
// (?tab=values&mode=json, handled at bootstrap) opens straight into raw/JSON mode.
|
|
2006
|
+
if (tab === 'values') {
|
|
2007
|
+
valuesRawMode = false;
|
|
2008
|
+
}
|
|
2009
|
+
setUrlState(tab, valuesRawMode);
|
|
2010
|
+
byId('tab-schema').classList.toggle('active', tab === 'schema');
|
|
2011
|
+
byId('tab-values').classList.toggle('active', tab === 'values');
|
|
2012
|
+
// Paint immediately with whatever's already cached for this tab (or a skeleton, if it's
|
|
2013
|
+
// never been loaded) instead of leaving the previous tab's content on screen until the
|
|
2014
|
+
// debounced fetch below resolves.
|
|
2015
|
+
render();
|
|
2016
|
+
scheduleAutoLoad(tab);
|
|
2017
|
+
}
|
|
2018
|
+
function switchTab(tab) {
|
|
2019
|
+
if (dirty) {
|
|
2020
|
+
showConfirmModal('Discard unsaved changes and switch tabs?', () => doSwitchTab(tab));
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
doSwitchTab(tab);
|
|
2024
|
+
}
|
|
2025
|
+
// ── Schema tab ───────────────────────────────────────────────────────────────
|
|
2026
|
+
function showAdditionalOptionsHelp(i) {
|
|
2027
|
+
const tag = schemaRows[i];
|
|
2028
|
+
// showInfoModal() escapes the title itself.
|
|
2029
|
+
showInfoModal('additional_options — ' + (tag.tag_type || 'text') + (tag.tag_source ? ' / ' + tag.tag_source : ''), additionalOptionsDetails(tag));
|
|
2030
|
+
}
|
|
2031
|
+
// Re-renders the whole schema table (needed because changing tag_type/tag_source/an editor
|
|
2032
|
+
// flag can change which OTHER fields this same row should show), then restores row i's
|
|
2033
|
+
// advanced panel to whatever open/closed state it was in — render() always starts it closed.
|
|
2034
|
+
function rerenderKeepingAdvancedOpen(i) {
|
|
2035
|
+
const before = byId('advanced-' + i);
|
|
2036
|
+
const wasOpen = !!before && before.style.display !== 'none';
|
|
2037
|
+
render();
|
|
2038
|
+
if (wasOpen) {
|
|
2039
|
+
const after = byId('advanced-' + i);
|
|
2040
|
+
const btn = byId('expand-btn-' + i);
|
|
2041
|
+
if (after) {
|
|
2042
|
+
after.style.display = '';
|
|
2043
|
+
}
|
|
2044
|
+
if (btn) {
|
|
2045
|
+
btn.textContent = '▾';
|
|
2046
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
function updateEditorFlag(i, field, checked) {
|
|
2051
|
+
schemaRows[i][field] = checked ? 'Y' : 'N';
|
|
2052
|
+
// Matches MI's own form: turning WYSIWYG on is exclusive with Raw HTML / JSON Editor.
|
|
2053
|
+
if (field === 'use_hmtl_editor_ind' && checked) {
|
|
2054
|
+
schemaRows[i].use_raw_html_ind = 'N';
|
|
2055
|
+
schemaRows[i].use_json_editor_ind = 'N';
|
|
2056
|
+
}
|
|
2057
|
+
setDirty(true);
|
|
2058
|
+
rerenderKeepingAdvancedOpen(i);
|
|
2059
|
+
}
|
|
2060
|
+
function onCustomSelectChange(i, selectEl, field) {
|
|
2061
|
+
if (selectEl.value !== CUSTOM_TYPE_SENTINEL) {
|
|
2062
|
+
updateSchemaField(i, field, selectEl.value);
|
|
2063
|
+
// tag_type/tag_source changes can change which other fields this row should show.
|
|
2064
|
+
rerenderKeepingAdvancedOpen(i);
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
showCustomValueModal('Custom ' + field + ' value', schemaRows[i][field] || '', (custom) => {
|
|
2068
|
+
if (custom) {
|
|
2069
|
+
updateSchemaField(i, field, custom);
|
|
2070
|
+
}
|
|
2071
|
+
// Re-render either way: on success, so the select shows the new custom option instead of
|
|
2072
|
+
// "Custom…"; on cancel, so it reverts to the row's actual (unchanged) value.
|
|
2073
|
+
rerenderKeepingAdvancedOpen(i);
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
function toggleRawMode() {
|
|
2077
|
+
if (!schemaState) {
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
if (!rawMode) {
|
|
2081
|
+
// Entering raw mode: serialize the current table state so edits aren't lost.
|
|
2082
|
+
schemaState.raw = JSON.stringify(Object.assign({}, schemaState.schema, { tags: schemaRows }), null, 2);
|
|
2083
|
+
}
|
|
2084
|
+
else {
|
|
2085
|
+
// Leaving raw mode: try to parse back into the table; keep raw mode on failure.
|
|
2086
|
+
const editorEl = byId('raw-editor');
|
|
2087
|
+
const editorText = editorEl ? editorEl.value : schemaState.raw;
|
|
2088
|
+
try {
|
|
2089
|
+
const parsed = JSON.parse(editorText);
|
|
2090
|
+
if (!parsed || !Array.isArray(parsed.tags)) {
|
|
2091
|
+
throw new Error('Missing "tags" array');
|
|
2092
|
+
}
|
|
2093
|
+
schemaState.schema = parsed;
|
|
2094
|
+
schemaRows = parsed.tags.map((t) => Object.assign({}, t));
|
|
2095
|
+
schemaState.exists = true;
|
|
2096
|
+
delete schemaState.parseError;
|
|
2097
|
+
}
|
|
2098
|
+
catch (e) {
|
|
2099
|
+
// showBanner() re-renders, which would otherwise regenerate this textarea from the last
|
|
2100
|
+
// known-good schemaState.raw — put the user's (still-broken) text back afterward so their
|
|
2101
|
+
// in-progress edit isn't silently discarded.
|
|
2102
|
+
showBanner('error', 'Cannot switch to table view: ' + escapeHtml(errorMessage(e)));
|
|
2103
|
+
const freshEditorEl = byId('raw-editor');
|
|
2104
|
+
if (freshEditorEl) {
|
|
2105
|
+
freshEditorEl.value = editorText;
|
|
2106
|
+
}
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
rawMode = !rawMode;
|
|
2111
|
+
render();
|
|
2112
|
+
}
|
|
2113
|
+
function updateSchemaField(i, field, value) {
|
|
2114
|
+
schemaRows[i][field] = value;
|
|
2115
|
+
setDirty(true);
|
|
2116
|
+
}
|
|
2117
|
+
function updateSchemaAdditionalOptions(i, text) {
|
|
2118
|
+
try {
|
|
2119
|
+
schemaRows[i].additional_options = text.trim() ? JSON.parse(text) : '';
|
|
2120
|
+
invalidAdditionalOptionsRows.delete(schemaRows[i]);
|
|
2121
|
+
}
|
|
2122
|
+
catch {
|
|
2123
|
+
schemaRows[i].additional_options = text;
|
|
2124
|
+
invalidAdditionalOptionsRows.add(schemaRows[i]);
|
|
2125
|
+
}
|
|
2126
|
+
setDirty(true);
|
|
2127
|
+
}
|
|
2128
|
+
function syncAdditionalOptionsRaw(i) {
|
|
2129
|
+
const ta = byId('ao-raw-' + i);
|
|
2130
|
+
if (!ta || ta.disabled) {
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
const value = schemaRows[i].additional_options;
|
|
2134
|
+
ta.value = value !== undefined && value !== '' ? JSON.stringify(value) : '';
|
|
2135
|
+
}
|
|
2136
|
+
function commitListColumns(i, columns, rerender) {
|
|
2137
|
+
const normalized = columns.map(normalizeListColumn);
|
|
2138
|
+
schemaRows[i].additional_options = normalized.length ? normalized : '';
|
|
2139
|
+
invalidAdditionalOptionsRows.delete(schemaRows[i]);
|
|
2140
|
+
setDirty(true);
|
|
2141
|
+
if (rerender) {
|
|
2142
|
+
rerenderKeepingAdvancedOpen(i);
|
|
2143
|
+
}
|
|
2144
|
+
else {
|
|
2145
|
+
syncAdditionalOptionsRaw(i);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
function addListColumn(i) {
|
|
2149
|
+
const cols = listColumnsFor(schemaRows[i]);
|
|
2150
|
+
cols.push({ name: '', type: 'textarea' });
|
|
2151
|
+
commitListColumns(i, cols, true);
|
|
2152
|
+
}
|
|
2153
|
+
function removeListColumn(i, colIndex) {
|
|
2154
|
+
const cols = listColumnsFor(schemaRows[i]);
|
|
2155
|
+
cols.splice(colIndex, 1);
|
|
2156
|
+
commitListColumns(i, cols, true);
|
|
2157
|
+
}
|
|
2158
|
+
let listColumnDrag = null;
|
|
2159
|
+
function onListColumnDragStart(ev) {
|
|
2160
|
+
const col = ev.target.closest('.ve-ao-column');
|
|
2161
|
+
if (!col) {
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
listColumnDrag = {
|
|
2165
|
+
row: Number(col.dataset.row),
|
|
2166
|
+
fromIndex: Number(col.dataset.colIndex),
|
|
2167
|
+
};
|
|
2168
|
+
col.classList.add('is-dragging');
|
|
2169
|
+
ev.dataTransfer.effectAllowed = 'move';
|
|
2170
|
+
// Firefox requires setData for the drag to start.
|
|
2171
|
+
ev.dataTransfer.setData('text/plain', String(listColumnDrag.fromIndex));
|
|
2172
|
+
}
|
|
2173
|
+
function onListColumnDragOver(ev, col) {
|
|
2174
|
+
// Schema-row reordering can pass over list-column cards inside an open advanced panel —
|
|
2175
|
+
// accept the drag here so the drop isn't blocked by nested handlers.
|
|
2176
|
+
if (schemaRowDrag) {
|
|
2177
|
+
ev.preventDefault();
|
|
2178
|
+
ev.dataTransfer.dropEffect = 'move';
|
|
2179
|
+
const details = col.closest('tr.details-row');
|
|
2180
|
+
if (details) {
|
|
2181
|
+
details.classList.add('is-drop-target');
|
|
2182
|
+
}
|
|
2183
|
+
return;
|
|
2184
|
+
}
|
|
2185
|
+
if (!listColumnDrag) {
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
if (Number(col.dataset.row) !== listColumnDrag.row) {
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
ev.preventDefault();
|
|
2192
|
+
ev.dataTransfer.dropEffect = 'move';
|
|
2193
|
+
col.classList.add('is-drop-target');
|
|
2194
|
+
}
|
|
2195
|
+
function onListColumnDragLeave(ev, col) {
|
|
2196
|
+
const related = ev.relatedTarget;
|
|
2197
|
+
// Ignore leave events that stay inside the same column card (child elements).
|
|
2198
|
+
if (col.contains(related)) {
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
col.classList.remove('is-drop-target');
|
|
2202
|
+
if (schemaRowDrag) {
|
|
2203
|
+
const details = col.closest('tr.details-row');
|
|
2204
|
+
if (details && !details.contains(related)) {
|
|
2205
|
+
details.classList.remove('is-drop-target');
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
function onListColumnDrop(ev, col) {
|
|
2210
|
+
ev.preventDefault();
|
|
2211
|
+
col.classList.remove('is-drop-target');
|
|
2212
|
+
if (schemaRowDrag) {
|
|
2213
|
+
const details = col.closest('tr.details-row');
|
|
2214
|
+
if (!details) {
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
details.classList.remove('is-drop-target');
|
|
2218
|
+
const toIndex = Number(details.dataset.rowIndex);
|
|
2219
|
+
const fromIndex = schemaRowDrag.fromIndex;
|
|
2220
|
+
schemaRowDrag = null;
|
|
2221
|
+
if (fromIndex !== toIndex) {
|
|
2222
|
+
reorderSchemaRow(fromIndex, toIndex);
|
|
2223
|
+
}
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
if (!listColumnDrag || Number(col.dataset.row) !== listColumnDrag.row) {
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
const toIndex = Number(col.dataset.colIndex);
|
|
2230
|
+
const fromIndex = listColumnDrag.fromIndex;
|
|
2231
|
+
listColumnDrag = null;
|
|
2232
|
+
if (fromIndex === toIndex) {
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
reorderListColumn(Number(col.dataset.row), fromIndex, toIndex);
|
|
2236
|
+
}
|
|
2237
|
+
function onListColumnDragEnd() {
|
|
2238
|
+
listColumnDrag = null;
|
|
2239
|
+
document.querySelectorAll('.ve-ao-column.is-dragging, .ve-ao-column.is-drop-target').forEach((el) => {
|
|
2240
|
+
el.classList.remove('is-dragging', 'is-drop-target');
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
function reorderListColumn(i, fromIndex, toIndex) {
|
|
2244
|
+
const cols = listColumnsFor(schemaRows[i]);
|
|
2245
|
+
if (fromIndex < 0 || fromIndex >= cols.length || toIndex < 0 || toIndex >= cols.length) {
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
const moved = cols.splice(fromIndex, 1)[0];
|
|
2249
|
+
cols.splice(toIndex, 0, moved);
|
|
2250
|
+
commitListColumns(i, cols, true);
|
|
2251
|
+
}
|
|
2252
|
+
function updateListColumnField(i, colIndex, field, value) {
|
|
2253
|
+
const cols = listColumnsFor(schemaRows[i]);
|
|
2254
|
+
const col = Object.assign({ name: '', type: 'textarea' }, cols[colIndex]);
|
|
2255
|
+
if (field === 'name') {
|
|
2256
|
+
col.name = value;
|
|
2257
|
+
}
|
|
2258
|
+
else if (field === 'type') {
|
|
2259
|
+
col.type = value;
|
|
2260
|
+
if (value !== 'select' && value !== 'multi-select') {
|
|
2261
|
+
delete col.source;
|
|
2262
|
+
delete col.options;
|
|
2263
|
+
}
|
|
2264
|
+
else if (Array.isArray(col.options) && col.options.length) {
|
|
2265
|
+
delete col.source;
|
|
2266
|
+
}
|
|
2267
|
+
else if (!col.source) {
|
|
2268
|
+
col.source = 'static';
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
else if (field === 'source') {
|
|
2272
|
+
col.source = value;
|
|
2273
|
+
if (value !== 'static') {
|
|
2274
|
+
delete col.options;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
cols[colIndex] = col;
|
|
2278
|
+
commitListColumns(i, cols, field === 'type' || field === 'source');
|
|
2279
|
+
}
|
|
2280
|
+
function updateListColumnOptions(i, colIndex, text) {
|
|
2281
|
+
const cols = listColumnsFor(schemaRows[i]);
|
|
2282
|
+
const col = Object.assign({ name: '', type: 'textarea' }, cols[colIndex]);
|
|
2283
|
+
const options = text
|
|
2284
|
+
.split(',')
|
|
2285
|
+
.map((s) => s.trim())
|
|
2286
|
+
.filter(Boolean);
|
|
2287
|
+
if (options.length) {
|
|
2288
|
+
col.options = options;
|
|
2289
|
+
delete col.source;
|
|
2290
|
+
}
|
|
2291
|
+
else {
|
|
2292
|
+
delete col.options;
|
|
2293
|
+
if (!col.source) {
|
|
2294
|
+
col.source = 'static';
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
cols[colIndex] = col;
|
|
2298
|
+
commitListColumns(i, cols, false);
|
|
2299
|
+
}
|
|
2300
|
+
function toggleSchemaAdvanced(i) {
|
|
2301
|
+
const row = byId('advanced-' + i);
|
|
2302
|
+
const btn = byId('expand-btn-' + i);
|
|
2303
|
+
const nowOpen = row.style.display === 'none';
|
|
2304
|
+
row.style.display = nowOpen ? '' : 'none';
|
|
2305
|
+
if (btn) {
|
|
2306
|
+
btn.textContent = nowOpen ? '▾' : '▸';
|
|
2307
|
+
btn.setAttribute('aria-expanded', String(nowOpen));
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
function addSchemaRow() {
|
|
2311
|
+
const uid = newUid();
|
|
2312
|
+
schemaState.exists = true;
|
|
2313
|
+
schemaRows.push({
|
|
2314
|
+
name: 'new_variable_' + (schemaRows.length + 1),
|
|
2315
|
+
uid: uid,
|
|
2316
|
+
tag_type: 'text',
|
|
2317
|
+
tag_source: 'static',
|
|
2318
|
+
default_value: '',
|
|
2319
|
+
additional_options: '',
|
|
2320
|
+
description: '',
|
|
2321
|
+
});
|
|
2322
|
+
setDirty(true);
|
|
2323
|
+
render();
|
|
2324
|
+
}
|
|
2325
|
+
function removeSchemaRow(i) {
|
|
2326
|
+
const name = (schemaRows[i] && schemaRows[i].name) || '(unnamed)';
|
|
2327
|
+
showConfirmModal('Delete variable "' + name + '"? This only takes effect once you Save.', () => {
|
|
2328
|
+
schemaRows.splice(i, 1);
|
|
2329
|
+
setDirty(true);
|
|
2330
|
+
render();
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
function schemaRowOpenKey(tag, i) {
|
|
2334
|
+
return tag && tag.uid ? String(tag.uid) : tag && tag.name ? 'name:' + tag.name : 'idx:' + i;
|
|
2335
|
+
}
|
|
2336
|
+
function collectOpenAdvancedKeys() {
|
|
2337
|
+
const open = [];
|
|
2338
|
+
schemaRows.forEach((tag, i) => {
|
|
2339
|
+
const el = byId('advanced-' + i);
|
|
2340
|
+
if (el && el.style.display !== 'none') {
|
|
2341
|
+
open.push(schemaRowOpenKey(tag, i));
|
|
2342
|
+
}
|
|
2343
|
+
});
|
|
2344
|
+
return open;
|
|
2345
|
+
}
|
|
2346
|
+
function restoreOpenAdvancedKeys(openKeys) {
|
|
2347
|
+
if (!openKeys || !openKeys.length) {
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
schemaRows.forEach((tag, i) => {
|
|
2351
|
+
if (openKeys.indexOf(schemaRowOpenKey(tag, i)) === -1) {
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
const after = byId('advanced-' + i);
|
|
2355
|
+
const btn = byId('expand-btn-' + i);
|
|
2356
|
+
if (after) {
|
|
2357
|
+
after.style.display = '';
|
|
2358
|
+
}
|
|
2359
|
+
if (btn) {
|
|
2360
|
+
btn.textContent = '▾';
|
|
2361
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
2362
|
+
}
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
let schemaRowDrag = null;
|
|
2366
|
+
function onSchemaRowDragStart(ev) {
|
|
2367
|
+
const row = ev.target.closest('tr.schema-row');
|
|
2368
|
+
if (!row) {
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
const fromIndex = Number(row.dataset.rowIndex);
|
|
2372
|
+
schemaRowDrag = { fromIndex: fromIndex };
|
|
2373
|
+
row.classList.add('is-dragging');
|
|
2374
|
+
const details = byId('advanced-' + fromIndex);
|
|
2375
|
+
if (details) {
|
|
2376
|
+
details.classList.add('is-dragging');
|
|
2377
|
+
}
|
|
2378
|
+
ev.dataTransfer.effectAllowed = 'move';
|
|
2379
|
+
// Firefox requires setData for the drag to start.
|
|
2380
|
+
ev.dataTransfer.setData('text/plain', String(fromIndex));
|
|
2381
|
+
}
|
|
2382
|
+
function onSchemaRowDragOver(ev, row) {
|
|
2383
|
+
if (!schemaRowDrag) {
|
|
2384
|
+
return;
|
|
2385
|
+
}
|
|
2386
|
+
ev.preventDefault();
|
|
2387
|
+
ev.dataTransfer.dropEffect = 'move';
|
|
2388
|
+
row.classList.add('is-drop-target');
|
|
2389
|
+
}
|
|
2390
|
+
function onSchemaRowDragLeave(ev, row) {
|
|
2391
|
+
if (row.contains(ev.relatedTarget)) {
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
row.classList.remove('is-drop-target');
|
|
2395
|
+
}
|
|
2396
|
+
function onSchemaRowDrop(ev, row) {
|
|
2397
|
+
ev.preventDefault();
|
|
2398
|
+
row.classList.remove('is-drop-target');
|
|
2399
|
+
if (!schemaRowDrag) {
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
const toIndex = Number(row.dataset.rowIndex);
|
|
2403
|
+
const fromIndex = schemaRowDrag.fromIndex;
|
|
2404
|
+
schemaRowDrag = null;
|
|
2405
|
+
if (fromIndex === toIndex) {
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
reorderSchemaRow(fromIndex, toIndex);
|
|
2409
|
+
}
|
|
2410
|
+
function onSchemaRowDragEnd() {
|
|
2411
|
+
schemaRowDrag = null;
|
|
2412
|
+
document
|
|
2413
|
+
.querySelectorAll('tr.schema-row.is-dragging, tr.schema-row.is-drop-target, tr.details-row.is-dragging, tr.details-row.is-drop-target')
|
|
2414
|
+
.forEach((el) => {
|
|
2415
|
+
el.classList.remove('is-dragging', 'is-drop-target');
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2418
|
+
function reorderSchemaRow(fromIndex, toIndex) {
|
|
2419
|
+
if (fromIndex < 0 || fromIndex >= schemaRows.length || toIndex < 0 || toIndex >= schemaRows.length) {
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
const openKeys = collectOpenAdvancedKeys();
|
|
2423
|
+
const moved = schemaRows.splice(fromIndex, 1)[0];
|
|
2424
|
+
schemaRows.splice(toIndex, 0, moved);
|
|
2425
|
+
setDirty(true);
|
|
2426
|
+
render();
|
|
2427
|
+
restoreOpenAdvancedKeys(openKeys);
|
|
2428
|
+
}
|
|
2429
|
+
async function saveSchema() {
|
|
2430
|
+
if (!schemaState) {
|
|
2431
|
+
showBanner('error', 'Cannot save the schema until it has loaded successfully.');
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
let raw;
|
|
2435
|
+
const beforeText = jsonBaseline.schema;
|
|
2436
|
+
if (rawMode) {
|
|
2437
|
+
raw = byId('raw-editor').value;
|
|
2438
|
+
}
|
|
2439
|
+
else {
|
|
2440
|
+
const invalidAdditionalOptionsIndex = schemaRows.findIndex((row) => invalidAdditionalOptionsRows.has(row));
|
|
2441
|
+
if (invalidAdditionalOptionsIndex !== -1) {
|
|
2442
|
+
const tagName = schemaRows[invalidAdditionalOptionsIndex].name || 'row ' + (invalidAdditionalOptionsIndex + 1);
|
|
2443
|
+
showBanner('error', 'additional_options must be valid JSON for "' + escapeHtml(tagName) + '".');
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
raw = JSON.stringify(Object.assign({}, schemaState.schema, { tags: schemaRows }), null, 2);
|
|
2447
|
+
}
|
|
2448
|
+
if (normalizeJsonText(beforeText) === normalizeJsonText(raw)) {
|
|
2449
|
+
setDirty(false);
|
|
2450
|
+
showBanner('success', 'No JSON changes to save.');
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
showJsonDiffModal(beforeText, raw, () => void commitSchemaSave(raw).catch(showSaveError));
|
|
2454
|
+
}
|
|
2455
|
+
// Every way a save can fail ends in a visible error: a lost save must never look like a pending
|
|
2456
|
+
// one. The banner leaves the tab as it is (preserveContent), so the unsaved edits, raw JSON drafts
|
|
2457
|
+
// included, stay on screen and the tab stays dirty.
|
|
2458
|
+
function showSaveError(e) {
|
|
2459
|
+
showBanner('error', 'Save failed: ' + escapeHtml((e instanceof Error && e.message) || String(e)), true);
|
|
2460
|
+
}
|
|
2461
|
+
// PUTs a save and resolves to the server's JSON answer, or shows why the save failed and resolves
|
|
2462
|
+
// to null. A response that isn't JSON (Express' HTML error page, a proxy in between) counts as a
|
|
2463
|
+
// failure too, also with a 2xx status.
|
|
2464
|
+
async function putSave(url, body) {
|
|
2465
|
+
let r;
|
|
2466
|
+
try {
|
|
2467
|
+
r = await fetch(url, {
|
|
2468
|
+
method: 'PUT',
|
|
2469
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2470
|
+
body: JSON.stringify(body),
|
|
2471
|
+
});
|
|
2472
|
+
}
|
|
2473
|
+
catch (e) {
|
|
2474
|
+
showSaveError(e);
|
|
2475
|
+
return null;
|
|
2476
|
+
}
|
|
2477
|
+
const data = (await r.json().catch(() => null));
|
|
2478
|
+
if (r.ok && data && typeof data === 'object') {
|
|
2479
|
+
return data;
|
|
2480
|
+
}
|
|
2481
|
+
let message = (data && data.error) ||
|
|
2482
|
+
(r.ok
|
|
2483
|
+
? 'the dev server sent an answer that is not JSON'
|
|
2484
|
+
: 'the dev server answered HTTP ' + r.status + (r.statusText ? ' ' + r.statusText : ''));
|
|
2485
|
+
if (data && data.details) {
|
|
2486
|
+
message += ' ' + data.details;
|
|
2487
|
+
}
|
|
2488
|
+
showSaveError(new Error(message));
|
|
2489
|
+
return null;
|
|
2490
|
+
}
|
|
2491
|
+
async function commitSchemaSave(raw) {
|
|
2492
|
+
const saveEditSeq = editSeq;
|
|
2493
|
+
const data = await putSave('/@api/variables/schema', { raw });
|
|
2494
|
+
if (!data) {
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
const warnings = data.warnings || [];
|
|
2498
|
+
const hasNewerEdits = editSeq !== saveEditSeq;
|
|
2499
|
+
const message = warnings.length
|
|
2500
|
+
? 'Saved, with warning(s):<ul>' + warnings.map((w) => '<li>' + escapeHtml(w) + '</li>').join('') + '</ul>'
|
|
2501
|
+
: 'Schema saved.';
|
|
2502
|
+
if (hasNewerEdits) {
|
|
2503
|
+
showBanner('warning', message + '<div>Newer edits remain unsaved.</div>', true);
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
setDirty(false);
|
|
2507
|
+
showBanner(warnings.length ? 'warning' : 'success', message);
|
|
2508
|
+
jsonBaseline.schema = raw;
|
|
2509
|
+
await loadTabData('schema');
|
|
2510
|
+
render();
|
|
2511
|
+
}
|
|
2512
|
+
// ── Values tab ───────────────────────────────────────────────────────────────
|
|
2513
|
+
function onComboFocus(i) {
|
|
2514
|
+
const list = byId('combo-list-' + i);
|
|
2515
|
+
if (list) {
|
|
2516
|
+
list.style.display = '';
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
function onComboBlur(i) {
|
|
2520
|
+
// mousedown+preventDefault() on options already stops most blurs from firing before a pick
|
|
2521
|
+
// registers; this is just a safety net for focus leaving for any other reason (e.g. Tab).
|
|
2522
|
+
setTimeout(() => {
|
|
2523
|
+
const list = byId('combo-list-' + i);
|
|
2524
|
+
if (list && !stopped) {
|
|
2525
|
+
list.style.display = 'none';
|
|
2526
|
+
}
|
|
2527
|
+
}, 150);
|
|
2528
|
+
}
|
|
2529
|
+
function onComboFilter(i, text) {
|
|
2530
|
+
const list = byId('combo-list-' + i);
|
|
2531
|
+
if (!list) {
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
const needle = text.toLowerCase();
|
|
2535
|
+
Array.from(list.children).forEach((opt) => {
|
|
2536
|
+
const hay = (opt.dataset.id + ' ' + opt.dataset.text).toLowerCase();
|
|
2537
|
+
opt.style.display = hay.indexOf(needle) === -1 ? 'none' : '';
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
function onSingleComboInput(i, text) {
|
|
2541
|
+
onComboFilter(i, text);
|
|
2542
|
+
updateValueField(i, text);
|
|
2543
|
+
}
|
|
2544
|
+
function onComboOptionPick(optEl) {
|
|
2545
|
+
const list = optEl.closest('.ve-combo-list');
|
|
2546
|
+
const i = Number(list.id.replace('combo-list-', ''));
|
|
2547
|
+
const id = optEl.dataset.id;
|
|
2548
|
+
const text = optEl.dataset.text || id;
|
|
2549
|
+
const combo = optEl.closest('.ve-combo');
|
|
2550
|
+
const chipsEl = combo.querySelector('.ve-combo-chips');
|
|
2551
|
+
const inputEl = combo.querySelector('.ve-combo-input');
|
|
2552
|
+
if (chipsEl) {
|
|
2553
|
+
const already = Array.from(chipsEl.children).some((c) => c.dataset.id === id);
|
|
2554
|
+
if (!already) {
|
|
2555
|
+
chipsEl.insertAdjacentHTML('beforeend', chipHtml(id, text));
|
|
2556
|
+
}
|
|
2557
|
+
inputEl.value = '';
|
|
2558
|
+
onComboFilter(i, '');
|
|
2559
|
+
commitMultiValue(i, chipsEl);
|
|
2560
|
+
}
|
|
2561
|
+
else {
|
|
2562
|
+
inputEl.value = id;
|
|
2563
|
+
updateValueField(i, id);
|
|
2564
|
+
list.style.display = 'none';
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
function onChipRemove(btn) {
|
|
2568
|
+
const chip = btn.closest('.ve-chip');
|
|
2569
|
+
const chipsEl = chip.parentElement;
|
|
2570
|
+
const i = Number(chipsEl.dataset.row);
|
|
2571
|
+
chip.remove();
|
|
2572
|
+
commitMultiValue(i, chipsEl);
|
|
2573
|
+
}
|
|
2574
|
+
function commitMultiValue(i, chipsEl) {
|
|
2575
|
+
const ids = Array.from(chipsEl.children).map((c) => c.dataset.id);
|
|
2576
|
+
updateValueField(i, ids.join(','));
|
|
2577
|
+
}
|
|
2578
|
+
function onListItemSelectChange(i, itemIndex, fieldName, selectEl) {
|
|
2579
|
+
const value = selectEl.multiple
|
|
2580
|
+
? Array.from(selectEl.selectedOptions)
|
|
2581
|
+
.map((o) => o.value)
|
|
2582
|
+
.join(',')
|
|
2583
|
+
: selectEl.value;
|
|
2584
|
+
updateListItemField(i, itemIndex, fieldName, value);
|
|
2585
|
+
}
|
|
2586
|
+
// `fieldName` is the item's column, `null` for an item of a flat list of strings.
|
|
2587
|
+
function updateListItemField(i, itemIndex, fieldName, value) {
|
|
2588
|
+
const items = parseListItems(valueRows[i]) || [];
|
|
2589
|
+
if (fieldName === null) {
|
|
2590
|
+
items[itemIndex] = value;
|
|
2591
|
+
}
|
|
2592
|
+
else {
|
|
2593
|
+
if (typeof items[itemIndex] !== 'object' || items[itemIndex] === null || Array.isArray(items[itemIndex])) {
|
|
2594
|
+
items[itemIndex] = {};
|
|
2595
|
+
}
|
|
2596
|
+
items[itemIndex][fieldName] = value;
|
|
2597
|
+
}
|
|
2598
|
+
valueRows[i].value = JSON.stringify(items);
|
|
2599
|
+
setDirty(true);
|
|
2600
|
+
}
|
|
2601
|
+
function addListItem(i) {
|
|
2602
|
+
const items = parseListItems(valueRows[i]) || [];
|
|
2603
|
+
const config = listConfigFor(schemaTagFor(valuesState, valueRows[i].name));
|
|
2604
|
+
if (config) {
|
|
2605
|
+
const newItem = {};
|
|
2606
|
+
config.forEach((col) => {
|
|
2607
|
+
newItem[String(col.name)] = '';
|
|
2608
|
+
});
|
|
2609
|
+
items.push(newItem);
|
|
2610
|
+
}
|
|
2611
|
+
else {
|
|
2612
|
+
items.push('');
|
|
2613
|
+
}
|
|
2614
|
+
valueRows[i].value = JSON.stringify(items);
|
|
2615
|
+
selectedValueIndex = i;
|
|
2616
|
+
setDirty(true);
|
|
2617
|
+
render();
|
|
2618
|
+
}
|
|
2619
|
+
function removeListItem(i, itemIndex) {
|
|
2620
|
+
const items = parseListItems(valueRows[i]) || [];
|
|
2621
|
+
items.splice(itemIndex, 1);
|
|
2622
|
+
valueRows[i].value = JSON.stringify(items);
|
|
2623
|
+
selectedValueIndex = i;
|
|
2624
|
+
setDirty(true);
|
|
2625
|
+
render();
|
|
2626
|
+
}
|
|
2627
|
+
let listItemDrag = null;
|
|
2628
|
+
function onListItemDragStart(ev) {
|
|
2629
|
+
const item = ev.target.closest('.ve-values-list-item');
|
|
2630
|
+
if (!item) {
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
listItemDrag = {
|
|
2634
|
+
valueIndex: Number(item.dataset.valueIndex),
|
|
2635
|
+
fromIndex: Number(item.dataset.itemIndex),
|
|
2636
|
+
};
|
|
2637
|
+
item.classList.add('is-dragging');
|
|
2638
|
+
ev.dataTransfer.effectAllowed = 'move';
|
|
2639
|
+
// Firefox requires setData for the drag to start.
|
|
2640
|
+
ev.dataTransfer.setData('text/plain', String(listItemDrag.fromIndex));
|
|
2641
|
+
}
|
|
2642
|
+
function onListItemDragOver(ev, item) {
|
|
2643
|
+
if (!listItemDrag) {
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
if (Number(item.dataset.valueIndex) !== listItemDrag.valueIndex) {
|
|
2647
|
+
return;
|
|
2648
|
+
}
|
|
2649
|
+
ev.preventDefault();
|
|
2650
|
+
ev.dataTransfer.dropEffect = 'move';
|
|
2651
|
+
item.classList.add('is-drop-target');
|
|
2652
|
+
}
|
|
2653
|
+
function onListItemDragLeave(ev, item) {
|
|
2654
|
+
// Ignore leave events that stay inside the same item card (child elements).
|
|
2655
|
+
if (item.contains(ev.relatedTarget)) {
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
item.classList.remove('is-drop-target');
|
|
2659
|
+
}
|
|
2660
|
+
function onListItemDrop(ev, item) {
|
|
2661
|
+
ev.preventDefault();
|
|
2662
|
+
item.classList.remove('is-drop-target');
|
|
2663
|
+
if (!listItemDrag || Number(item.dataset.valueIndex) !== listItemDrag.valueIndex) {
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
const toIndex = Number(item.dataset.itemIndex);
|
|
2667
|
+
const fromIndex = listItemDrag.fromIndex;
|
|
2668
|
+
listItemDrag = null;
|
|
2669
|
+
if (fromIndex === toIndex) {
|
|
2670
|
+
return;
|
|
2671
|
+
}
|
|
2672
|
+
reorderListItem(Number(item.dataset.valueIndex), fromIndex, toIndex);
|
|
2673
|
+
}
|
|
2674
|
+
function onListItemDragEnd() {
|
|
2675
|
+
listItemDrag = null;
|
|
2676
|
+
document.querySelectorAll('.ve-values-list-item.is-dragging, .ve-values-list-item.is-drop-target').forEach((el) => {
|
|
2677
|
+
el.classList.remove('is-dragging', 'is-drop-target');
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
function reorderListItem(i, fromIndex, toIndex) {
|
|
2681
|
+
const items = parseListItems(valueRows[i]) || [];
|
|
2682
|
+
if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex >= items.length) {
|
|
2683
|
+
return;
|
|
2684
|
+
}
|
|
2685
|
+
const moved = items.splice(fromIndex, 1)[0];
|
|
2686
|
+
items.splice(toIndex, 0, moved);
|
|
2687
|
+
valueRows[i].value = JSON.stringify(items);
|
|
2688
|
+
selectedValueIndex = i;
|
|
2689
|
+
setDirty(true);
|
|
2690
|
+
render();
|
|
2691
|
+
}
|
|
2692
|
+
function refreshValuesJsonIssues() {
|
|
2693
|
+
const editorEl = byId('values-raw-editor');
|
|
2694
|
+
const issuesEl = byId('values-raw-issues');
|
|
2695
|
+
if (!editorEl || !issuesEl) {
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
issuesEl.innerHTML = renderJsonIssuesBar(editorEl.value, valuesState);
|
|
2699
|
+
}
|
|
2700
|
+
let valuesRawIssuesTimer = null;
|
|
2701
|
+
function onValuesRawInput() {
|
|
2702
|
+
setDirty(true);
|
|
2703
|
+
if (valuesRawIssuesTimer) {
|
|
2704
|
+
clearTimeout(valuesRawIssuesTimer);
|
|
2705
|
+
}
|
|
2706
|
+
valuesRawIssuesTimer = setTimeout(refreshValuesJsonIssues, 300);
|
|
2707
|
+
}
|
|
2708
|
+
function downloadValuesJson() {
|
|
2709
|
+
const editorEl = byId('values-raw-editor');
|
|
2710
|
+
const text = editorEl ? editorEl.value : JSON.stringify(toExportableValueRows(valueRows, valuesState), null, 2);
|
|
2711
|
+
const blob = new Blob([text], { type: 'application/json' });
|
|
2712
|
+
const url = URL.createObjectURL(blob);
|
|
2713
|
+
const a = document.createElement('a');
|
|
2714
|
+
a.href = url;
|
|
2715
|
+
a.download = 'page-variables.json';
|
|
2716
|
+
document.body.appendChild(a);
|
|
2717
|
+
a.click();
|
|
2718
|
+
a.remove();
|
|
2719
|
+
URL.revokeObjectURL(url);
|
|
2720
|
+
}
|
|
2721
|
+
function triggerImportValuesJson() {
|
|
2722
|
+
byId('import-values-file').click();
|
|
2723
|
+
}
|
|
2724
|
+
function onImportValuesFile(inputEl) {
|
|
2725
|
+
const file = inputEl.files?.[0];
|
|
2726
|
+
if (!file) {
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
const reader = new FileReader();
|
|
2730
|
+
reader.onload = () => {
|
|
2731
|
+
const editorEl = byId('values-raw-editor');
|
|
2732
|
+
if (editorEl) {
|
|
2733
|
+
editorEl.value = String(reader.result || '');
|
|
2734
|
+
setDirty(true);
|
|
2735
|
+
refreshValuesJsonIssues();
|
|
2736
|
+
}
|
|
2737
|
+
};
|
|
2738
|
+
reader.readAsText(file);
|
|
2739
|
+
inputEl.value = '';
|
|
2740
|
+
}
|
|
2741
|
+
function updateValueNavActive(i) {
|
|
2742
|
+
document
|
|
2743
|
+
.querySelectorAll('.ve-values-nav-list .ve-values-nav-item[data-value-index]')
|
|
2744
|
+
.forEach((el) => {
|
|
2745
|
+
const active = Number(el.dataset.valueIndex) === i;
|
|
2746
|
+
el.classList.toggle('is-active', active);
|
|
2747
|
+
if (active) {
|
|
2748
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
2749
|
+
}
|
|
2750
|
+
});
|
|
2751
|
+
document.querySelectorAll('.ve-values-anchor[data-value-index]').forEach((el) => {
|
|
2752
|
+
el.classList.toggle('is-active', Number(el.dataset.valueIndex) === i);
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2755
|
+
function scrollToValueRow(i, smooth) {
|
|
2756
|
+
const detail = document.querySelector('.ve-values-detail');
|
|
2757
|
+
const target = byId('value-item-' + i);
|
|
2758
|
+
if (!detail || !target || typeof detail.scrollTo !== 'function') {
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
const top = target.getBoundingClientRect().top - detail.getBoundingClientRect().top + detail.scrollTop;
|
|
2762
|
+
detail.scrollTo({ top: Math.max(0, top - 8), behavior: smooth ? 'smooth' : 'auto' });
|
|
2763
|
+
}
|
|
2764
|
+
let valueNavScrollLock = false;
|
|
2765
|
+
let valueNavScrollLockTimer = null;
|
|
2766
|
+
function selectValueRow(i) {
|
|
2767
|
+
selectedValueIndex = i;
|
|
2768
|
+
updateValueNavActive(i);
|
|
2769
|
+
valueNavScrollLock = true;
|
|
2770
|
+
if (valueNavScrollLockTimer) {
|
|
2771
|
+
clearTimeout(valueNavScrollLockTimer);
|
|
2772
|
+
}
|
|
2773
|
+
scrollToValueRow(i, true);
|
|
2774
|
+
valueNavScrollLockTimer = setTimeout(function () {
|
|
2775
|
+
valueNavScrollLock = false;
|
|
2776
|
+
valueNavScrollLockTimer = null;
|
|
2777
|
+
}, 500);
|
|
2778
|
+
}
|
|
2779
|
+
function syncValueNavFromScroll() {
|
|
2780
|
+
if (valueNavScrollLock) {
|
|
2781
|
+
return;
|
|
2782
|
+
}
|
|
2783
|
+
const detail = document.querySelector('.ve-values-detail');
|
|
2784
|
+
if (!detail) {
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
const anchors = detail.querySelectorAll('.ve-values-anchor[data-value-index]');
|
|
2788
|
+
if (!anchors.length) {
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
const nearBottom = detail.scrollTop + detail.clientHeight >= detail.scrollHeight - 4;
|
|
2792
|
+
let current;
|
|
2793
|
+
if (nearBottom) {
|
|
2794
|
+
current = Number(anchors[anchors.length - 1].dataset.valueIndex);
|
|
2795
|
+
}
|
|
2796
|
+
else {
|
|
2797
|
+
const threshold = detail.getBoundingClientRect().top + 24;
|
|
2798
|
+
current = Number(anchors[0].dataset.valueIndex) || 0;
|
|
2799
|
+
anchors.forEach(function (el) {
|
|
2800
|
+
if (el.getBoundingClientRect().top <= threshold) {
|
|
2801
|
+
current = Number(el.dataset.valueIndex);
|
|
2802
|
+
}
|
|
2803
|
+
});
|
|
2804
|
+
}
|
|
2805
|
+
if (current !== selectedValueIndex) {
|
|
2806
|
+
selectedValueIndex = current;
|
|
2807
|
+
updateValueNavActive(current);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
function bindValuesDetailScroll() {
|
|
2811
|
+
const detail = document.querySelector('.ve-values-detail');
|
|
2812
|
+
if (!detail || detail.dataset.scrollBound === '1') {
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2815
|
+
detail.dataset.scrollBound = '1';
|
|
2816
|
+
detail.addEventListener('scroll', syncValueNavFromScroll, { passive: true });
|
|
2817
|
+
}
|
|
2818
|
+
function renderValuesTab() {
|
|
2819
|
+
if (valuesState && !valuesRawMode && valueRows.length) {
|
|
2820
|
+
if (selectedValueIndex < 0 || selectedValueIndex >= valueRows.length) {
|
|
2821
|
+
selectedValueIndex = 0;
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
return renderValuesTabHtml(valuesState, valueRows, valuesRawMode, selectedValueIndex);
|
|
2825
|
+
}
|
|
2826
|
+
function toggleValuesRawMode() {
|
|
2827
|
+
if (valuesRawMode) {
|
|
2828
|
+
// Leaving raw mode: try to parse back into rows; keep raw mode on failure. The value field
|
|
2829
|
+
// may be a native JSON array (list-type) as well as a plain string — see toExportableValueRows().
|
|
2830
|
+
const editorEl = byId('values-raw-editor');
|
|
2831
|
+
const editorText = editorEl ? editorEl.value : JSON.stringify(toExportableValueRows(valueRows, valuesState));
|
|
2832
|
+
try {
|
|
2833
|
+
const parsed = JSON.parse(editorText);
|
|
2834
|
+
if (!Array.isArray(parsed) ||
|
|
2835
|
+
parsed.some((e) => {
|
|
2836
|
+
const entry = e;
|
|
2837
|
+
return (typeof entry !== 'object' ||
|
|
2838
|
+
entry === null ||
|
|
2839
|
+
typeof entry.name !== 'string' ||
|
|
2840
|
+
entry.value === undefined ||
|
|
2841
|
+
entry.value === null);
|
|
2842
|
+
})) {
|
|
2843
|
+
throw new Error('Must be an array of { name, value } entries with non-empty names and a defined value.');
|
|
2844
|
+
}
|
|
2845
|
+
valueRows = fromExportableValueRows(parsed);
|
|
2846
|
+
}
|
|
2847
|
+
catch (e) {
|
|
2848
|
+
// showBanner() re-renders, which would otherwise regenerate this textarea from the last
|
|
2849
|
+
// known-good valueRows — put the user's (still-broken) text back afterward so their
|
|
2850
|
+
// in-progress edit isn't silently discarded.
|
|
2851
|
+
showBanner('error', 'Cannot switch to table view: ' + escapeHtml(errorMessage(e)));
|
|
2852
|
+
const freshEditorEl = byId('values-raw-editor');
|
|
2853
|
+
if (freshEditorEl) {
|
|
2854
|
+
freshEditorEl.value = editorText;
|
|
2855
|
+
}
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
valuesRawMode = !valuesRawMode;
|
|
2860
|
+
setUrlState(activeTab, valuesRawMode);
|
|
2861
|
+
render();
|
|
2862
|
+
}
|
|
2863
|
+
function updateValueField(i, value) {
|
|
2864
|
+
valueRows[i].value = value;
|
|
2865
|
+
setDirty(true);
|
|
2866
|
+
}
|
|
2867
|
+
// ── Values tab: "Import from page…" ─────────────────────────────────────────
|
|
2868
|
+
// Two-step flow, both steps rendered into the same overlay element (only its innerHTML swaps)
|
|
2869
|
+
// so the outer overlay's click-outside/Escape handlers, bound once here, keep working through
|
|
2870
|
+
// "← Back". Nothing is written to MI until the user then hits the regular Save button.
|
|
2871
|
+
function openImportValuesModal() {
|
|
2872
|
+
closeImportModal();
|
|
2873
|
+
const overlay = document.createElement('div');
|
|
2874
|
+
overlay.className = 've-modal-overlay';
|
|
2875
|
+
overlay.id = 've-import-overlay';
|
|
2876
|
+
overlay.addEventListener('click', (ev) => {
|
|
2877
|
+
if (ev.target === overlay) {
|
|
2878
|
+
closeImportModal();
|
|
2879
|
+
}
|
|
2880
|
+
});
|
|
2881
|
+
overlay.addEventListener('keydown', (ev) => {
|
|
2882
|
+
if (ev.key === 'Escape') {
|
|
2883
|
+
ev.preventDefault();
|
|
2884
|
+
closeImportModal();
|
|
2885
|
+
}
|
|
2886
|
+
});
|
|
2887
|
+
document.body.appendChild(overlay);
|
|
2888
|
+
void renderImportPickerStep(overlay);
|
|
2889
|
+
}
|
|
2890
|
+
function closeImportModal() {
|
|
2891
|
+
const overlay = byId('ve-import-overlay');
|
|
2892
|
+
if (overlay) {
|
|
2893
|
+
overlay.remove();
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
function backToImportPicker() {
|
|
2897
|
+
const overlay = byId('ve-import-overlay');
|
|
2898
|
+
if (overlay) {
|
|
2899
|
+
void renderImportPickerStep(overlay);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
async function renderImportPickerStep(overlay) {
|
|
2903
|
+
overlay.innerHTML = importPickerStepHtml();
|
|
2904
|
+
let r;
|
|
2905
|
+
try {
|
|
2906
|
+
r = await fetch('/@api/variables/pages');
|
|
2907
|
+
}
|
|
2908
|
+
catch (e) {
|
|
2909
|
+
setImportBodyError(overlay, errorMessage(e));
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
const data = (await r.json().catch(() => ({})));
|
|
2913
|
+
if (stopped || !overlay.isConnected) {
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
if (!r.ok) {
|
|
2917
|
+
setImportBodyError(overlay, data.error || 'Failed to load the page list.');
|
|
2918
|
+
return;
|
|
2919
|
+
}
|
|
2920
|
+
const bodyEl = overlay.querySelector('.ve-modal-body');
|
|
2921
|
+
if (!bodyEl) {
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
bodyEl.innerHTML = importPickerBodyHtml(data.pages || []);
|
|
2925
|
+
const filterEl = byId('ve-import-filter');
|
|
2926
|
+
if (filterEl) {
|
|
2927
|
+
filterEl.focus();
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
function setImportBodyError(overlay, message) {
|
|
2931
|
+
if (stopped || !overlay.isConnected) {
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
const bodyEl = overlay.querySelector('.ve-modal-body');
|
|
2935
|
+
if (bodyEl) {
|
|
2936
|
+
bodyEl.innerHTML = importErrorHtml(message);
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
function filterImportPages(text) {
|
|
2940
|
+
const needle = text.trim().toLowerCase();
|
|
2941
|
+
let visible = 0;
|
|
2942
|
+
document.querySelectorAll('.ve-import-page-row').forEach(function (row) {
|
|
2943
|
+
const match = !needle || row.dataset.search.indexOf(needle) !== -1;
|
|
2944
|
+
row.style.display = match ? '' : 'none';
|
|
2945
|
+
if (match) {
|
|
2946
|
+
visible++;
|
|
2947
|
+
}
|
|
2948
|
+
});
|
|
2949
|
+
document.querySelectorAll('.ve-import-page-group').forEach(function (group) {
|
|
2950
|
+
const anyVisible = Array.from(group.querySelectorAll('.ve-import-page-row')).some(function (row) {
|
|
2951
|
+
return row.style.display !== 'none';
|
|
2952
|
+
});
|
|
2953
|
+
group.style.display = anyVisible ? '' : 'none';
|
|
2954
|
+
});
|
|
2955
|
+
const emptyEl = byId('ve-import-page-empty');
|
|
2956
|
+
if (emptyEl) {
|
|
2957
|
+
emptyEl.style.display = visible === 0 ? '' : 'none';
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
async function selectImportSourcePage(pageId, pageName) {
|
|
2961
|
+
const overlay = byId('ve-import-overlay');
|
|
2962
|
+
if (!overlay) {
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
overlay.dataset.sourcePageId = String(pageId);
|
|
2966
|
+
overlay.innerHTML = importReviewStepHtml(pageName);
|
|
2967
|
+
let r;
|
|
2968
|
+
try {
|
|
2969
|
+
r = await fetch('/@api/variables/import-plan/' + pageId);
|
|
2970
|
+
}
|
|
2971
|
+
catch (e) {
|
|
2972
|
+
setImportBodyError(overlay, errorMessage(e));
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
const data = (await r.json().catch(() => ({})));
|
|
2976
|
+
// A newer pick (or Back) landed before this one resolved — drop the stale response.
|
|
2977
|
+
if (stopped || !overlay.isConnected || overlay.dataset.sourcePageId !== String(pageId)) {
|
|
2978
|
+
return;
|
|
2979
|
+
}
|
|
2980
|
+
if (!r.ok) {
|
|
2981
|
+
setImportBodyError(overlay, data.error || 'Failed to load values from this page.');
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
const bodyEl = overlay.querySelector('.ve-modal-body');
|
|
2985
|
+
const actionsEl = overlay.querySelector('#ve-import-actions');
|
|
2986
|
+
if (!bodyEl || !actionsEl) {
|
|
2987
|
+
return;
|
|
2988
|
+
}
|
|
2989
|
+
importReviewCandidates = data.importable || [];
|
|
2990
|
+
importReviewPageName = pageName;
|
|
2991
|
+
bodyEl.innerHTML = importReviewBodyHtml(data);
|
|
2992
|
+
actionsEl.innerHTML = importReviewActionsHtml(data);
|
|
2993
|
+
const applyBtn = byId('ve-import-apply-btn');
|
|
2994
|
+
if (applyBtn) {
|
|
2995
|
+
applyBtn.focus();
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
function toggleImportAll(checked) {
|
|
2999
|
+
document.querySelectorAll('.ve-import-check').forEach(function (c) {
|
|
3000
|
+
c.checked = checked;
|
|
3001
|
+
});
|
|
3002
|
+
updateImportCount();
|
|
3003
|
+
}
|
|
3004
|
+
function updateImportCount() {
|
|
3005
|
+
const checks = document.querySelectorAll('.ve-import-check');
|
|
3006
|
+
const checkedCount = document.querySelectorAll('.ve-import-check:checked').length;
|
|
3007
|
+
const countEl = byId('ve-import-count');
|
|
3008
|
+
const selectAllEl = byId('ve-import-select-all');
|
|
3009
|
+
const applyBtn = byId('ve-import-apply-btn');
|
|
3010
|
+
if (countEl) {
|
|
3011
|
+
countEl.textContent = checkedCount + ' of ' + checks.length + ' selected';
|
|
3012
|
+
}
|
|
3013
|
+
if (selectAllEl) {
|
|
3014
|
+
selectAllEl.checked = checkedCount === checks.length;
|
|
3015
|
+
}
|
|
3016
|
+
if (applyBtn) {
|
|
3017
|
+
applyBtn.textContent = 'Apply ' + checkedCount + ' selected';
|
|
3018
|
+
applyBtn.disabled = checkedCount === 0;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
function applyImportPlan() {
|
|
3022
|
+
const checkedIndexes = Array.from(document.querySelectorAll('.ve-import-check:checked')).map(function (c) {
|
|
3023
|
+
return Number(c.dataset.index);
|
|
3024
|
+
});
|
|
3025
|
+
let applied = 0;
|
|
3026
|
+
checkedIndexes.forEach((idx) => {
|
|
3027
|
+
const candidate = importReviewCandidates[idx];
|
|
3028
|
+
if (!candidate) {
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
const row = valueRows.find((r) => r.name === candidate.name);
|
|
3032
|
+
if (!row) {
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
row.value = candidate.sourceValue;
|
|
3036
|
+
applied++;
|
|
3037
|
+
});
|
|
3038
|
+
const total = importReviewCandidates.length;
|
|
3039
|
+
const pageName = importReviewPageName;
|
|
3040
|
+
closeImportModal();
|
|
3041
|
+
if (!applied) {
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
setDirty(true);
|
|
3045
|
+
showBanner('success', 'Imported ' + applied + ' of ' + total + ' variable(s) from "' + escapeHtml(pageName) + '". Save to apply.');
|
|
3046
|
+
}
|
|
3047
|
+
function addValueRow() {
|
|
3048
|
+
showCustomValueModal('Variable name', '', (name) => {
|
|
3049
|
+
if (!name) {
|
|
3050
|
+
return;
|
|
3051
|
+
}
|
|
3052
|
+
valueRows.push({ name, value: '' });
|
|
3053
|
+
selectedValueIndex = valueRows.length - 1;
|
|
3054
|
+
setDirty(true);
|
|
3055
|
+
render();
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
function removeValueRow(i) {
|
|
3059
|
+
const name = (valueRows[i] && valueRows[i].name) || '(unnamed)';
|
|
3060
|
+
showConfirmModal('Delete variable "' + name + '"? This only takes effect once you Save.', () => {
|
|
3061
|
+
valueRows.splice(i, 1);
|
|
3062
|
+
selectedValueIndex = Math.min(i, Math.max(0, valueRows.length - 1));
|
|
3063
|
+
setDirty(true);
|
|
3064
|
+
render();
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
async function saveValues() {
|
|
3068
|
+
if (!valuesState) {
|
|
3069
|
+
showBanner('error', 'Cannot save values until live variables have loaded successfully.');
|
|
3070
|
+
return;
|
|
3071
|
+
}
|
|
3072
|
+
let tags = valueRows;
|
|
3073
|
+
const beforeText = jsonBaseline.values;
|
|
3074
|
+
if (valuesRawMode) {
|
|
3075
|
+
try {
|
|
3076
|
+
// The textarea may contain native JSON arrays for list-type values (see
|
|
3077
|
+
// toExportableValueRows()) — convert back to MI's plain-string form before sending.
|
|
3078
|
+
tags = fromExportableValueRows(JSON.parse(byId('values-raw-editor').value));
|
|
3079
|
+
}
|
|
3080
|
+
catch (e) {
|
|
3081
|
+
showBanner('error', 'Raw JSON is not valid: ' + escapeHtml(errorMessage(e)));
|
|
3082
|
+
return;
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
const afterText = snapshotValuesJson(tags);
|
|
3086
|
+
if (normalizeJsonText(beforeText) === normalizeJsonText(afterText)) {
|
|
3087
|
+
setDirty(false);
|
|
3088
|
+
showBanner('success', 'No JSON changes to save.');
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
showJsonDiffModal(beforeText, afterText, () => void commitValuesSave(tags, afterText).catch(showSaveError));
|
|
3092
|
+
}
|
|
3093
|
+
async function commitValuesSave(tags, afterText) {
|
|
3094
|
+
const saveEditSeq = editSeq;
|
|
3095
|
+
const data = await putSave('/@api/variables/values', { tags });
|
|
3096
|
+
if (!data) {
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
const warnings = data.warnings || [];
|
|
3100
|
+
const hasNewerEdits = editSeq !== saveEditSeq;
|
|
3101
|
+
// The local pages are updated with the save; if that failed, the floating button offers it again.
|
|
3102
|
+
const localNote = data.localError
|
|
3103
|
+
? '<div>The local pages still show the old values: ' +
|
|
3104
|
+
escapeHtml(data.localError) +
|
|
3105
|
+
'. Use <b>Update local pages</b>.</div>'
|
|
3106
|
+
: '';
|
|
3107
|
+
const message = (warnings.length
|
|
3108
|
+
? 'Saved, with warning(s):<ul>' +
|
|
3109
|
+
warnings.map((w) => '<li>' + escapeHtml(w.message) + '</li>').join('') +
|
|
3110
|
+
'</ul>'
|
|
3111
|
+
: 'Values saved.') + localNote;
|
|
3112
|
+
// MI holds the saved values now, and the local pages what the save updated them to: compare
|
|
3113
|
+
// those, also when newer edits keep the tab from reloading below.
|
|
3114
|
+
if (valuesState) {
|
|
3115
|
+
valuesState.live = tags.map((t) => ({ name: t.name, value: t.value }));
|
|
3116
|
+
if (data.local !== undefined) {
|
|
3117
|
+
valuesState.local = data.local;
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
renderLocalUpdateButton();
|
|
3121
|
+
if (hasNewerEdits) {
|
|
3122
|
+
showBanner('warning', message + '<div>Newer edits remain unsaved.</div>', true);
|
|
3123
|
+
return;
|
|
3124
|
+
}
|
|
3125
|
+
setDirty(false);
|
|
3126
|
+
showBanner(warnings.length ? 'warning' : 'success', message);
|
|
3127
|
+
jsonBaseline.values = afterText;
|
|
3128
|
+
await loadTabData('values');
|
|
3129
|
+
render();
|
|
3130
|
+
}
|
|
3131
|
+
function save() {
|
|
3132
|
+
(activeTab === 'schema' ? saveSchema() : saveValues()).catch(showSaveError);
|
|
3133
|
+
}
|
|
3134
|
+
// ── Local pages ──────────────────────────────────────────────────────────────
|
|
3135
|
+
// The dev server substitutes page variables into the pages it serves (to every user) from its own
|
|
3136
|
+
// copy, loaded from MI with the page data. A save updates it; a change made in MI itself doesn't,
|
|
3137
|
+
// so the Values tab compares that copy with MI and offers to update it.
|
|
3138
|
+
function localDifferences() {
|
|
3139
|
+
if (!valuesState || !Array.isArray(valuesState.local) || !Array.isArray(valuesState.live)) {
|
|
3140
|
+
return [];
|
|
3141
|
+
}
|
|
3142
|
+
// A missing variable differs from an empty one: the page keeps a missing one's [VarName] as is,
|
|
3143
|
+
// and drops an empty one's.
|
|
3144
|
+
const local = new Map(valuesState.local.map((e) => [e.name, String(e.value ?? '')]));
|
|
3145
|
+
const live = new Map(valuesState.live.map((e) => [e.name, String(e.value ?? '')]));
|
|
3146
|
+
const names = Array.from(new Set([...local.keys(), ...live.keys()]));
|
|
3147
|
+
return names
|
|
3148
|
+
.filter((name) => local.get(name) !== live.get(name))
|
|
3149
|
+
.map((name) => ({ name, local: local.get(name), live: live.get(name) }));
|
|
3150
|
+
}
|
|
3151
|
+
function shortValue(value) {
|
|
3152
|
+
if (value === undefined) {
|
|
3153
|
+
return '(missing)';
|
|
3154
|
+
}
|
|
3155
|
+
if (!value) {
|
|
3156
|
+
return '(empty)';
|
|
3157
|
+
}
|
|
3158
|
+
return value.length > 40 ? value.slice(0, 40) + '…' : value;
|
|
3159
|
+
}
|
|
3160
|
+
function renderLocalUpdateButton() {
|
|
3161
|
+
const btn = byId('local-update-btn');
|
|
3162
|
+
if (!btn || stopped) {
|
|
3163
|
+
return;
|
|
3164
|
+
}
|
|
3165
|
+
const diffs = activeTab === 'values' ? localDifferences() : [];
|
|
3166
|
+
btn.hidden = diffs.length === 0;
|
|
3167
|
+
if (!diffs.length) {
|
|
3168
|
+
return;
|
|
3169
|
+
}
|
|
3170
|
+
const lineBreak = String.fromCharCode(10);
|
|
3171
|
+
btn.textContent = 'Update local pages · ' + diffs.length;
|
|
3172
|
+
btn.title = ['The local dev server still shows older values (local → MI):']
|
|
3173
|
+
.concat(diffs.map((d) => d.name + ': ' + shortValue(d.local) + ' → ' + shortValue(d.live)))
|
|
3174
|
+
.join(lineBreak);
|
|
3175
|
+
}
|
|
3176
|
+
async function updateLocalValues() {
|
|
3177
|
+
const btn = byId('local-update-btn');
|
|
3178
|
+
btn.disabled = true;
|
|
3179
|
+
try {
|
|
3180
|
+
const r = await fetch('/@api/variables/values/local', { method: 'POST' });
|
|
3181
|
+
const data = (await r.json().catch(() => ({})));
|
|
3182
|
+
// The banners leave the tab as it is (preserveContent): an unsaved draft, raw JSON included,
|
|
3183
|
+
// must survive updating the local pages.
|
|
3184
|
+
if (!r.ok) {
|
|
3185
|
+
showBanner('error', escapeHtml(data.error || 'Failed to update the local values.') +
|
|
3186
|
+
(data.details ? ' ' + escapeHtml(data.details) : ''), true);
|
|
3187
|
+
return;
|
|
3188
|
+
}
|
|
3189
|
+
if (valuesState) {
|
|
3190
|
+
// Just reloaded from MI: they are MI's latest values too, as far as the comparison goes.
|
|
3191
|
+
valuesState.local = data.local;
|
|
3192
|
+
valuesState.live = data.local;
|
|
3193
|
+
}
|
|
3194
|
+
showBanner('success', 'Local pages updated: every tab shows the new values once reloaded.', true);
|
|
3195
|
+
}
|
|
3196
|
+
catch (e) {
|
|
3197
|
+
showBanner('error', 'Failed to update the local values: ' + escapeHtml(errorMessage(e)), true);
|
|
3198
|
+
}
|
|
3199
|
+
finally {
|
|
3200
|
+
if (!stopped) {
|
|
3201
|
+
btn.disabled = false;
|
|
3202
|
+
}
|
|
3203
|
+
renderLocalUpdateButton();
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
// ── Render ───────────────────────────────────────────────────────────────────
|
|
3207
|
+
function render() {
|
|
3208
|
+
if (stopped) {
|
|
3209
|
+
return;
|
|
3210
|
+
}
|
|
3211
|
+
const bannerHtml = banner ? '<div class="banner banner-' + banner.type + '">' + banner.html + '</div>' : '';
|
|
3212
|
+
contentEl.innerHTML =
|
|
3213
|
+
bannerHtml + (activeTab === 'schema' ? renderSchemaTabHtml(schemaState, rawMode, schemaRows) : renderValuesTab());
|
|
3214
|
+
autosizeAllCellTextareas();
|
|
3215
|
+
renderLocalUpdateButton();
|
|
3216
|
+
if (activeTab === 'values' && !valuesRawMode) {
|
|
3217
|
+
bindValuesDetailScroll();
|
|
3218
|
+
scrollToValueRow(selectedValueIndex, false);
|
|
3219
|
+
}
|
|
3220
|
+
if (activeTab === 'values' && valuesRawMode) {
|
|
3221
|
+
refreshValuesJsonIssues();
|
|
3222
|
+
}
|
|
3223
|
+
const loadingEl = byId('loading-indicator');
|
|
3224
|
+
if (loadingEl) {
|
|
3225
|
+
// Only surface the indicator when there's cached content already on screen underneath it —
|
|
3226
|
+
// when there isn't (state is still null), renderSkeleton() above is the loading signal.
|
|
3227
|
+
const stateForTab = activeTab === 'schema' ? schemaState : valuesState;
|
|
3228
|
+
const isLoading = activeTab === 'schema' ? schemaLoading : valuesLoading;
|
|
3229
|
+
loadingEl.style.display = isLoading && stateForTab ? '' : 'none';
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
// ── Events ───────────────────────────────────────────────────────────────────
|
|
3233
|
+
// One listener per event type for the whole page — the toolbar, the tab's content and the
|
|
3234
|
+
// import dialog. Controls carry `data-action` and what it acts on: `data-index` (the variable's
|
|
3235
|
+
// row), `data-col` (a list column), `data-item` (a list item), `data-column` (a list item's
|
|
3236
|
+
// field, none for a flat list), `data-field`; `data-autosize` on a textarea/select also resizes
|
|
3237
|
+
// it to its content. Drag handles carry `data-drag`, drop targets `data-drop`.
|
|
3238
|
+
const actionTarget = (event) => event.target instanceof Element ? event.target.closest('[data-action]') : null;
|
|
3239
|
+
const index = (el) => Number(el.dataset.index);
|
|
3240
|
+
const col = (el) => Number(el.dataset.col);
|
|
3241
|
+
const item = (el) => Number(el.dataset.item);
|
|
3242
|
+
const field = (el) => el.dataset.field;
|
|
3243
|
+
const checked = (el) => el.checked;
|
|
3244
|
+
const clickActions = {
|
|
3245
|
+
'switch-tab': (el) => switchTab(el.dataset.tab),
|
|
3246
|
+
refresh: () => refresh(),
|
|
3247
|
+
save: () => save(),
|
|
3248
|
+
'update-local-values': () => void updateLocalValues(),
|
|
3249
|
+
'toggle-raw-mode': () => toggleRawMode(),
|
|
3250
|
+
'add-schema-row': () => addSchemaRow(),
|
|
3251
|
+
'toggle-schema-advanced': (el) => toggleSchemaAdvanced(index(el)),
|
|
3252
|
+
'remove-schema-row': (el) => removeSchemaRow(index(el)),
|
|
3253
|
+
'show-additional-options-help': (el) => showAdditionalOptionsHelp(index(el)),
|
|
3254
|
+
'show-list-columns-help': () => showInfoModal('additional_options — list', LIST_COLUMNS_HELP_HTML),
|
|
3255
|
+
'add-list-column': (el) => addListColumn(index(el)),
|
|
3256
|
+
'remove-list-column': (el) => removeListColumn(index(el), col(el)),
|
|
3257
|
+
'toggle-values-raw-mode': () => toggleValuesRawMode(),
|
|
3258
|
+
'download-values-json': () => downloadValuesJson(),
|
|
3259
|
+
'import-values-json': () => triggerImportValuesJson(),
|
|
3260
|
+
'open-import-modal': () => openImportValuesModal(),
|
|
3261
|
+
'add-value-row': () => addValueRow(),
|
|
3262
|
+
'select-value-row': (el) => selectValueRow(Number(el.dataset.valueIndex)),
|
|
3263
|
+
'remove-value-row': (el) => removeValueRow(index(el)),
|
|
3264
|
+
'remove-chip': (el) => onChipRemove(el),
|
|
3265
|
+
'add-list-item': (el) => addListItem(index(el)),
|
|
3266
|
+
'remove-list-item': (el) => removeListItem(index(el), item(el)),
|
|
3267
|
+
'close-import-modal': () => closeImportModal(),
|
|
3268
|
+
'back-to-import-picker': () => backToImportPicker(),
|
|
3269
|
+
'select-import-page': (el) => void selectImportSourcePage(el.dataset.pageId, el.dataset.pageName),
|
|
3270
|
+
'apply-import-plan': () => applyImportPlan(),
|
|
3271
|
+
};
|
|
3272
|
+
const inputActions = {
|
|
3273
|
+
'set-dirty': () => setDirty(true),
|
|
3274
|
+
'schema-field': (el) => updateSchemaField(index(el), field(el), el.value),
|
|
3275
|
+
'schema-additional-options': (el) => updateSchemaAdditionalOptions(index(el), el.value),
|
|
3276
|
+
'list-column-name': (el) => updateListColumnField(index(el), col(el), 'name', el.value),
|
|
3277
|
+
'list-column-options': (el) => updateListColumnOptions(index(el), col(el), el.value),
|
|
3278
|
+
value: (el) => updateValueField(index(el), el.value),
|
|
3279
|
+
'values-raw-input': () => onValuesRawInput(),
|
|
3280
|
+
'combo-filter': (el) => onComboFilter(index(el), el.value),
|
|
3281
|
+
'single-combo': (el) => onSingleComboInput(index(el), el.value),
|
|
3282
|
+
'list-item-field': (el) => updateListItemField(index(el), item(el), el.dataset.column ?? null, el.value),
|
|
3283
|
+
'filter-import-pages': (el) => filterImportPages(el.value),
|
|
3284
|
+
};
|
|
3285
|
+
const changeActions = {
|
|
3286
|
+
'custom-select': (el) => onCustomSelectChange(index(el), el, field(el)),
|
|
3287
|
+
'editor-flag': (el) => updateEditorFlag(index(el), field(el), checked(el)),
|
|
3288
|
+
'list-column-field': (el) => updateListColumnField(index(el), col(el), field(el), el.value),
|
|
3289
|
+
'value-boolean': (el) => updateValueField(index(el), checked(el) ? 'true' : 'false'),
|
|
3290
|
+
'list-item-select': (el) => onListItemSelectChange(index(el), item(el), el.dataset.column, el),
|
|
3291
|
+
'import-values-file': (el) => onImportValuesFile(el),
|
|
3292
|
+
'toggle-import-all': (el) => toggleImportAll(checked(el)),
|
|
3293
|
+
'update-import-count': () => updateImportCount(),
|
|
3294
|
+
};
|
|
3295
|
+
const onClick = (event) => {
|
|
3296
|
+
const el = actionTarget(event);
|
|
3297
|
+
if (el) {
|
|
3298
|
+
clickActions[el.dataset.action]?.(el);
|
|
3299
|
+
}
|
|
3300
|
+
};
|
|
3301
|
+
// Input and change each run only their own actions: a select also fires `input`, a textarea
|
|
3302
|
+
// `change` on blur, and those aren't what their handlers listen to.
|
|
3303
|
+
const onFieldEvent = (actions) => (event) => {
|
|
3304
|
+
const el = actionTarget(event);
|
|
3305
|
+
const handler = el && actions[el.dataset.action];
|
|
3306
|
+
if (!el || !handler) {
|
|
3307
|
+
return;
|
|
3308
|
+
}
|
|
3309
|
+
if (el.dataset.autosize !== undefined) {
|
|
3310
|
+
autosizeCellTextarea(el);
|
|
3311
|
+
}
|
|
3312
|
+
handler(el);
|
|
3313
|
+
};
|
|
3314
|
+
// A combobox option is picked, and a chip's ✕ pressed, on mousedown with its default prevented:
|
|
3315
|
+
// the combobox's input keeps the focus, so its list doesn't close (blur) before the pick lands.
|
|
3316
|
+
const onMouseDown = (event) => {
|
|
3317
|
+
const el = actionTarget(event);
|
|
3318
|
+
if (el?.dataset.action === 'pick-combo-option') {
|
|
3319
|
+
event.preventDefault();
|
|
3320
|
+
onComboOptionPick(el);
|
|
3321
|
+
}
|
|
3322
|
+
else if (el?.dataset.action === 'remove-chip') {
|
|
3323
|
+
event.preventDefault();
|
|
3324
|
+
}
|
|
3325
|
+
};
|
|
3326
|
+
// A combobox's option list shows while its input has the focus.
|
|
3327
|
+
const comboIndex = (event) => event.target instanceof HTMLElement && event.target.classList.contains('ve-combo-input')
|
|
3328
|
+
? Number(event.target.dataset.index)
|
|
3329
|
+
: null;
|
|
3330
|
+
const onFocusIn = (event) => {
|
|
3331
|
+
const i = comboIndex(event);
|
|
3332
|
+
if (i !== null) {
|
|
3333
|
+
onComboFocus(i);
|
|
3334
|
+
}
|
|
3335
|
+
};
|
|
3336
|
+
const onFocusOut = (event) => {
|
|
3337
|
+
const i = comboIndex(event);
|
|
3338
|
+
if (i !== null) {
|
|
3339
|
+
onComboBlur(i);
|
|
3340
|
+
}
|
|
3341
|
+
};
|
|
3342
|
+
const dragHandlers = {
|
|
3343
|
+
'schema-row': { start: onSchemaRowDragStart, end: onSchemaRowDragEnd },
|
|
3344
|
+
'list-column': { start: onListColumnDragStart, end: onListColumnDragEnd },
|
|
3345
|
+
'list-item': { start: onListItemDragStart, end: onListItemDragEnd },
|
|
3346
|
+
};
|
|
3347
|
+
const dropHandlers = {
|
|
3348
|
+
'schema-row': { over: onSchemaRowDragOver, leave: onSchemaRowDragLeave, drop: onSchemaRowDrop },
|
|
3349
|
+
'list-column': { over: onListColumnDragOver, leave: onListColumnDragLeave, drop: onListColumnDrop },
|
|
3350
|
+
'list-item': { over: onListItemDragOver, leave: onListItemDragLeave, drop: onListItemDrop },
|
|
3351
|
+
};
|
|
3352
|
+
const dragHandle = (event) => event.target instanceof Element ? event.target.closest('[data-drag]') : null;
|
|
3353
|
+
const onDragStart = (event) => {
|
|
3354
|
+
const handle = dragHandle(event);
|
|
3355
|
+
if (handle) {
|
|
3356
|
+
dragHandlers[handle.dataset.drag]?.start(event);
|
|
3357
|
+
}
|
|
3358
|
+
};
|
|
3359
|
+
const onDragEnd = (event) => {
|
|
3360
|
+
const handle = dragHandle(event);
|
|
3361
|
+
if (handle) {
|
|
3362
|
+
dragHandlers[handle.dataset.drag]?.end();
|
|
3363
|
+
}
|
|
3364
|
+
};
|
|
3365
|
+
// Drop targets nest (a list column's card sits in a schema row's details row), and each has its
|
|
3366
|
+
// own handler: they all run, innermost first, as the event bubbles through them. The targets are
|
|
3367
|
+
// collected first — a drop re-renders the tab, as when each element had its own listener.
|
|
3368
|
+
const onDropZoneEvent = (kind) => (event) => {
|
|
3369
|
+
const zones = [];
|
|
3370
|
+
let zone = event.target instanceof Element ? event.target.closest('[data-drop]') : null;
|
|
3371
|
+
while (zone) {
|
|
3372
|
+
zones.push(zone);
|
|
3373
|
+
zone = zone.parentElement?.closest('[data-drop]') ?? null;
|
|
3374
|
+
}
|
|
3375
|
+
for (const target of zones) {
|
|
3376
|
+
dropHandlers[target.dataset.drop]?.[kind](event, target);
|
|
3377
|
+
}
|
|
3378
|
+
};
|
|
3379
|
+
const listeners = {
|
|
3380
|
+
click: onClick,
|
|
3381
|
+
input: onFieldEvent(inputActions),
|
|
3382
|
+
change: onFieldEvent(changeActions),
|
|
3383
|
+
mousedown: onMouseDown,
|
|
3384
|
+
focusin: onFocusIn,
|
|
3385
|
+
focusout: onFocusOut,
|
|
3386
|
+
dragstart: onDragStart,
|
|
3387
|
+
dragend: onDragEnd,
|
|
3388
|
+
dragover: onDropZoneEvent('over'),
|
|
3389
|
+
dragleave: onDropZoneEvent('leave'),
|
|
3390
|
+
drop: onDropZoneEvent('drop'),
|
|
3391
|
+
};
|
|
3392
|
+
for (const [type, listener] of Object.entries(listeners)) {
|
|
3393
|
+
document.addEventListener(type, listener);
|
|
3394
|
+
}
|
|
3395
|
+
byId('tab-schema').classList.toggle('active', activeTab === 'schema');
|
|
3396
|
+
byId('tab-values').classList.toggle('active', activeTab === 'values');
|
|
3397
|
+
setUrlState(activeTab, valuesRawMode);
|
|
3398
|
+
// Skeleton first (nothing's cached yet on a fresh page load), then swap in the real content.
|
|
3399
|
+
render();
|
|
3400
|
+
void loadTabData(activeTab).then(render);
|
|
3401
|
+
return () => {
|
|
3402
|
+
stopped = true;
|
|
3403
|
+
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
3404
|
+
document.removeEventListener('click', onThemeClick);
|
|
3405
|
+
for (const [type, listener] of Object.entries(listeners)) {
|
|
3406
|
+
document.removeEventListener(type, listener);
|
|
3407
|
+
}
|
|
3408
|
+
if (autoLoadTimer) {
|
|
3409
|
+
clearTimeout(autoLoadTimer);
|
|
3410
|
+
}
|
|
3411
|
+
if (valuesRawIssuesTimer) {
|
|
3412
|
+
clearTimeout(valuesRawIssuesTimer);
|
|
3413
|
+
}
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
// Entry of the Variables Editor bundle: its styles and page, built next to it in dist/client.
|
|
3418
|
+
startVariablesEditor({ templateLess: document.body.dataset.templateLess === 'true' });
|
|
3419
|
+
//# sourceMappingURL=variables-editor.js.map
|