@case-framework/survey-core 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -143
- package/build/answer-types-DH0gv0Ea.d.mts +20 -0
- package/build/answer-types-DH0gv0Ea.d.mts.map +1 -0
- package/build/answer-types-Dtm6MqZQ.mjs +19 -0
- package/build/answer-types-Dtm6MqZQ.mjs.map +1 -0
- package/build/editor/colors.d.mts +1 -2
- package/build/editor/colors.d.mts.map +1 -1
- package/build/editor/colors.mjs.map +1 -1
- package/build/editor.d.mts +72931 -227
- package/build/editor.d.mts.map +1 -1
- package/build/editor.mjs +1281 -675
- package/build/editor.mjs.map +1 -1
- package/build/index.d.mts +2 -932
- package/build/index.mjs +1419 -1829
- package/build/index.mjs.map +1 -1
- package/build/keyed-object-D1yh_W5v.mjs +45 -0
- package/build/keyed-object-D1yh_W5v.mjs.map +1 -0
- package/build/localization-1lzNpyFX.mjs +1373 -0
- package/build/localization-1lzNpyFX.mjs.map +1 -0
- package/build/package.json +14 -9
- package/build/projection-DrLPFroE.mjs +2344 -0
- package/build/projection-DrLPFroE.mjs.map +1 -0
- package/build/registry-BOXcyWfX.d.mts +54292 -0
- package/build/registry-BOXcyWfX.d.mts.map +1 -0
- package/build/schema.d.mts +3 -0
- package/build/schema.mjs +4 -0
- package/package.json +14 -9
- package/build/index.d.mts.map +0 -1
- package/build/survey-BKblV5rN.mjs +0 -1460
- package/build/survey-BKblV5rN.mjs.map +0 -1
- package/build/survey-DedDemSc.d.mts +0 -997
- package/build/survey-DedDemSc.d.mts.map +0 -1
package/build/editor.mjs
CHANGED
|
@@ -1,211 +1,8 @@
|
|
|
1
|
-
import { $ as
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
this.survey = survey;
|
|
7
|
-
}
|
|
8
|
-
/**
|
|
9
|
-
* Copy a survey item and all its data to clipboard format
|
|
10
|
-
* When copying a group, automatically includes all items in the subtree
|
|
11
|
-
* @param itemId - The id of the item to copy
|
|
12
|
-
* @returns Clipboard data that can be serialized to JSON for clipboard
|
|
13
|
-
*/
|
|
14
|
-
copyItem(itemId) {
|
|
15
|
-
if (!this.survey.surveyItems.get(itemId)) throw new Error(`Item with id '${itemId}' not found`);
|
|
16
|
-
const itemsToCopy = this.collectItemsForCopy(itemId);
|
|
17
|
-
const items = itemsToCopy.map((id) => {
|
|
18
|
-
const item = this.survey.surveyItems.get(id);
|
|
19
|
-
if (!item) throw new Error(`Item with id '${id}' not found during copy`);
|
|
20
|
-
return {
|
|
21
|
-
itemId: id,
|
|
22
|
-
itemData: item.rawItem
|
|
23
|
-
};
|
|
24
|
-
});
|
|
25
|
-
const translations = {};
|
|
26
|
-
itemsToCopy.forEach((id) => {
|
|
27
|
-
try {
|
|
28
|
-
const itemTranslations = this.survey.getItemTranslations(id);
|
|
29
|
-
if (itemTranslations?.locales?.length) {
|
|
30
|
-
const serializedTranslations = {};
|
|
31
|
-
itemTranslations.locales.forEach((locale) => {
|
|
32
|
-
const localeContent = itemTranslations.getAllForLocale(locale);
|
|
33
|
-
if (localeContent) serializedTranslations[locale] = localeContent;
|
|
34
|
-
});
|
|
35
|
-
translations[id] = serializedTranslations;
|
|
36
|
-
} else translations[id] = {};
|
|
37
|
-
} catch {
|
|
38
|
-
translations[id] = {};
|
|
39
|
-
}
|
|
40
|
-
});
|
|
41
|
-
return {
|
|
42
|
-
type: "survey-item",
|
|
43
|
-
version: "1.0.0",
|
|
44
|
-
items,
|
|
45
|
-
translations,
|
|
46
|
-
rootItemId: itemId,
|
|
47
|
-
timestamp: Date.now()
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Collect all items that should be copied, including subtree for groups
|
|
52
|
-
* @param itemId - The root item id
|
|
53
|
-
* @returns Array of item keys to copy
|
|
54
|
-
*/
|
|
55
|
-
collectItemsForCopy(itemId) {
|
|
56
|
-
const itemsToCopy = [itemId];
|
|
57
|
-
const item = this.survey.surveyItems.get(itemId);
|
|
58
|
-
if (!item) return [];
|
|
59
|
-
if (item instanceof GroupItemCore) {
|
|
60
|
-
const childIds = item.items;
|
|
61
|
-
if (childIds.length > 0) childIds.forEach((childId) => {
|
|
62
|
-
const childItems = this.collectItemsForCopy(childId);
|
|
63
|
-
itemsToCopy.push(...childItems);
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
return itemsToCopy;
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Add an item to its parent group.
|
|
70
|
-
* Ensures key uniqueness among siblings.
|
|
71
|
-
* @param target - Target location information
|
|
72
|
-
* @param item - The item to add (may have key adjusted for uniqueness)
|
|
73
|
-
*/
|
|
74
|
-
addItemToParentGroup(target, item) {
|
|
75
|
-
const parentGroup = this.survey.surveyItems.get(target.parentId);
|
|
76
|
-
if (!parentGroup) throw new Error(`Parent item with id '${target.parentId}' not found`);
|
|
77
|
-
if (!(parentGroup instanceof GroupItemCore)) throw new Error(`Parent item '${target.parentId}' is not a group item`);
|
|
78
|
-
const siblings = parentGroup.items.map((id) => this.survey.surveyItems.get(id)).filter((s) => s !== void 0);
|
|
79
|
-
const siblingKeys = new Set(siblings.map((s) => s.key));
|
|
80
|
-
let uniqueKey = item.key;
|
|
81
|
-
if (siblingKeys.has(uniqueKey)) {
|
|
82
|
-
let counter = 1;
|
|
83
|
-
const originalKey = item.key;
|
|
84
|
-
while (siblingKeys.has(uniqueKey)) {
|
|
85
|
-
uniqueKey = originalKey + `_${counter}`;
|
|
86
|
-
counter++;
|
|
87
|
-
}
|
|
88
|
-
if (uniqueKey !== item.key) {
|
|
89
|
-
const updatedRawItem = {
|
|
90
|
-
...item.rawItem,
|
|
91
|
-
key: uniqueKey
|
|
92
|
-
};
|
|
93
|
-
const newItem = this.survey.createItemFromRaw(updatedRawItem);
|
|
94
|
-
this.survey.surveyItems.delete(item.id);
|
|
95
|
-
this.survey.surveyItems.set(newItem.id, newItem);
|
|
96
|
-
item = newItem;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
const insertIndex = target.index !== void 0 ? Math.min(target.index, parentGroup.items.length) : parentGroup.items.length;
|
|
100
|
-
parentGroup.addChild(item.id, insertIndex);
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* Paste a survey item from clipboard data to a target location
|
|
104
|
-
* Handles multiple items and subtrees from the clipboard data
|
|
105
|
-
* @param clipboardData - The clipboard data containing the item(s) to paste
|
|
106
|
-
* @param target - Target location where to paste the item
|
|
107
|
-
* @returns The id of the pasted root item
|
|
108
|
-
*/
|
|
109
|
-
pasteItem(clipboardData, target) {
|
|
110
|
-
if (!ItemCopyPaste.isValidClipboardData(clipboardData)) throw new Error("Invalid clipboard data format");
|
|
111
|
-
const idMapping = {};
|
|
112
|
-
clipboardData.items.forEach(({ itemId }) => {
|
|
113
|
-
idMapping[itemId] = generateId();
|
|
114
|
-
});
|
|
115
|
-
clipboardData.items.forEach(({ itemId, itemData }) => {
|
|
116
|
-
const newId = idMapping[itemId];
|
|
117
|
-
if (newId) {
|
|
118
|
-
const updatedItemData = this.updateItemIdsInData(itemData, idMapping);
|
|
119
|
-
updatedItemData.id = newId;
|
|
120
|
-
const newItem = this.survey.createItemFromRaw(updatedItemData);
|
|
121
|
-
this.survey.surveyItems.set(newItem.id, newItem);
|
|
122
|
-
if (itemId === clipboardData.rootItemId) this.addItemToParentGroup(target, newItem);
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
const updatedTranslations = this.updateTranslations(clipboardData.translations, idMapping);
|
|
126
|
-
Object.keys(updatedTranslations).forEach((itemId) => {
|
|
127
|
-
const itemTranslations = new SurveyItemTranslations();
|
|
128
|
-
const serializedData = updatedTranslations[itemId];
|
|
129
|
-
Object.keys(serializedData).forEach((locale) => {
|
|
130
|
-
itemTranslations.setAllForLocale(locale, serializedData[locale]);
|
|
131
|
-
});
|
|
132
|
-
this.survey.translations.setItemTranslations(itemId, itemTranslations);
|
|
133
|
-
});
|
|
134
|
-
return idMapping[clipboardData.rootItemId];
|
|
135
|
-
}
|
|
136
|
-
/**
|
|
137
|
-
* Update all item ids in the raw item data recursively
|
|
138
|
-
*/
|
|
139
|
-
updateItemIdsInData(itemData, idMapping) {
|
|
140
|
-
const updatedData = JSON.parse(JSON.stringify(itemData));
|
|
141
|
-
if (updatedData.itemType === "group" && updatedData.config) {
|
|
142
|
-
const config = updatedData.config;
|
|
143
|
-
if (config.items) config.items = config.items.map((childId) => idMapping[childId] ?? childId);
|
|
144
|
-
}
|
|
145
|
-
this.updateExpressionsInItemData(updatedData, idMapping);
|
|
146
|
-
return updatedData;
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* Update expressions in item data that reference the old ids
|
|
150
|
-
*/
|
|
151
|
-
updateExpressionsInItemData(itemData, idMapping) {
|
|
152
|
-
if (itemData.displayConditions?.root) this.updateExpressionReferences(itemData.displayConditions.root, idMapping);
|
|
153
|
-
if (itemData.displayConditions?.components) Object.values(itemData.displayConditions.components).forEach((expr) => {
|
|
154
|
-
if (expr) this.updateExpressionReferences(expr, idMapping);
|
|
155
|
-
});
|
|
156
|
-
if (itemData.disabledConditions?.components) Object.values(itemData.disabledConditions.components).forEach((expr) => {
|
|
157
|
-
if (expr) this.updateExpressionReferences(expr, idMapping);
|
|
158
|
-
});
|
|
159
|
-
if (itemData.validations) Object.values(itemData.validations).forEach((expr) => {
|
|
160
|
-
if (expr) this.updateExpressionReferences(expr, idMapping);
|
|
161
|
-
});
|
|
162
|
-
if (itemData.prefills) itemData.prefills.forEach((prefill) => {
|
|
163
|
-
if (prefill.when) this.updateExpressionReferences(prefill.when, idMapping);
|
|
164
|
-
if (prefill.source.type === "expression") this.updateExpressionReferences(prefill.source.expression, idMapping);
|
|
165
|
-
if (prefill.source.type === "previousResponse") prefill.source.ref.itemId = idMapping[prefill.source.ref.itemId] ?? prefill.source.ref.itemId;
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
/**
|
|
169
|
-
* Update references in a single expression (recursive)
|
|
170
|
-
*/
|
|
171
|
-
updateExpressionReferences(expression, idMapping) {
|
|
172
|
-
if (!expression || typeof expression !== "object") return;
|
|
173
|
-
const expr = expression;
|
|
174
|
-
if (Array.isArray(expr.data)) expr.data.forEach((arg) => {
|
|
175
|
-
if (arg && typeof arg === "object" && arg !== null && "str" in arg) {
|
|
176
|
-
const argObj = arg;
|
|
177
|
-
if (typeof argObj.str === "string" && Object.prototype.hasOwnProperty.call(idMapping, argObj.str)) argObj.str = idMapping[argObj.str];
|
|
178
|
-
this.updateExpressionReferences(arg, idMapping);
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
Object.keys(expr).forEach((key) => {
|
|
182
|
-
const val = expr[key];
|
|
183
|
-
if (val && typeof val === "object") this.updateExpressionReferences(val, idMapping);
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
/**
|
|
187
|
-
* Update translation keys for the pasted item
|
|
188
|
-
*/
|
|
189
|
-
updateTranslations(translations, idMapping) {
|
|
190
|
-
const newTranslations = {};
|
|
191
|
-
Object.keys(translations).forEach((itemId) => {
|
|
192
|
-
const itemTranslations = translations[itemId];
|
|
193
|
-
const newItemId = idMapping[itemId];
|
|
194
|
-
newTranslations[newItemId] = itemTranslations;
|
|
195
|
-
});
|
|
196
|
-
return newTranslations;
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* Validate clipboard data format
|
|
200
|
-
*/
|
|
201
|
-
static isValidClipboardData(data) {
|
|
202
|
-
if (typeof data !== "object" || data === null || data === void 0) return false;
|
|
203
|
-
const clipboardData = data;
|
|
204
|
-
if (clipboardData.rootItemId === void 0 || typeof clipboardData.rootItemId !== "string" || !Array.isArray(clipboardData.items) || clipboardData.items.length === 0 || typeof clipboardData.translations !== "object" || clipboardData.translations === null || Array.isArray(clipboardData.translations)) return false;
|
|
205
|
-
return clipboardData.type === "survey-item" && clipboardData.version === "1.0.0" && clipboardData.items.every((item) => typeof item === "object" && item !== null && typeof item.itemId === "string" && item.itemId.length > 0 && typeof item.itemData === "object" && item.itemData !== null && !Array.isArray(item.itemData) && typeof item.itemData.itemType === "string") && clipboardData.items.some((item) => item.itemId === clipboardData.rootItemId);
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
//#endregion
|
|
1
|
+
import { $ as inspectAnnotated, G as stringRecord, U as ownValue, W as setOwnValue, X as contentOwnerIds, it as transformAnnotated, l as readLocalizationContent, mt as transformExpression, nt as remapAnnotated, ot as expressionSchema, q as annotatedReferences, tt as referenceId } from "./localization-1lzNpyFX.mjs";
|
|
2
|
+
import { D as DefinitionError, F as allocateResponseVariableName, I as isValidResponseVariableName, O as Survey, Z as generateId, i as localizedExportSlots, k as checkDocumentBudget, n as getDefaultProjectionDiagnostics, r as getDefaultProjectionIssues, s as projectSlot, tt as structuredCloneMethod, w as SurveyStateGraph } from "./projection-DrLPFroE.mjs";
|
|
3
|
+
import { t as answerTypeReservationsSchema } from "./answer-types-Dtm6MqZQ.mjs";
|
|
4
|
+
import { SURVEY_EDITOR_ITEM_COLORS } from "./editor/colors.mjs";
|
|
5
|
+
import { z } from "zod";
|
|
209
6
|
//#region src/editor/undo-redo.ts
|
|
210
7
|
const CommitSource = {
|
|
211
8
|
USER: "user",
|
|
@@ -239,34 +36,17 @@ function cloneAssets(assets) {
|
|
|
239
36
|
return structuredCloneMethod(assets);
|
|
240
37
|
}
|
|
241
38
|
function normalizeHistoryEntry(entry) {
|
|
242
|
-
if ("kind"
|
|
243
|
-
if (entry.kind === "survey-snapshot") return {
|
|
244
|
-
kind: "survey-snapshot",
|
|
245
|
-
survey: structuredCloneMethod(entry.survey),
|
|
246
|
-
timestamp: entry.timestamp,
|
|
247
|
-
meta: entry.meta,
|
|
248
|
-
memorySize: entry.memorySize
|
|
249
|
-
};
|
|
250
|
-
return {
|
|
251
|
-
kind: "asset-change",
|
|
252
|
-
changes: structuredCloneMethod(entry.changes),
|
|
253
|
-
timestamp: entry.timestamp,
|
|
254
|
-
meta: entry.meta,
|
|
255
|
-
memorySize: entry.memorySize
|
|
256
|
-
};
|
|
257
|
-
}
|
|
39
|
+
if (entry.kind !== "survey-snapshot" && entry.kind !== "asset-change") throw new Error("Unsupported history entry kind");
|
|
258
40
|
return {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
timestamp: entry.timestamp,
|
|
262
|
-
meta: entry.meta,
|
|
263
|
-
memorySize: entry.memorySize
|
|
41
|
+
...structuredCloneMethod(entry),
|
|
42
|
+
id: entry.id ?? generateId()
|
|
264
43
|
};
|
|
265
44
|
}
|
|
266
45
|
var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
267
46
|
history = [];
|
|
268
47
|
currentIndex = -1;
|
|
269
48
|
_config;
|
|
49
|
+
baseAssetSize;
|
|
270
50
|
_initialAssets;
|
|
271
51
|
constructor(initialSurvey, config = {}, meta = {
|
|
272
52
|
label: "Initial state",
|
|
@@ -284,7 +64,8 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
284
64
|
saveEntry(entry) {
|
|
285
65
|
const payload = entry.kind === "survey-snapshot" ? {
|
|
286
66
|
kind: entry.kind,
|
|
287
|
-
survey: entry.survey
|
|
67
|
+
survey: entry.survey,
|
|
68
|
+
changes: entry.changes
|
|
288
69
|
} : {
|
|
289
70
|
kind: entry.kind,
|
|
290
71
|
changes: entry.changes
|
|
@@ -293,6 +74,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
293
74
|
this.history = this.history.slice(0, this.currentIndex + 1);
|
|
294
75
|
this.history.push({
|
|
295
76
|
...structuredCloneMethod(entry),
|
|
77
|
+
id: generateId(),
|
|
296
78
|
timestamp: Date.now(),
|
|
297
79
|
memorySize
|
|
298
80
|
});
|
|
@@ -302,12 +84,12 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
302
84
|
saveSnapshot(survey, meta) {
|
|
303
85
|
this.saveEntry({
|
|
304
86
|
kind: "survey-snapshot",
|
|
305
|
-
survey
|
|
87
|
+
survey,
|
|
306
88
|
meta
|
|
307
89
|
});
|
|
308
90
|
}
|
|
309
91
|
getBaseAssetsSize() {
|
|
310
|
-
return this._initialAssets ? MemoryCalculator.calculateSize(this._initialAssets) : 0;
|
|
92
|
+
return this.baseAssetSize ??= this._initialAssets ? MemoryCalculator.calculateSize(this._initialAssets) : 0;
|
|
311
93
|
}
|
|
312
94
|
cleanupHistory() {
|
|
313
95
|
const maxMemoryBytes = this._config.maxTotalMemoryMB * 1024 * 1024;
|
|
@@ -321,6 +103,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
321
103
|
const nextEntry = this.history[nextStateIndex];
|
|
322
104
|
const newFirstEntry = {
|
|
323
105
|
kind: "survey-snapshot",
|
|
106
|
+
id: nextEntry.id,
|
|
324
107
|
survey: nextStateSurvey,
|
|
325
108
|
meta: nextEntry.meta,
|
|
326
109
|
timestamp: nextEntry.timestamp,
|
|
@@ -330,8 +113,9 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
330
113
|
})
|
|
331
114
|
};
|
|
332
115
|
const removedBytes = this.history[0].memorySize;
|
|
333
|
-
this._initialAssets =
|
|
334
|
-
this.
|
|
116
|
+
this._initialAssets = nextStateAssets;
|
|
117
|
+
this.baseAssetSize = void 0;
|
|
118
|
+
this.history = [newFirstEntry, ...this.history.slice(2)];
|
|
335
119
|
this.currentIndex--;
|
|
336
120
|
return removedBytes;
|
|
337
121
|
}
|
|
@@ -347,8 +131,8 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
347
131
|
throw new Error("Invalid history state");
|
|
348
132
|
}
|
|
349
133
|
applyAssetPatch(assets, patch) {
|
|
350
|
-
const nextAssets =
|
|
351
|
-
if (patch.next) nextAssets
|
|
134
|
+
const nextAssets = assets;
|
|
135
|
+
if (patch.next) setOwnValue(nextAssets, patch.assetId, structuredCloneMethod(patch.next));
|
|
352
136
|
else delete nextAssets[patch.assetId];
|
|
353
137
|
return nextAssets;
|
|
354
138
|
}
|
|
@@ -357,13 +141,23 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
357
141
|
let assets = cloneAssets(this._initialAssets) ?? {};
|
|
358
142
|
for (let i = 1; i <= index; i++) {
|
|
359
143
|
const entry = this.history[i];
|
|
360
|
-
|
|
361
|
-
for (const patch of entry.changes) assets = this.applyAssetPatch(assets, patch);
|
|
144
|
+
for (const patch of entry.changes ?? []) assets = this.applyAssetPatch(assets, patch);
|
|
362
145
|
}
|
|
363
146
|
return Object.keys(assets).length > 0 ? assets : void 0;
|
|
364
147
|
}
|
|
365
|
-
|
|
366
|
-
this.
|
|
148
|
+
getEntryId(index) {
|
|
149
|
+
return this.history[index]?.id;
|
|
150
|
+
}
|
|
151
|
+
findEntryIndex(id) {
|
|
152
|
+
return this.history.findIndex((entry) => entry.id === id);
|
|
153
|
+
}
|
|
154
|
+
commit(survey, meta, changes) {
|
|
155
|
+
this.saveEntry({
|
|
156
|
+
kind: "survey-snapshot",
|
|
157
|
+
survey,
|
|
158
|
+
meta,
|
|
159
|
+
...changes?.length ? { changes } : {}
|
|
160
|
+
});
|
|
367
161
|
}
|
|
368
162
|
commitAssetChange(changes, meta) {
|
|
369
163
|
if (changes.length === 0) return;
|
|
@@ -407,7 +201,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
407
201
|
}
|
|
408
202
|
getMemoryUsage() {
|
|
409
203
|
return {
|
|
410
|
-
totalMB: this.getTotalMemoryUsage() /
|
|
204
|
+
totalMB: this.getTotalMemoryUsage() / 1048576,
|
|
411
205
|
entries: this.history.length
|
|
412
206
|
};
|
|
413
207
|
}
|
|
@@ -422,6 +216,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
422
216
|
index,
|
|
423
217
|
kind: entry.kind,
|
|
424
218
|
meta: entry.meta,
|
|
219
|
+
id: entry.id ?? generateId(),
|
|
425
220
|
timestamp: entry.timestamp,
|
|
426
221
|
memorySize: entry.memorySize,
|
|
427
222
|
isCurrent: index === this.currentIndex
|
|
@@ -440,6 +235,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
440
235
|
index,
|
|
441
236
|
kind: entry.kind,
|
|
442
237
|
meta: entry.meta,
|
|
238
|
+
id: entry.id ?? generateId(),
|
|
443
239
|
timestamp: entry.timestamp,
|
|
444
240
|
memorySize: entry.memorySize,
|
|
445
241
|
isCurrent: index === this.currentIndex
|
|
@@ -484,6 +280,8 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
484
280
|
if (entry.kind === "survey-snapshot") return {
|
|
485
281
|
kind: "survey-snapshot",
|
|
486
282
|
survey: entry.survey,
|
|
283
|
+
changes: entry.changes,
|
|
284
|
+
id: entry.id ?? generateId(),
|
|
487
285
|
timestamp: entry.timestamp,
|
|
488
286
|
meta: entry.meta,
|
|
489
287
|
memorySize: entry.memorySize
|
|
@@ -491,6 +289,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
491
289
|
return {
|
|
492
290
|
kind: "asset-change",
|
|
493
291
|
changes: entry.changes,
|
|
292
|
+
id: entry.id ?? generateId(),
|
|
494
293
|
timestamp: entry.timestamp,
|
|
495
294
|
meta: entry.meta,
|
|
496
295
|
memorySize: entry.memorySize
|
|
@@ -512,9 +311,10 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
512
311
|
if (!jsonData.config) throw new Error("Invalid object: config is required");
|
|
513
312
|
const normalizedHistory = jsonData.history.map(normalizeHistoryEntry);
|
|
514
313
|
const firstEntry = normalizedHistory[0];
|
|
515
|
-
const
|
|
314
|
+
const firstSurvey = firstEntry.kind === "survey-snapshot" ? firstEntry.survey : (() => {
|
|
516
315
|
throw new Error("Invalid object: first history entry must resolve to a survey snapshot");
|
|
517
|
-
})()
|
|
316
|
+
})();
|
|
317
|
+
const instance = new SurveyEditorUndoRedo(firstSurvey, jsonData.config, firstEntry.meta, initialAssetsOverride ?? jsonData.initialAssets);
|
|
518
318
|
instance.history = normalizedHistory;
|
|
519
319
|
instance.currentIndex = jsonData.currentIndex;
|
|
520
320
|
instance._initialAssets = cloneAssets(initialAssetsOverride ?? jsonData.initialAssets);
|
|
@@ -522,530 +322,1336 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
522
322
|
}
|
|
523
323
|
};
|
|
524
324
|
//#endregion
|
|
325
|
+
//#region src/editor/response-naming.ts
|
|
326
|
+
function readPath(input, path) {
|
|
327
|
+
let value = input;
|
|
328
|
+
for (const key of path) if (Array.isArray(value) && typeof key === "number") value = value[key];
|
|
329
|
+
else if (value !== null && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, key)) value = Reflect.get(value, key);
|
|
330
|
+
else throw new Error("Response naming declaration does not match its config");
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
function writePath(input, path, value) {
|
|
334
|
+
if (!path.length) throw new Error("Invalid response naming path");
|
|
335
|
+
const parent = readPath(input, path.slice(0, -1));
|
|
336
|
+
if (!parent || typeof parent !== "object") throw new Error("Response naming declaration does not match its config");
|
|
337
|
+
const key = path.at(-1);
|
|
338
|
+
if (Array.isArray(parent) && typeof key === "number") parent[key] = value;
|
|
339
|
+
else setOwnValue(parent, String(key), value);
|
|
340
|
+
}
|
|
341
|
+
const assertName = (name) => {
|
|
342
|
+
if (!isValidResponseVariableName(name)) throw new Error(`Invalid variable name "${name}". Use lowercase snake_case, starting with a letter, at most 32 characters.`);
|
|
343
|
+
};
|
|
344
|
+
/** Detached, validated preview; applying it uses the editor's ordinary mutation/history path. */
|
|
345
|
+
function previewNaming(survey, edits) {
|
|
346
|
+
const registry = survey.slots;
|
|
347
|
+
const raw = structuredClone(survey.serialize());
|
|
348
|
+
const config = (slot) => readPath(raw, [...survey.elements.get(slot.elementId).path, "config"]);
|
|
349
|
+
for (const edit of edits) if ("slotId" in edit) {
|
|
350
|
+
const slot = registry.get(edit.slotId);
|
|
351
|
+
if (!slot) throw new Error(`Unknown response variable "${edit.slotId}"`);
|
|
352
|
+
if (slot.naming?.kind === "matrix") throw new Error("Matrix cell names are derived; edit the matrix prefix or row/column coding names");
|
|
353
|
+
if (!slot.naming) throw new Error(`The input owning "${edit.slotId}" does not expose a naming mutation`);
|
|
354
|
+
assertName(edit.variableName);
|
|
355
|
+
writePath(config(slot), slot.naming.path, edit.variableName);
|
|
356
|
+
} else {
|
|
357
|
+
const slots = registry.definitions.filter((slot) => slot.elementId === edit.elementId && slot.naming?.kind === "matrix");
|
|
358
|
+
if (!slots.length) throw new Error(`Unknown matrix element "${edit.elementId}"`);
|
|
359
|
+
for (const [ids, kind] of [[edit.rows, "row"], [edit.columns, "column"]]) for (const [id, name] of Object.entries(ids ?? {})) {
|
|
360
|
+
assertName(name);
|
|
361
|
+
const slot = slots.find((slot) => kind === "row" ? slot.rowId === id : slot.columnId === id);
|
|
362
|
+
const naming = slot?.naming;
|
|
363
|
+
const path = naming?.kind === "matrix" ? kind === "row" ? naming.rowPath : naming.columnPath : void 0;
|
|
364
|
+
if (!slot || !path) throw new Error(`Unknown matrix ${kind} "${id}"`);
|
|
365
|
+
writePath(config(slot), path, name);
|
|
366
|
+
}
|
|
367
|
+
if (edit.prefix !== void 0) {
|
|
368
|
+
assertName(edit.prefix);
|
|
369
|
+
const slot = slots[0], naming = slot.naming;
|
|
370
|
+
if (naming?.kind === "matrix") writePath(config(slot), naming.prefixPath, edit.prefix);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const next = Survey.parse(raw, survey.registry, { admission: "draft" });
|
|
374
|
+
const nextRegistry = next.slots;
|
|
375
|
+
const beforeIssues = new Set(registry.diagnostics.map((issue) => JSON.stringify(issue)));
|
|
376
|
+
const issues = nextRegistry.diagnostics.filter((issue) => !beforeIssues.has(JSON.stringify(issue)));
|
|
377
|
+
if (issues.length) throw new Error(issues.map((issue) => issue.message).join("\n"));
|
|
378
|
+
const changes = nextRegistry.definitions.flatMap((slot) => {
|
|
379
|
+
const before = registry.get(slot.slotId).variableName;
|
|
380
|
+
return before === slot.variableName ? [] : [{
|
|
381
|
+
slotId: slot.slotId,
|
|
382
|
+
itemId: slot.itemId,
|
|
383
|
+
before,
|
|
384
|
+
after: slot.variableName
|
|
385
|
+
}];
|
|
386
|
+
});
|
|
387
|
+
return {
|
|
388
|
+
survey: next.serialize(),
|
|
389
|
+
changes
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function assertProjectionChanges(before, after) {
|
|
393
|
+
const existing = new Set(getDefaultProjectionIssues(before));
|
|
394
|
+
const introduced = getDefaultProjectionIssues(after).filter((issue) => !existing.has(issue));
|
|
395
|
+
if (introduced.length) throw new Error(introduced.join("\n"));
|
|
396
|
+
}
|
|
397
|
+
function previewResponseNaming(survey, edits) {
|
|
398
|
+
const preview = previewNaming(survey, edits);
|
|
399
|
+
assertProjectionChanges(survey, Survey.parse(preview.survey, survey.registry, { admission: "draft" }));
|
|
400
|
+
return preview;
|
|
401
|
+
}
|
|
402
|
+
/** Naming, categorical codes, and formats share one detached, atomic preview. */
|
|
403
|
+
function previewResponseSettings(survey, naming, settings) {
|
|
404
|
+
const preview = previewNaming(survey, naming);
|
|
405
|
+
const raw = preview.survey;
|
|
406
|
+
const registry = survey.slots;
|
|
407
|
+
const codeWrites = /* @__PURE__ */ new Map();
|
|
408
|
+
for (const edit of settings) {
|
|
409
|
+
const slot = registry.get(edit.slotId);
|
|
410
|
+
if (!slot) throw new Error(`Unknown response variable "${edit.slotId}"`);
|
|
411
|
+
const path = survey.elements.get(slot.elementId).path;
|
|
412
|
+
const element = survey.registry.elementSchema.parse(readPath(raw, path));
|
|
413
|
+
for (const [id, code] of Object.entries(edit.optionCodes ?? {})) {
|
|
414
|
+
const path = ownValue(slot.optionCodePaths, id);
|
|
415
|
+
if (!path) throw new Error(`Option "${id}" does not expose an export-code mutation`);
|
|
416
|
+
if (!code.trim()) throw new Error("Option export codes cannot be empty");
|
|
417
|
+
const target = JSON.stringify([slot.elementId, path]);
|
|
418
|
+
const previous = codeWrites.get(target);
|
|
419
|
+
if (previous && previous.code !== code) throw new Error(`Conflicting export codes for shared option "${id}" on variables "${registry.get(previous.slotId).variableName}" and "${slot.variableName}". Supply one code for this shared option.`);
|
|
420
|
+
codeWrites.set(target, {
|
|
421
|
+
code,
|
|
422
|
+
slotId: slot.slotId
|
|
423
|
+
});
|
|
424
|
+
writePath(element.config, path, code);
|
|
425
|
+
}
|
|
426
|
+
if (edit.exportSettings) element.exportSettings = {
|
|
427
|
+
...element.exportSettings,
|
|
428
|
+
[slot.slotId]: structuredClone(edit.exportSettings)
|
|
429
|
+
};
|
|
430
|
+
writePath(raw, path, element);
|
|
431
|
+
}
|
|
432
|
+
const next = Survey.parse(raw, survey.registry, { admission: "draft" });
|
|
433
|
+
for (const slot of localizedExportSlots(next).filter((slot) => settings.some((edit) => edit.slotId === slot.slotId))) projectSlot(slot, {
|
|
434
|
+
mode: "default",
|
|
435
|
+
...slot.exportSettings
|
|
436
|
+
});
|
|
437
|
+
assertProjectionChanges(survey, next);
|
|
438
|
+
return {
|
|
439
|
+
...preview,
|
|
440
|
+
survey: next.serialize()
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/** Allocate names using declared mutation locations, including every nested element. */
|
|
444
|
+
function allocateCopiedResponseNames(schema, input, registry, occupied) {
|
|
445
|
+
const used = new Set(occupied);
|
|
446
|
+
return transformAnnotated(schema, input, ({ annotation, value }) => {
|
|
447
|
+
if (annotation.kind !== "owner" || !value || typeof value !== "object" || !("kind" in value) || value.kind !== "element") return value;
|
|
448
|
+
const node = registry.elementSchema.parse(value);
|
|
449
|
+
const definition = registry.get(node.elementType);
|
|
450
|
+
const paths = /* @__PURE__ */ new Map();
|
|
451
|
+
for (const slot of definition.inspect(node).slots) {
|
|
452
|
+
const naming = slot.naming;
|
|
453
|
+
if (!naming) {
|
|
454
|
+
if (used.has(slot.variableName)) throw new Error(`Cannot copy variable '${slot.variableName}': element '${node.elementType}' must declare a naming location to resolve the duplicate name`);
|
|
455
|
+
used.add(slot.variableName);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const path = ["config", ...naming.kind === "variable" ? naming.path : naming.prefixPath];
|
|
459
|
+
const base = readPath(node, path);
|
|
460
|
+
if (typeof base !== "string") throw new Error("Invalid response naming location");
|
|
461
|
+
const key = JSON.stringify(path);
|
|
462
|
+
const group = paths.get(key) ?? {
|
|
463
|
+
path,
|
|
464
|
+
base,
|
|
465
|
+
slots: /* @__PURE__ */ new Set(),
|
|
466
|
+
maxLength: 32
|
|
467
|
+
};
|
|
468
|
+
group.slots.add(slot.slotId);
|
|
469
|
+
group.maxLength = Math.min(group.maxLength, 32 - (slot.variableName.length - base.length));
|
|
470
|
+
paths.set(key, group);
|
|
471
|
+
}
|
|
472
|
+
for (const { path, base, slots, maxLength } of paths.values()) {
|
|
473
|
+
let candidate = base, attempts = 0;
|
|
474
|
+
const names = () => definition.inspect(node).slots.filter((slot) => slots.has(slot.slotId)).map((slot) => slot.variableName);
|
|
475
|
+
while (names().some((name) => used.has(name))) {
|
|
476
|
+
if (++attempts > used.size + 1) throw new Error(`Element '${node.elementType}' does not honor its declared naming location`);
|
|
477
|
+
const before = names();
|
|
478
|
+
candidate = allocateResponseVariableName(candidate, /* @__PURE__ */ new Set([candidate]), maxLength);
|
|
479
|
+
writePath(node, path, candidate);
|
|
480
|
+
if (before.every((name, index) => name === names()[index])) throw new Error(`Element '${node.elementType}' does not honor its declared naming location`);
|
|
481
|
+
}
|
|
482
|
+
names().forEach((name) => used.add(name));
|
|
483
|
+
}
|
|
484
|
+
return node;
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
//#endregion
|
|
488
|
+
//#region src/editor/item-copy-paste.ts
|
|
489
|
+
/** Only the current format is accepted. Parsing is repeated at the paste boundary. */
|
|
490
|
+
function createItemClipboardSchema(registry) {
|
|
491
|
+
const shape = registry.surveySchema.shape;
|
|
492
|
+
return z.strictObject({
|
|
493
|
+
type: z.literal("survey-item"),
|
|
494
|
+
version: z.literal(2),
|
|
495
|
+
rootItemId: referenceId("item"),
|
|
496
|
+
surveyItems: shape.surveyItems,
|
|
497
|
+
translations: shape.translations,
|
|
498
|
+
assets: shape.assets
|
|
499
|
+
}).superRefine((payload, ctx) => {
|
|
500
|
+
const items = new Map(payload.surveyItems.map((item) => [item.id, item]));
|
|
501
|
+
const visited = /* @__PURE__ */ new Set();
|
|
502
|
+
const visit = (id) => {
|
|
503
|
+
if (visited.has(id)) {
|
|
504
|
+
ctx.addIssue({
|
|
505
|
+
code: "custom",
|
|
506
|
+
message: "Clipboard flow must be a tree"
|
|
507
|
+
});
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
visited.add(id);
|
|
511
|
+
const item = items.get(id);
|
|
512
|
+
if (!item) {
|
|
513
|
+
ctx.addIssue({
|
|
514
|
+
code: "custom",
|
|
515
|
+
message: "Clipboard flow cannot reference an external child"
|
|
516
|
+
});
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (item.itemType === "group") {
|
|
520
|
+
if (item.config.isRoot) ctx.addIssue({
|
|
521
|
+
code: "custom",
|
|
522
|
+
message: "A root group cannot be pasted"
|
|
523
|
+
});
|
|
524
|
+
item.config.items.forEach(visit);
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
visit(payload.rootItemId);
|
|
528
|
+
if (items.size !== payload.surveyItems.length || visited.size !== items.size) ctx.addIssue({
|
|
529
|
+
code: "custom",
|
|
530
|
+
message: "Clipboard contains duplicate or disconnected flow items"
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
function copySurveyItem(survey, itemId) {
|
|
535
|
+
if (!survey.getItem(itemId)) throw new Error(`Unknown item '${itemId}'`);
|
|
536
|
+
const items = survey.data.surveyItems.filter((item) => survey.isDescendantOf(item.id, itemId));
|
|
537
|
+
const itemIds = new Set(items.map((item) => item.id));
|
|
538
|
+
const owners = new Set([...survey.owners.values()].filter((owner) => itemIds.has(owner.itemId)).map((owner) => owner.id));
|
|
539
|
+
const payload = {
|
|
540
|
+
type: "survey-item",
|
|
541
|
+
version: 2,
|
|
542
|
+
rootItemId: itemId,
|
|
543
|
+
surveyItems: structuredClone(items).map((item) => item.itemType === "group" && item.config.isRoot ? {
|
|
544
|
+
...item,
|
|
545
|
+
config: {
|
|
546
|
+
...item.config,
|
|
547
|
+
isRoot: false
|
|
548
|
+
}
|
|
549
|
+
} : item),
|
|
550
|
+
translations: Object.fromEntries(Object.entries(survey.data.translations ?? {}).flatMap(([locale, translation]) => {
|
|
551
|
+
const content = Object.fromEntries(Object.entries(translation.content).filter(([id, roles]) => owners.has(id) && Object.keys(roles).length > 0));
|
|
552
|
+
return Object.keys(content).length ? [[locale, { content }]] : [];
|
|
553
|
+
}))
|
|
554
|
+
};
|
|
555
|
+
const schema = createItemClipboardSchema(survey.registry);
|
|
556
|
+
const assetIds = new Set(inspectAnnotated(schema, payload).flatMap(({ annotation, value }) => annotation.kind === "reference" && annotation.target === "asset" && typeof value === "string" ? [value] : []));
|
|
557
|
+
payload.assets = Object.fromEntries([...assetIds].map((id) => [id, ownValue(survey.data.assets, id)]));
|
|
558
|
+
return schema.parse(payload);
|
|
559
|
+
}
|
|
560
|
+
/** Prepare a detached payload; the editor publishes it only after whole-document validation. */
|
|
561
|
+
function prepareItemPaste(survey, input) {
|
|
562
|
+
checkDocumentBudget(input);
|
|
563
|
+
const schema = createItemClipboardSchema(survey.registry);
|
|
564
|
+
const payload = schema.parse(input);
|
|
565
|
+
const ids = /* @__PURE__ */ new Map();
|
|
566
|
+
for (const { annotation, value } of inspectAnnotated(schema, payload)) {
|
|
567
|
+
if (annotation.kind !== "identity" || typeof value !== "string") continue;
|
|
568
|
+
if (ids.has(value)) throw new Error(`Duplicate clipboard identity '${value}'`);
|
|
569
|
+
const reuse = annotation.identity === "asset" && JSON.stringify(ownValue(survey.data.assets, value)) === JSON.stringify(ownValue(payload.assets, value));
|
|
570
|
+
ids.set(value, reuse ? value : generateId());
|
|
571
|
+
}
|
|
572
|
+
return allocateCopiedResponseNames(schema, remapAnnotated(schema, payload, ids), survey.registry, new Set(survey.slots.definitions.map((slot) => slot.variableName)));
|
|
573
|
+
}
|
|
574
|
+
//#endregion
|
|
575
|
+
//#region src/editor/session-schema.ts
|
|
576
|
+
/** The saved editor session is separate from both survey definitions and participant resume state. */
|
|
577
|
+
function createEditorSessionSchema(registry) {
|
|
578
|
+
const survey = registry.surveySchema;
|
|
579
|
+
const asset = registry.surveySchema.shape.assets.unwrap().valueType;
|
|
580
|
+
const assets = stringRecord(z.string().min(1), asset);
|
|
581
|
+
const patch = z.strictObject({
|
|
582
|
+
assetId: z.string().min(1),
|
|
583
|
+
prev: asset.optional(),
|
|
584
|
+
next: asset.optional()
|
|
585
|
+
});
|
|
586
|
+
const entry = {
|
|
587
|
+
id: z.string().min(1).optional(),
|
|
588
|
+
timestamp: z.number().finite(),
|
|
589
|
+
memorySize: z.number().nonnegative().finite(),
|
|
590
|
+
meta: z.strictObject({
|
|
591
|
+
label: z.string(),
|
|
592
|
+
source: z.enum(CommitSource).optional()
|
|
593
|
+
})
|
|
594
|
+
};
|
|
595
|
+
return z.strictObject({
|
|
596
|
+
version: z.literal(2),
|
|
597
|
+
survey,
|
|
598
|
+
hasUncommittedChanges: z.boolean(),
|
|
599
|
+
answerTypes: answerTypeReservationsSchema,
|
|
600
|
+
undoRedo: z.strictObject({
|
|
601
|
+
history: z.array(z.discriminatedUnion("kind", [z.strictObject({
|
|
602
|
+
...entry,
|
|
603
|
+
kind: z.literal("survey-snapshot"),
|
|
604
|
+
survey,
|
|
605
|
+
changes: z.array(patch).optional()
|
|
606
|
+
}), z.strictObject({
|
|
607
|
+
...entry,
|
|
608
|
+
kind: z.literal("asset-change"),
|
|
609
|
+
changes: z.array(patch)
|
|
610
|
+
})])).min(1),
|
|
611
|
+
currentIndex: z.number().int().nonnegative(),
|
|
612
|
+
initialAssets: assets.optional(),
|
|
613
|
+
config: z.strictObject({
|
|
614
|
+
maxTotalMemoryMB: z.number().positive().finite(),
|
|
615
|
+
minHistorySize: z.number().int().nonnegative(),
|
|
616
|
+
maxHistorySize: z.number().int().positive()
|
|
617
|
+
})
|
|
618
|
+
}).superRefine((value, context) => {
|
|
619
|
+
if (value.currentIndex >= value.history.length) context.addIssue({
|
|
620
|
+
code: "custom",
|
|
621
|
+
path: ["currentIndex"],
|
|
622
|
+
message: "History index is outside the stored history"
|
|
623
|
+
});
|
|
624
|
+
if (value.history[0]?.kind !== "survey-snapshot") context.addIssue({
|
|
625
|
+
code: "custom",
|
|
626
|
+
path: ["history", 0],
|
|
627
|
+
message: "History must start with a survey snapshot"
|
|
628
|
+
});
|
|
629
|
+
})
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
//#endregion
|
|
525
633
|
//#region src/editor/survey-editor.ts
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
634
|
+
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
635
|
+
function at(input, path) {
|
|
636
|
+
let value = input;
|
|
637
|
+
for (const part of path) if (Array.isArray(value) && typeof part === "number") value = value[part];
|
|
638
|
+
else if (record(value)) value = ownValue(value, String(part));
|
|
639
|
+
else throw new Error(`Invalid derived location ${JSON.stringify(path)}`);
|
|
640
|
+
return value;
|
|
641
|
+
}
|
|
642
|
+
function arrayAt(input, path) {
|
|
643
|
+
const value = at(input, path);
|
|
644
|
+
if (!Array.isArray(value)) throw new Error("Expected array at derived location");
|
|
645
|
+
return value;
|
|
646
|
+
}
|
|
647
|
+
function setAt(input, path, value) {
|
|
648
|
+
const parent = at(input, path.slice(0, -1)), part = path.at(-1);
|
|
649
|
+
if (Array.isArray(parent) && typeof part === "number") parent[part] = value;
|
|
650
|
+
else if (record(parent) && typeof part === "string") setOwnValue(parent, part, value);
|
|
651
|
+
else throw new Error("Invalid mutation location");
|
|
652
|
+
}
|
|
653
|
+
const inside = (ancestor, path) => ancestor.length <= path.length && ancestor.every((part, i) => path[i] === part);
|
|
654
|
+
function stripAssets(survey) {
|
|
655
|
+
const { assets: _assets, ...snapshot } = survey;
|
|
529
656
|
return snapshot;
|
|
530
657
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if (
|
|
534
|
-
|
|
535
|
-
return
|
|
658
|
+
/** Compare persisted JSON semantics without depending on object property insertion order. */
|
|
659
|
+
function equal(left, right) {
|
|
660
|
+
if (left === right) return true;
|
|
661
|
+
if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => equal(value, right[index]));
|
|
662
|
+
if (!record(left) || !record(right)) return false;
|
|
663
|
+
const keys = Object.keys(left).filter((key) => left[key] !== void 0);
|
|
664
|
+
return keys.length === Object.keys(right).filter((key) => right[key] !== void 0).length && keys.every((key) => Object.hasOwn(right, key) && equal(left[key], right[key]));
|
|
665
|
+
}
|
|
666
|
+
/** Element mutations may edit their owned wording, never another owner's content or survey UI. */
|
|
667
|
+
function assertElementTranslationScope(before, after, owners) {
|
|
668
|
+
const surfaces = (translation) => Object.fromEntries(Object.entries(translation ?? {}).filter(([key]) => key !== "content"));
|
|
669
|
+
for (const locale of /* @__PURE__ */ new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])) {
|
|
670
|
+
const previous = ownValue(before, locale), next = ownValue(after, locale);
|
|
671
|
+
if (!previous || !next || !equal(surfaces(previous), surfaces(next))) throw new Error(`Element edits cannot change survey-level translations or add/remove locale '${locale}'`);
|
|
672
|
+
for (const owner of /* @__PURE__ */ new Set([...Object.keys(previous?.content ?? {}), ...Object.keys(next?.content ?? {})])) if (!owners.has(owner) && !equal(ownValue(previous?.content, owner), ownValue(next?.content, owner))) throw new Error(`Element edits cannot change content owned by '${owner}' in locale '${locale}'`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/** Derive modification ownership from the committed revision, including nested localized content. */
|
|
676
|
+
function timestampEditedItems(before, after) {
|
|
677
|
+
const previous = new Map(before.surveyItems.map((item) => [item.id, item]));
|
|
678
|
+
const positions = (items) => new Map(items.flatMap((item) => item.itemType === "group" ? item.config.items.map((id, index) => [id, [item.id, index]]) : []));
|
|
679
|
+
const previousPositions = positions(before.surveyItems);
|
|
680
|
+
const nextPositions = positions(after.data.surveyItems);
|
|
681
|
+
const changed = /* @__PURE__ */ new Set();
|
|
682
|
+
for (const item of after.data.surveyItems) if (!equal(previous.get(item.id), item) || !equal(previousPositions.get(item.id), nextPositions.get(item.id))) changed.add(item.id);
|
|
683
|
+
for (const locale of /* @__PURE__ */ new Set([...Object.keys(before.translations ?? {}), ...after.locales])) {
|
|
684
|
+
const oldContent = ownValue(before.translations, locale)?.content ?? {};
|
|
685
|
+
const newContent = ownValue(after.data.translations, locale)?.content ?? {};
|
|
686
|
+
for (const id of /* @__PURE__ */ new Set([...Object.keys(oldContent), ...Object.keys(newContent)])) {
|
|
687
|
+
if (equal(ownValue(oldContent, id), ownValue(newContent, id))) continue;
|
|
688
|
+
const owner = after.owners.get(id);
|
|
689
|
+
if (owner) changed.add(owner.itemId);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
693
|
+
return after.withItemEditorMetadata(new Map([...changed].map((id) => {
|
|
694
|
+
const original = previous.get(id);
|
|
695
|
+
return [id, {
|
|
696
|
+
createdAt: original ? original.editorMetadata?.createdAt ?? timestamp : timestamp,
|
|
697
|
+
updatedAt: timestamp
|
|
698
|
+
}];
|
|
699
|
+
})));
|
|
700
|
+
}
|
|
701
|
+
/** A surviving answer identity cannot acquire a different wire value type. */
|
|
702
|
+
function getAnswerTypeChangeDiagnostics(before, after) {
|
|
703
|
+
if (before.slots === after.slots) return [];
|
|
704
|
+
return answerTypeDiagnostics(after, (id) => before.slots.get(id)?.valueType);
|
|
536
705
|
}
|
|
706
|
+
function answerTypeDiagnostics(after, previousType) {
|
|
707
|
+
return after.slots.definitions.flatMap((slot) => {
|
|
708
|
+
const previous = previousType(slot.slotId);
|
|
709
|
+
return previous && previous !== slot.valueType ? [{
|
|
710
|
+
code: "answer-type-change",
|
|
711
|
+
severity: "error",
|
|
712
|
+
path: after.identities.get(slot.slotId)?.path ?? after.elements.get(slot.elementId).path,
|
|
713
|
+
message: `Answer '${slot.variableName}' (${slot.slotId}) cannot change from ${previous} to ${slot.valueType} while keeping its ID. Allocate a new answer ID and explicitly repair its references, prefills and export settings in the same transaction.`
|
|
714
|
+
}] : [];
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
function assertStableAnswerTypes(before, after) {
|
|
718
|
+
const diagnostics = getAnswerTypeChangeDiagnostics(before, after);
|
|
719
|
+
if (diagnostics.length) throw new DefinitionError(diagnostics);
|
|
720
|
+
}
|
|
721
|
+
/** All human and assistant operations publish through this transaction/history owner. */
|
|
537
722
|
var SurveyEditor = class SurveyEditor {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
723
|
+
current;
|
|
724
|
+
answerTypes = /* @__PURE__ */ new Map();
|
|
725
|
+
rememberedSlots;
|
|
726
|
+
_history;
|
|
727
|
+
listeners = /* @__PURE__ */ new Set();
|
|
728
|
+
mutationEpoch = 0;
|
|
729
|
+
fieldWriteDepth = 0;
|
|
730
|
+
actionDepth = 0;
|
|
731
|
+
actionMeta;
|
|
732
|
+
pendingSurvey = false;
|
|
733
|
+
pendingAssets = [];
|
|
734
|
+
pendingMeta;
|
|
735
|
+
constructor(survey, options = {}, meta) {
|
|
736
|
+
const { answerTypes, ...config } = options;
|
|
737
|
+
this.answerTypes = new Map(answerTypeReservationsSchema.parse(answerTypes ?? []));
|
|
738
|
+
const diagnostics = answerTypeDiagnostics(survey, (id) => this.answerTypes.get(id));
|
|
739
|
+
if (diagnostics.length) throw new DefinitionError(diagnostics);
|
|
740
|
+
this.current = survey;
|
|
741
|
+
this.rememberAnswerTypes(survey);
|
|
546
742
|
const serialized = survey.serialize();
|
|
547
|
-
this.
|
|
743
|
+
this._history = new SurveyEditorUndoRedo(stripAssets(serialized), config, meta, serialized.assets);
|
|
548
744
|
}
|
|
549
|
-
/** Returns an immutable copy of the current survey state. */
|
|
550
745
|
get survey() {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
746
|
+
return this.current;
|
|
747
|
+
}
|
|
748
|
+
get history() {
|
|
749
|
+
return this._history;
|
|
750
|
+
}
|
|
751
|
+
getAnswerTypes() {
|
|
752
|
+
return [...this.answerTypes];
|
|
554
753
|
}
|
|
555
754
|
get hasUncommittedChanges() {
|
|
556
|
-
return this.
|
|
755
|
+
return this.pendingSurvey || this.pendingAssets.length > 0;
|
|
557
756
|
}
|
|
558
|
-
get
|
|
559
|
-
return this.
|
|
757
|
+
get hasCommittedChanges() {
|
|
758
|
+
return this.history.getCurrentIndex() > 0;
|
|
560
759
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
760
|
+
subscribe(listener) {
|
|
761
|
+
this.listeners.add(listener);
|
|
762
|
+
return () => {
|
|
763
|
+
this.listeners.delete(listener);
|
|
764
|
+
};
|
|
565
765
|
}
|
|
566
|
-
|
|
567
|
-
this.
|
|
766
|
+
notify() {
|
|
767
|
+
this.listeners.forEach((listener) => listener());
|
|
768
|
+
}
|
|
769
|
+
rememberAnswerTypes(survey) {
|
|
770
|
+
if (this.rememberedSlots === survey.slots) return;
|
|
771
|
+
for (const slot of survey.slots.definitions) this.answerTypes.set(slot.slotId, slot.valueType);
|
|
772
|
+
this.rememberedSlots = survey.slots;
|
|
773
|
+
}
|
|
774
|
+
publish(next) {
|
|
775
|
+
this.current = next;
|
|
776
|
+
if (this.actionDepth) return;
|
|
777
|
+
this.rememberAnswerTypes(next);
|
|
778
|
+
this.mutationEpoch++;
|
|
779
|
+
this.notify();
|
|
780
|
+
}
|
|
781
|
+
trackPending(next, meta) {
|
|
782
|
+
this.pendingSurvey = !equal(stripAssets(next.data), this.history.getCurrentState());
|
|
783
|
+
const previous = this.history.getCurrentAssets() ?? {}, assets = next.data.assets ?? {};
|
|
784
|
+
this.pendingAssets = [.../* @__PURE__ */ new Set([...Object.keys(previous), ...Object.keys(assets)])].filter((id) => !equal(ownValue(previous, id), ownValue(assets, id))).map((assetId) => ({
|
|
785
|
+
assetId,
|
|
786
|
+
prev: ownValue(previous, assetId),
|
|
787
|
+
next: ownValue(assets, assetId)
|
|
788
|
+
}));
|
|
789
|
+
this.pendingMeta ??= meta;
|
|
790
|
+
}
|
|
791
|
+
accept(next, meta, staged = false) {
|
|
792
|
+
if (equal(next.data, this.current.data)) return;
|
|
793
|
+
assertStableAnswerTypes(this.current, next);
|
|
794
|
+
if (this.current.slots !== next.slots) {
|
|
795
|
+
const diagnostics = answerTypeDiagnostics(next, (id) => this.answerTypes.get(id));
|
|
796
|
+
if (diagnostics.length) throw new DefinitionError(diagnostics);
|
|
797
|
+
const existing = new Set(getDefaultProjectionDiagnostics(this.current).flatMap((issue) => issue.code === "duplicate-header" ? [issue.header] : []));
|
|
798
|
+
const introduced = getDefaultProjectionDiagnostics(next).filter((issue) => issue.code === "duplicate-header" && !existing.has(issue.header));
|
|
799
|
+
if (introduced.length) throw new Error(introduced.map((issue) => issue.message).join("\n"));
|
|
800
|
+
}
|
|
801
|
+
if (!this.fieldWriteDepth && !this.actionDepth && !staged) {
|
|
802
|
+
const pending = this.current;
|
|
803
|
+
this.commitIfNeeded();
|
|
804
|
+
if (this.current !== pending) {
|
|
805
|
+
const metadata = /* @__PURE__ */ new Map();
|
|
806
|
+
for (const item of next.data.surveyItems) {
|
|
807
|
+
const before = pending.getItem(item.id)?.editorMetadata;
|
|
808
|
+
const committed = this.current.getItem(item.id)?.editorMetadata;
|
|
809
|
+
if (committed && equal(item.editorMetadata, before) && !equal(before, committed)) metadata.set(item.id, committed);
|
|
810
|
+
}
|
|
811
|
+
next = next.withItemEditorMetadata(metadata);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
const baseline = this.current;
|
|
815
|
+
const pending = {
|
|
816
|
+
survey: this.pendingSurvey,
|
|
817
|
+
assets: this.pendingAssets,
|
|
818
|
+
meta: this.pendingMeta
|
|
819
|
+
};
|
|
820
|
+
try {
|
|
821
|
+
this.trackPending(next, meta);
|
|
822
|
+
this.current = next;
|
|
823
|
+
if (!this.fieldWriteDepth && !this.actionDepth && !staged) this.recordCommit(meta);
|
|
824
|
+
} catch (error) {
|
|
825
|
+
this.current = baseline;
|
|
826
|
+
this.pendingSurvey = pending.survey;
|
|
827
|
+
this.pendingAssets = pending.assets;
|
|
828
|
+
this.pendingMeta = pending.meta;
|
|
829
|
+
throw error;
|
|
830
|
+
}
|
|
831
|
+
this.publish(this.current);
|
|
832
|
+
}
|
|
833
|
+
transact(meta, operation) {
|
|
834
|
+
const draft = this.current.serialize();
|
|
835
|
+
const hadLocales = this.current.locales.length > 0;
|
|
836
|
+
operation(draft);
|
|
837
|
+
const first = Object.keys(draft.translations ?? {})[0];
|
|
838
|
+
if (!hadLocales && first !== void 0) draft.defaultLocale ??= first;
|
|
839
|
+
const next = Survey.parse(draft, this.current.registry, { admission: "draft" });
|
|
840
|
+
new SurveyStateGraph(next, { admission: "draft" });
|
|
841
|
+
this.accept(next, meta);
|
|
842
|
+
}
|
|
843
|
+
/** Synchronous compound UI action: validated writes, one publication/history entry, rollback on failure. */
|
|
844
|
+
runAction(operation) {
|
|
845
|
+
if (!this.actionDepth) this.commitIfNeeded();
|
|
846
|
+
const baseline = this.current;
|
|
847
|
+
const pending = {
|
|
848
|
+
survey: this.pendingSurvey,
|
|
849
|
+
assets: this.pendingAssets,
|
|
850
|
+
meta: this.pendingMeta,
|
|
851
|
+
actionMeta: this.actionMeta
|
|
852
|
+
};
|
|
853
|
+
this.actionDepth++;
|
|
854
|
+
let result;
|
|
855
|
+
try {
|
|
856
|
+
result = operation();
|
|
857
|
+
if (result && typeof result === "object" && "then" in result) throw new Error("Editor actions must be synchronous");
|
|
858
|
+
assertStableAnswerTypes(baseline, this.current);
|
|
859
|
+
} catch (error) {
|
|
860
|
+
this.current = baseline;
|
|
861
|
+
this.pendingSurvey = pending.survey;
|
|
862
|
+
this.pendingAssets = pending.assets;
|
|
863
|
+
this.pendingMeta = pending.meta;
|
|
864
|
+
this.actionMeta = pending.actionMeta;
|
|
865
|
+
throw error;
|
|
866
|
+
} finally {
|
|
867
|
+
this.actionDepth--;
|
|
868
|
+
}
|
|
869
|
+
if (!this.actionDepth) {
|
|
870
|
+
const meta = this.actionMeta ?? this.pendingMeta ?? { label: "Edit survey" };
|
|
871
|
+
this.actionMeta = void 0;
|
|
872
|
+
const changed = !equal(baseline.data, this.current.data);
|
|
873
|
+
if (!changed) {
|
|
874
|
+
this.current = baseline;
|
|
875
|
+
this.pendingSurvey = pending.survey;
|
|
876
|
+
this.pendingAssets = pending.assets;
|
|
877
|
+
this.pendingMeta = pending.meta;
|
|
878
|
+
}
|
|
879
|
+
if (changed) {
|
|
880
|
+
try {
|
|
881
|
+
this.recordCommit(meta);
|
|
882
|
+
} catch (error) {
|
|
883
|
+
this.current = baseline;
|
|
884
|
+
this.pendingSurvey = pending.survey;
|
|
885
|
+
this.pendingAssets = pending.assets;
|
|
886
|
+
this.pendingMeta = pending.meta;
|
|
887
|
+
this.actionMeta = pending.actionMeta;
|
|
888
|
+
throw error;
|
|
889
|
+
}
|
|
890
|
+
this.publish(this.current);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return result;
|
|
894
|
+
}
|
|
895
|
+
getLocalizedContent(ref, locale) {
|
|
896
|
+
return readLocalizationContent(this.current, ref, locale);
|
|
897
|
+
}
|
|
898
|
+
setLocalizedContent(ref, locale, content, meta = { label: "Edit translated content" }) {
|
|
899
|
+
let next = this.current.withLocalizationContent(ref, locale, content);
|
|
900
|
+
if (!this.current.locales.length && next.locales.length && next.data.defaultLocale === void 0) next = next.withDefaultLocale(next.locales[0]);
|
|
901
|
+
this.accept(next, meta);
|
|
902
|
+
}
|
|
903
|
+
/** Reuses the editor's field session boundary; no separate editable survey or history. */
|
|
904
|
+
beginFieldEdit(itemId) {
|
|
905
|
+
if (this.hasUncommittedChanges || this.actionDepth) throw new Error("Finish pending changes before editing a field");
|
|
906
|
+
if (itemId && !this.current.data.surveyItems.some((item) => item.id === itemId)) throw new Error(`Unknown item '${itemId}'`);
|
|
907
|
+
let expectedEpoch = this.mutationEpoch;
|
|
908
|
+
const history = this.history, index = history.getCurrentIndex();
|
|
909
|
+
const owns = () => this.mutationEpoch === expectedEpoch && this.history === history && history.getCurrentIndex() === index;
|
|
910
|
+
const run = (write) => {
|
|
911
|
+
if (!owns()) throw new Error("Field edit was superseded by another action");
|
|
912
|
+
this.fieldWriteDepth++;
|
|
913
|
+
try {
|
|
914
|
+
return write();
|
|
915
|
+
} finally {
|
|
916
|
+
this.fieldWriteDepth--;
|
|
917
|
+
expectedEpoch = this.mutationEpoch;
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
return {
|
|
921
|
+
run,
|
|
922
|
+
reconcile: (restoreValue) => {
|
|
923
|
+
if (!owns()) return false;
|
|
924
|
+
if (restoreValue) run(restoreValue);
|
|
925
|
+
this.restoreHistoryState(history.getCurrentState());
|
|
926
|
+
expectedEpoch = this.mutationEpoch;
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
recordCommit(meta) {
|
|
932
|
+
if (!this.hasUncommittedChanges) return false;
|
|
933
|
+
if (this.pendingSurvey) {
|
|
934
|
+
const stamped = timestampEditedItems(this.history.getCurrentState(), this.current);
|
|
935
|
+
this.history.commit(stripAssets(stamped.serialize()), meta, this.pendingAssets);
|
|
936
|
+
this.current = stamped;
|
|
937
|
+
} else this.history.commitAssetChange(this.pendingAssets, meta);
|
|
938
|
+
this.pendingSurvey = false;
|
|
939
|
+
this.pendingAssets = [];
|
|
940
|
+
this.pendingMeta = void 0;
|
|
941
|
+
this.mutationEpoch++;
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
944
|
+
commit(meta) {
|
|
945
|
+
if (this.actionDepth) {
|
|
946
|
+
this.actionMeta = meta;
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (this.recordCommit(meta)) this.notify();
|
|
568
950
|
}
|
|
569
951
|
commitIfNeeded() {
|
|
570
|
-
|
|
952
|
+
this.commit(this.pendingMeta ?? {
|
|
571
953
|
label: "Latest content changes",
|
|
572
954
|
source: CommitSource.SYSTEM
|
|
573
955
|
});
|
|
574
956
|
}
|
|
957
|
+
restoreHistoryState(snapshot) {
|
|
958
|
+
const assets = this.history.getCurrentAssets();
|
|
959
|
+
const next = Survey.parse({
|
|
960
|
+
...snapshot,
|
|
961
|
+
...assets ? { assets } : {}
|
|
962
|
+
}, this.current.registry, {
|
|
963
|
+
previous: this.current,
|
|
964
|
+
admission: "draft"
|
|
965
|
+
});
|
|
966
|
+
this.pendingSurvey = false;
|
|
967
|
+
this.pendingAssets = [];
|
|
968
|
+
this.pendingMeta = void 0;
|
|
969
|
+
this.publish(next);
|
|
970
|
+
}
|
|
575
971
|
undo() {
|
|
576
|
-
if (this.
|
|
577
|
-
|
|
578
|
-
this.
|
|
972
|
+
if (this.actionDepth) throw new Error("History navigation cannot run inside an edit action");
|
|
973
|
+
if (this.hasUncommittedChanges) {
|
|
974
|
+
this.restoreHistoryState(this.history.getCurrentState());
|
|
579
975
|
return true;
|
|
580
|
-
} else {
|
|
581
|
-
const previousState = this._undoRedo.undo();
|
|
582
|
-
if (previousState) {
|
|
583
|
-
this.restoreHistoryState(previousState, this._undoRedo.getCurrentAssets());
|
|
584
|
-
this._hasUncommittedChanges = false;
|
|
585
|
-
return true;
|
|
586
|
-
}
|
|
587
|
-
return false;
|
|
588
976
|
}
|
|
977
|
+
const value = this.history.undo();
|
|
978
|
+
if (!value) return false;
|
|
979
|
+
this.restoreHistoryState(value);
|
|
980
|
+
return true;
|
|
589
981
|
}
|
|
590
982
|
redo() {
|
|
591
|
-
if (this.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
}
|
|
598
|
-
return false;
|
|
983
|
+
if (this.actionDepth) throw new Error("History navigation cannot run inside an edit action");
|
|
984
|
+
if (this.hasUncommittedChanges) return false;
|
|
985
|
+
const value = this.history.redo();
|
|
986
|
+
if (!value) return false;
|
|
987
|
+
this.restoreHistoryState(value);
|
|
988
|
+
return true;
|
|
599
989
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
if (
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
this.restoreHistoryState(targetState, this._undoRedo.getCurrentAssets());
|
|
608
|
-
this._hasUncommittedChanges = false;
|
|
609
|
-
return true;
|
|
610
|
-
}
|
|
611
|
-
return false;
|
|
990
|
+
jumpToIndex(index) {
|
|
991
|
+
if (this.actionDepth) throw new Error("History navigation cannot run inside an edit action");
|
|
992
|
+
if (this.hasUncommittedChanges) return false;
|
|
993
|
+
const value = this.history.jumpToIndex(index);
|
|
994
|
+
if (!value) return false;
|
|
995
|
+
this.restoreHistoryState(value);
|
|
996
|
+
return true;
|
|
612
997
|
}
|
|
613
998
|
canUndo() {
|
|
614
|
-
return this.
|
|
999
|
+
return this.hasUncommittedChanges || this.history.canUndo();
|
|
615
1000
|
}
|
|
616
1001
|
canRedo() {
|
|
617
|
-
return !this.
|
|
1002
|
+
return !this.hasUncommittedChanges && this.history.canRedo();
|
|
618
1003
|
}
|
|
619
1004
|
getUndoMeta() {
|
|
620
|
-
|
|
621
|
-
label: "Latest content changes",
|
|
622
|
-
source: CommitSource.SYSTEM
|
|
623
|
-
};
|
|
624
|
-
return this._undoRedo.getUndoMeta();
|
|
1005
|
+
return this.hasUncommittedChanges ? this.pendingMeta ?? null : this.history.getUndoMeta();
|
|
625
1006
|
}
|
|
626
1007
|
getRedoMeta() {
|
|
627
|
-
|
|
628
|
-
return this._undoRedo.getRedoMeta();
|
|
1008
|
+
return this.hasUncommittedChanges ? null : this.history.getRedoMeta();
|
|
629
1009
|
}
|
|
630
1010
|
getMemoryUsage() {
|
|
631
|
-
return this.
|
|
1011
|
+
return this.history.getMemoryUsage();
|
|
632
1012
|
}
|
|
633
1013
|
getUndoRedoConfig() {
|
|
634
|
-
return this.
|
|
1014
|
+
return this.history.getConfig();
|
|
635
1015
|
}
|
|
636
|
-
/**
|
|
637
|
-
* Get committed changes after the initial editor state up to the current undo/redo position.
|
|
638
|
-
*/
|
|
639
1016
|
getCommitsSinceInitial() {
|
|
640
|
-
return this.
|
|
1017
|
+
return this.history.getCommitsSinceInitial();
|
|
641
1018
|
}
|
|
642
|
-
/**
|
|
643
|
-
* Serialize the SurveyEditor state to JSON
|
|
644
|
-
* @returns A JSON-serializable object containing the complete editor state
|
|
645
|
-
*/
|
|
646
1019
|
toJson() {
|
|
647
1020
|
return {
|
|
648
|
-
version:
|
|
649
|
-
survey: this.
|
|
650
|
-
undoRedo: this.
|
|
651
|
-
hasUncommittedChanges: this.
|
|
1021
|
+
version: 2,
|
|
1022
|
+
survey: this.current.serialize(),
|
|
1023
|
+
undoRedo: structuredClone(this.history.serialize()),
|
|
1024
|
+
hasUncommittedChanges: this.hasUncommittedChanges,
|
|
1025
|
+
answerTypes: this.getAnswerTypes()
|
|
652
1026
|
};
|
|
653
1027
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
|
|
1028
|
+
static fromJson(input, registry) {
|
|
1029
|
+
const data = createEditorSessionSchema(registry).parse(input);
|
|
1030
|
+
const answerTypes = new Map(data.answerTypes);
|
|
1031
|
+
const checkAnswerTypes = (survey) => {
|
|
1032
|
+
const diagnostics = answerTypeDiagnostics(survey, (id) => answerTypes.get(id));
|
|
1033
|
+
if (diagnostics.length) throw new DefinitionError(diagnostics);
|
|
1034
|
+
for (const slot of survey.slots.definitions) if (!answerTypes.has(slot.slotId)) throw new Error(`Stored answer type is missing for '${slot.slotId}'`);
|
|
1035
|
+
};
|
|
1036
|
+
let historySurvey;
|
|
1037
|
+
let previous;
|
|
1038
|
+
let currentHistoryRevision;
|
|
1039
|
+
const assets = structuredClone(data.undoRedo.initialAssets ?? {});
|
|
1040
|
+
for (const [index, entry] of data.undoRedo.history.entries()) {
|
|
1041
|
+
for (const patch of entry.changes ?? []) {
|
|
1042
|
+
if (!equal(ownValue(assets, patch.assetId), patch.prev)) throw new Error(`Inconsistent asset history for '${patch.assetId}'`);
|
|
1043
|
+
if (patch.next) setOwnValue(assets, patch.assetId, patch.next);
|
|
1044
|
+
else delete assets[patch.assetId];
|
|
1045
|
+
}
|
|
1046
|
+
if (entry.kind === "survey-snapshot") {
|
|
1047
|
+
if (entry.survey.assets !== void 0) throw new Error("History snapshots must use the shared asset history");
|
|
1048
|
+
historySurvey = entry.survey;
|
|
1049
|
+
}
|
|
1050
|
+
if (!historySurvey) throw new Error("History requires an initial survey snapshot");
|
|
1051
|
+
const snapshot = Survey.parse({
|
|
1052
|
+
...historySurvey,
|
|
1053
|
+
...Object.keys(assets).length ? { assets } : {}
|
|
1054
|
+
}, registry, {
|
|
1055
|
+
previous,
|
|
1056
|
+
admission: "draft"
|
|
1057
|
+
});
|
|
1058
|
+
if (snapshot.slots !== previous?.slots) new SurveyStateGraph(snapshot, { admission: "draft" });
|
|
1059
|
+
checkAnswerTypes(snapshot);
|
|
1060
|
+
previous = snapshot;
|
|
1061
|
+
if (index === data.undoRedo.currentIndex) currentHistoryRevision = snapshot;
|
|
1062
|
+
}
|
|
1063
|
+
const editor = new SurveyEditor(Survey.parse(data.survey, registry, {
|
|
1064
|
+
previous: currentHistoryRevision,
|
|
1065
|
+
admission: "draft"
|
|
1066
|
+
}));
|
|
1067
|
+
if (editor.survey.slots !== currentHistoryRevision?.slots) new SurveyStateGraph(editor.survey, { admission: "draft" });
|
|
1068
|
+
checkAnswerTypes(editor.survey);
|
|
1069
|
+
editor.answerTypes = answerTypes;
|
|
1070
|
+
editor._history = SurveyEditorUndoRedo.deserialize(data.undoRedo);
|
|
1071
|
+
editor.trackPending(editor.current);
|
|
1072
|
+
if (editor.hasUncommittedChanges !== data.hasUncommittedChanges) throw new Error("Stored pending-edit state does not match the current history revision");
|
|
674
1073
|
return editor;
|
|
675
1074
|
}
|
|
676
|
-
|
|
677
|
-
this.
|
|
678
|
-
}
|
|
679
|
-
createEditorTimestamp() {
|
|
680
|
-
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1075
|
+
getAsset(id) {
|
|
1076
|
+
return structuredClone(ownValue(this.current.data.assets, id));
|
|
681
1077
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
if (!item) return;
|
|
685
|
-
item.setModifiedAt(timestamp);
|
|
1078
|
+
getAssets() {
|
|
1079
|
+
return new Map(Object.entries(structuredClone(this.current.data.assets ?? {})));
|
|
686
1080
|
}
|
|
687
1081
|
addItem(target, item, content) {
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
if (!
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
if (!
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
if (parentGroup.hasChild(item.id)) throw new Error(`Item ${item.id} already in this group`);
|
|
704
|
-
const siblings = Array.from(this._survey.surveyItems.values()).filter((sItem) => parentGroup.items?.includes(sItem.id));
|
|
705
|
-
let counter = 1;
|
|
706
|
-
const originalKey = item.key;
|
|
707
|
-
let uniqueKey = originalKey;
|
|
708
|
-
while (siblings.some((sibling) => sibling.key === uniqueKey)) {
|
|
709
|
-
uniqueKey = `${originalKey}_${counter}`;
|
|
710
|
-
counter++;
|
|
1082
|
+
this.insertItemTree(target, item.id, [item], content);
|
|
1083
|
+
}
|
|
1084
|
+
/** Validate a preset's entire flow subtree together, including forward references. */
|
|
1085
|
+
insertItemTree(target, rootItemId, items, content, meta = { label: "Add item" }) {
|
|
1086
|
+
if (!items.some((item) => item.id === rootItemId)) throw new Error("Preset root is missing");
|
|
1087
|
+
const byId = new Map(items.map((item) => [item.id, item]));
|
|
1088
|
+
if (byId.size !== items.length) throw new Error("Duplicate preset item identity");
|
|
1089
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1090
|
+
const pending = [rootItemId];
|
|
1091
|
+
while (pending.length) {
|
|
1092
|
+
const id = pending.pop();
|
|
1093
|
+
const item = byId.get(id);
|
|
1094
|
+
if (!item || visited.has(id)) throw new Error("Preset must be a self-contained flow tree");
|
|
1095
|
+
visited.add(id);
|
|
1096
|
+
if (item.itemType === "group") pending.push(...item.config.items);
|
|
711
1097
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1098
|
+
if (visited.size !== items.length) throw new Error("Preset contains disconnected items");
|
|
1099
|
+
this.transact(meta, (draft) => {
|
|
1100
|
+
const parent = draft.surveyItems.find((candidate) => candidate.id === target.parentId);
|
|
1101
|
+
if (parent?.itemType !== "group") throw new Error("Item target must be a group");
|
|
1102
|
+
if (target.index !== void 0 && (!Number.isInteger(target.index) || target.index < 0)) throw new Error("Invalid item insertion index");
|
|
1103
|
+
parent.config.items.splice(Math.min(target.index ?? parent.config.items.length, parent.config.items.length), 0, rootItemId);
|
|
1104
|
+
draft.surveyItems.push(...structuredClone(items));
|
|
1105
|
+
if (content) {
|
|
1106
|
+
draft.translations ??= {};
|
|
1107
|
+
const translation = ownValue(draft.translations, content.locale) ?? { content: {} };
|
|
1108
|
+
setOwnValue(draft.translations, content.locale, translation);
|
|
1109
|
+
for (const [owner, roles] of Object.entries(content.content)) {
|
|
1110
|
+
if (this.current.owners.has(owner)) throw new Error(`Initial localized owner '${owner}' already exists`);
|
|
1111
|
+
setOwnValue(translation.content, owner, structuredClone(roles));
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
718
1114
|
});
|
|
719
|
-
item.setCreatedAt(timestamp);
|
|
720
|
-
item.setModifiedAt(timestamp);
|
|
721
|
-
this._survey.surveyItems.set(item.id, item);
|
|
722
|
-
parentGroup.addChild(item.id, insertIndex);
|
|
723
|
-
this.touchItemModifiedMetadata(parentGroup.id, timestamp);
|
|
724
|
-
if (content) this._survey.translations.setItemTranslations(item.id, content);
|
|
725
|
-
}
|
|
726
|
-
removeItem(itemId, nested = false) {
|
|
727
|
-
this.markAsModified();
|
|
728
|
-
const timestamp = this.createEditorTimestamp();
|
|
729
|
-
const item = this._survey.surveyItems.get(itemId);
|
|
730
|
-
if (!item) return false;
|
|
731
|
-
const parentItem = this._survey.getParentItem(itemId);
|
|
732
|
-
if (!parentItem) throw new Error(`Item with id '${itemId}' is the root item`);
|
|
733
|
-
if (item instanceof GroupItemCore) for (const childId of item.getChildrenIds()) this.removeItem(childId, true);
|
|
734
|
-
this._survey.surveyItems.delete(itemId);
|
|
735
|
-
this._survey.translations?.onItemDeleted(itemId);
|
|
736
|
-
if (!nested) {
|
|
737
|
-
parentItem.removeChild(itemId);
|
|
738
|
-
this.touchItemModifiedMetadata(parentItem.id, timestamp);
|
|
739
|
-
}
|
|
740
|
-
return true;
|
|
741
1115
|
}
|
|
742
|
-
|
|
743
|
-
this.
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
if (!
|
|
750
|
-
if (
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
const siblings = Array.from(this._survey.surveyItems.values()).filter((sItem) => targetGroup.hasChild(sItem.id));
|
|
760
|
-
let counter = 1;
|
|
761
|
-
let uniqueKey = item.key;
|
|
762
|
-
while (siblings.some((sibling) => sibling.key === uniqueKey)) {
|
|
763
|
-
uniqueKey = `${item.key}_${counter}`;
|
|
764
|
-
counter++;
|
|
765
|
-
}
|
|
766
|
-
if (uniqueKey !== item.key) item.updateRawItem({
|
|
767
|
-
...item.rawItem,
|
|
768
|
-
key: uniqueKey
|
|
1116
|
+
getItemDataCopy(itemId) {
|
|
1117
|
+
const item = this.current.getItem(itemId);
|
|
1118
|
+
if (!item) throw new Error(`Unknown item '${itemId}'`);
|
|
1119
|
+
return structuredClone(item);
|
|
1120
|
+
}
|
|
1121
|
+
updateItem(itemId, update, meta = { label: "Edit item" }) {
|
|
1122
|
+
const existing = this.current.getItem(itemId);
|
|
1123
|
+
if (!existing) throw new Error(`Unknown item '${itemId}'`);
|
|
1124
|
+
if (update.id !== void 0 && update.id !== itemId) throw new Error("Updating an item cannot change its identity");
|
|
1125
|
+
this.transact(meta, (draft) => {
|
|
1126
|
+
const index = draft.surveyItems.findIndex((item) => item.id === itemId);
|
|
1127
|
+
const next = {
|
|
1128
|
+
...existing,
|
|
1129
|
+
...update,
|
|
1130
|
+
id: itemId
|
|
1131
|
+
};
|
|
1132
|
+
setAt(draft, ["surveyItems", index], next);
|
|
769
1133
|
});
|
|
770
|
-
const insertIndex = newTarget.index !== void 0 ? Math.min(newTarget.index, targetGroup.items.length) : targetGroup.items.length;
|
|
771
|
-
targetGroup.addChild(itemId, insertIndex);
|
|
772
|
-
this.touchItemModifiedMetadata(targetGroup.id, timestamp);
|
|
773
|
-
this.touchItemModifiedMetadata(itemId, timestamp);
|
|
774
|
-
return true;
|
|
775
1134
|
}
|
|
776
|
-
/**
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1135
|
+
/** Repair answer reads only; state targets and previous-survey prefill references are distinct. */
|
|
1136
|
+
rewriteAnswerReferences(replacements) {
|
|
1137
|
+
let changed = 0;
|
|
1138
|
+
const raw = transformAnnotated(this.current.registry.surveySchema, this.current.data, ({ annotation, value }) => {
|
|
1139
|
+
if (annotation.kind !== "expression") return value;
|
|
1140
|
+
return transformExpression(expressionSchema.parse(value), {
|
|
1141
|
+
const: (node) => node,
|
|
1142
|
+
function: (node) => node,
|
|
1143
|
+
contextVariable: (node) => node,
|
|
1144
|
+
ownerState: (node) => node,
|
|
1145
|
+
responseVariable: (node) => {
|
|
1146
|
+
const slotId = ownValue(replacements, node.variableRef.slotId);
|
|
1147
|
+
if (!slotId || slotId === node.variableRef.slotId) return node;
|
|
1148
|
+
changed++;
|
|
1149
|
+
return {
|
|
1150
|
+
...node,
|
|
1151
|
+
variableRef: {
|
|
1152
|
+
...node.variableRef,
|
|
1153
|
+
slotId
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
793
1158
|
});
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
order.set(childId, orderIndex++);
|
|
799
|
-
const child = this._survey.surveyItems.get(childId);
|
|
800
|
-
if (child instanceof GroupItemCore) visit(child);
|
|
801
|
-
}
|
|
802
|
-
};
|
|
803
|
-
const root = this._survey.rootItem;
|
|
804
|
-
if (root) visit(root);
|
|
805
|
-
entries.sort((a, b) => (order.get(a.item.id) ?? 0) - (order.get(b.item.id) ?? 0));
|
|
806
|
-
const selectedSet = new Set(entries.map(({ item }) => item.id));
|
|
807
|
-
const requestedIndex = newTarget.index ?? targetGroup.items.length;
|
|
808
|
-
const removedBeforeTarget = targetGroup.items.slice(0, requestedIndex).filter((id) => selectedSet.has(id)).length;
|
|
809
|
-
const insertionIndex = Math.max(0, requestedIndex - removedBeforeTarget);
|
|
810
|
-
const timestamp = this.createEditorTimestamp();
|
|
811
|
-
this.markAsModified();
|
|
812
|
-
for (const { item, parent } of entries) {
|
|
813
|
-
parent.removeChild(item.id);
|
|
814
|
-
this.touchItemModifiedMetadata(parent.id, timestamp);
|
|
1159
|
+
if (changed) {
|
|
1160
|
+
const next = Survey.parse(raw, this.current.registry, { admission: "draft" });
|
|
1161
|
+
new SurveyStateGraph(next, { admission: "draft" });
|
|
1162
|
+
this.accept(next, { label: "Repair answer references" });
|
|
815
1163
|
}
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1164
|
+
return changed;
|
|
1165
|
+
}
|
|
1166
|
+
renameResponseVariables(edits) {
|
|
1167
|
+
return this.updateResponseSettings(edits, []);
|
|
1168
|
+
}
|
|
1169
|
+
updateResponseSettings(naming, settings) {
|
|
1170
|
+
const preview = previewResponseSettings(this.current, naming, settings);
|
|
1171
|
+
const next = Survey.parse(preview.survey, this.current.registry, { admission: "draft" });
|
|
1172
|
+
new SurveyStateGraph(next, { admission: "draft" });
|
|
1173
|
+
this.accept(next, { label: "Edit response variables" });
|
|
1174
|
+
return preview.changes;
|
|
1175
|
+
}
|
|
1176
|
+
addLocale(locale) {
|
|
1177
|
+
if (!locale.trim()) throw new Error("A locale is required");
|
|
1178
|
+
if (Object.hasOwn(this.current.data.translations ?? {}, locale)) return false;
|
|
1179
|
+
this.transact({ label: `Add language: ${locale}` }, (draft) => {
|
|
1180
|
+
draft.translations ??= {};
|
|
1181
|
+
setOwnValue(draft.translations, locale, { content: {} });
|
|
829
1182
|
});
|
|
830
|
-
this.touchItemModifiedMetadata(targetGroup.id, timestamp);
|
|
831
1183
|
return true;
|
|
832
1184
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
if (!item) throw new Error(`Item with id '${itemId}' not found`);
|
|
845
|
-
return JSON.parse(JSON.stringify(item.rawItem));
|
|
1185
|
+
removeLocale(locale) {
|
|
1186
|
+
if (!Object.hasOwn(this.current.data.translations ?? {}, locale)) return false;
|
|
1187
|
+
this.transact({ label: `Remove language: ${locale}` }, (draft) => {
|
|
1188
|
+
delete draft.translations?.[locale];
|
|
1189
|
+
if (draft.defaultLocale === locale) {
|
|
1190
|
+
const next = Object.keys(draft.translations ?? {})[0];
|
|
1191
|
+
if (next === void 0) delete draft.defaultLocale;
|
|
1192
|
+
else draft.defaultLocale = next;
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
return true;
|
|
846
1196
|
}
|
|
847
|
-
/**
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
* Alternatively, pass a Partial to merge specific fields (top-level keys only;
|
|
854
|
-
* e.g. config replaces the entire config object).
|
|
855
|
-
*
|
|
856
|
-
* @param itemId - The ID of the item to update (must match data.id if provided)
|
|
857
|
-
* @param rawItemData - Full RawSurveyItem or Partial to merge. The id cannot be changed.
|
|
858
|
-
*/
|
|
859
|
-
updateItem(itemId, rawItemData) {
|
|
860
|
-
const existingItem = this._survey.surveyItems.get(itemId);
|
|
861
|
-
if (!existingItem) throw new Error(`Item with id '${itemId}' not found`);
|
|
862
|
-
if ("id" in rawItemData && rawItemData.id !== void 0 && rawItemData.id !== itemId) throw new Error(`Cannot change item id from '${itemId}' to '${rawItemData.id}'`);
|
|
863
|
-
const merged = {
|
|
864
|
-
...existingItem.rawItem,
|
|
865
|
-
...rawItemData,
|
|
866
|
-
id: itemId
|
|
867
|
-
};
|
|
868
|
-
const newKey = merged.key ?? existingItem.key;
|
|
869
|
-
const parentGroup = this._survey.getParentItem(itemId);
|
|
870
|
-
if (parentGroup) {
|
|
871
|
-
const siblingWithSameKey = (parentGroup.items ?? []).filter((id) => id !== itemId).map((id) => this._survey.surveyItems.get(id)).find((s) => s !== void 0 && s.key === newKey);
|
|
872
|
-
if (siblingWithSameKey) throw new Error(`Key '${newKey}' is already in use by sibling item '${siblingWithSameKey.id}'`);
|
|
873
|
-
}
|
|
874
|
-
const newItem = this._survey.createItemFromRaw({
|
|
875
|
-
...merged,
|
|
876
|
-
editorMetadata: existingItem.rawItem.editorMetadata
|
|
1197
|
+
/** Sets the source language that exports and hosts fall back to. */
|
|
1198
|
+
setDefaultLocale(locale) {
|
|
1199
|
+
if (!Object.hasOwn(this.current.data.translations ?? {}, locale)) throw new Error(`Unknown locale '${locale}'`);
|
|
1200
|
+
if (this.current.data.defaultLocale === locale) return false;
|
|
1201
|
+
this.transact({ label: `Set default language: ${locale}` }, (draft) => {
|
|
1202
|
+
draft.defaultLocale = locale;
|
|
877
1203
|
});
|
|
878
|
-
newItem.setModifiedAt(this.createEditorTimestamp());
|
|
879
|
-
this._survey.surveyItems.set(itemId, newItem);
|
|
880
|
-
this.markAsModified();
|
|
881
|
-
}
|
|
882
|
-
updateItemTranslations(itemId, updatedContent) {
|
|
883
|
-
const item = this._survey.surveyItems.get(itemId);
|
|
884
|
-
if (!item) return false;
|
|
885
|
-
this.markAsModified();
|
|
886
|
-
item.setModifiedAt(this.createEditorTimestamp());
|
|
887
|
-
this._survey.translations.setItemTranslations(itemId, updatedContent);
|
|
888
1204
|
return true;
|
|
889
1205
|
}
|
|
890
|
-
/**
|
|
891
|
-
* Update survey-level translations that are not tied to specific items.
|
|
892
|
-
* Use these for survey card, navigation, validation messages, etc.
|
|
893
|
-
*/
|
|
894
1206
|
updateSurveyTranslations(updates) {
|
|
895
|
-
this.
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
return true;
|
|
1207
|
+
this.transact({ label: "Edit survey wording" }, (draft) => {
|
|
1208
|
+
const apply = (surface, update) => {
|
|
1209
|
+
if (!update) return;
|
|
1210
|
+
if (!update.locale.trim()) throw new Error("A locale is required");
|
|
1211
|
+
const existing = ownValue(draft.translations, update.locale);
|
|
1212
|
+
if (update.content === void 0 && existing?.[surface] === void 0) return;
|
|
1213
|
+
draft.translations ??= {};
|
|
1214
|
+
const translation = existing ?? { content: {} };
|
|
1215
|
+
setOwnValue(draft.translations, update.locale, translation);
|
|
1216
|
+
if (update.content === void 0) delete translation[surface];
|
|
1217
|
+
else translation[surface] = structuredClone(update.content);
|
|
1218
|
+
};
|
|
1219
|
+
apply("surveyCardContent", updates.surveyCard);
|
|
1220
|
+
apply("navigationContent", updates.navigation);
|
|
1221
|
+
apply("validationMessages", updates.validationMessages);
|
|
1222
|
+
});
|
|
912
1223
|
}
|
|
913
|
-
/**
|
|
914
|
-
* Get maxItemsPerPage immutably (returns a copy).
|
|
915
|
-
*/
|
|
916
1224
|
getMaxItemsPerPage() {
|
|
917
|
-
|
|
918
|
-
return val ? { ...val } : void 0;
|
|
1225
|
+
return structuredClone(this.current.data.maxItemsPerPage);
|
|
919
1226
|
}
|
|
920
|
-
/**
|
|
921
|
-
* Update maxItemsPerPage.
|
|
922
|
-
*/
|
|
923
1227
|
updateMaxItemsPerPage(value) {
|
|
924
|
-
this.
|
|
925
|
-
|
|
1228
|
+
this.transact({ label: "Change page size" }, (draft) => {
|
|
1229
|
+
if (value === void 0) delete draft.maxItemsPerPage;
|
|
1230
|
+
else draft.maxItemsPerPage = structuredClone(value);
|
|
1231
|
+
});
|
|
926
1232
|
}
|
|
927
|
-
/**
|
|
928
|
-
* Get metadata immutably (returns a copy).
|
|
929
|
-
*/
|
|
930
1233
|
getMetadata() {
|
|
931
|
-
|
|
932
|
-
return val ? { ...val } : void 0;
|
|
1234
|
+
return structuredClone(this.current.data.metadata);
|
|
933
1235
|
}
|
|
934
|
-
/**
|
|
935
|
-
* Update metadata.
|
|
936
|
-
*/
|
|
937
1236
|
updateMetadata(metadata) {
|
|
938
|
-
this.
|
|
939
|
-
|
|
1237
|
+
this.transact({ label: "Edit survey metadata" }, (draft) => {
|
|
1238
|
+
if (metadata === void 0) delete draft.metadata;
|
|
1239
|
+
else draft.metadata = structuredClone(metadata);
|
|
1240
|
+
});
|
|
940
1241
|
}
|
|
941
|
-
/**
|
|
942
|
-
* Get a template value immutably (returns a deep copy).
|
|
943
|
-
*/
|
|
944
1242
|
getTemplateValue(key) {
|
|
945
|
-
|
|
946
|
-
return val ? structuredCloneMethod(val) : void 0;
|
|
1243
|
+
return structuredClone(this.current.getTemplateValue(key));
|
|
947
1244
|
}
|
|
948
|
-
/**
|
|
949
|
-
* Get all template values immutably (returns a new Map with deep-copied values).
|
|
950
|
-
*/
|
|
951
1245
|
getTemplateValues() {
|
|
952
|
-
|
|
953
|
-
const result = /* @__PURE__ */ new Map();
|
|
954
|
-
for (const key of keys) {
|
|
955
|
-
const val = this._survey.getTemplateValue(key);
|
|
956
|
-
if (val) result.set(key, structuredCloneMethod(val));
|
|
957
|
-
}
|
|
958
|
-
return result;
|
|
1246
|
+
return new Map(Object.entries(structuredClone(this.current.data.templateValues ?? {})));
|
|
959
1247
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
this._survey.setTemplateValue(key, structuredCloneMethod(templateValue));
|
|
1248
|
+
setTemplateValue(key, value) {
|
|
1249
|
+
this.transact({ label: `Edit template: ${key}` }, (draft) => {
|
|
1250
|
+
draft.templateValues ??= {};
|
|
1251
|
+
setOwnValue(draft.templateValues, key, structuredClone(value));
|
|
1252
|
+
});
|
|
966
1253
|
}
|
|
967
|
-
/**
|
|
968
|
-
* Remove a template value.
|
|
969
|
-
*/
|
|
970
1254
|
removeTemplateValue(key) {
|
|
971
|
-
this.
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
/**
|
|
975
|
-
* Get a survey asset immutably.
|
|
976
|
-
*/
|
|
977
|
-
getAsset(assetId) {
|
|
978
|
-
const asset = this._survey.getAsset(assetId);
|
|
979
|
-
return asset ? structuredCloneMethod(asset) : void 0;
|
|
1255
|
+
this.transact({ label: `Remove template: ${key}` }, (draft) => {
|
|
1256
|
+
delete draft.templateValues?.[key];
|
|
1257
|
+
});
|
|
980
1258
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1259
|
+
stageAsset(id, asset) {
|
|
1260
|
+
const draft = this.current.serialize();
|
|
1261
|
+
draft.assets ??= {};
|
|
1262
|
+
if (asset) setOwnValue(draft.assets, id, asset);
|
|
1263
|
+
else {
|
|
1264
|
+
delete draft.assets[id];
|
|
1265
|
+
if (!Object.keys(draft.assets).length) delete draft.assets;
|
|
1266
|
+
}
|
|
1267
|
+
this.accept(Survey.parse(draft, this.current.registry, { admission: "draft" }), { label: `Update asset: ${id}` }, true);
|
|
986
1268
|
}
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
setAsset(assetId, asset) {
|
|
991
|
-
const previousAsset = this._survey.getAsset(assetId);
|
|
992
|
-
this._survey.setAsset(assetId, structuredCloneMethod(asset));
|
|
993
|
-
this._undoRedo.commitAssetChange([{
|
|
994
|
-
assetId,
|
|
995
|
-
prev: previousAsset,
|
|
996
|
-
next: structuredCloneMethod(asset)
|
|
997
|
-
}], {
|
|
998
|
-
label: `Update asset: ${assetId}`,
|
|
1269
|
+
setAsset(id, asset) {
|
|
1270
|
+
this.transact({
|
|
1271
|
+
label: `Update asset: ${id}`,
|
|
999
1272
|
source: CommitSource.USER
|
|
1273
|
+
}, (draft) => {
|
|
1274
|
+
draft.assets ??= {};
|
|
1275
|
+
setOwnValue(draft.assets, id, asset);
|
|
1000
1276
|
});
|
|
1001
1277
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
removeAsset(assetId) {
|
|
1006
|
-
const previousAsset = this._survey.getAsset(assetId);
|
|
1007
|
-
if (!previousAsset) return;
|
|
1008
|
-
this._survey.deleteAsset(assetId);
|
|
1009
|
-
this._undoRedo.commitAssetChange([{
|
|
1010
|
-
assetId,
|
|
1011
|
-
prev: previousAsset
|
|
1012
|
-
}], {
|
|
1013
|
-
label: `Remove asset: ${assetId}`,
|
|
1278
|
+
removeAsset(id) {
|
|
1279
|
+
this.transact({
|
|
1280
|
+
label: `Remove asset: ${id}`,
|
|
1014
1281
|
source: CommitSource.USER
|
|
1282
|
+
}, (draft) => {
|
|
1283
|
+
if (draft.assets) {
|
|
1284
|
+
delete draft.assets[id];
|
|
1285
|
+
if (!Object.keys(draft.assets).length) delete draft.assets;
|
|
1286
|
+
}
|
|
1015
1287
|
});
|
|
1016
1288
|
}
|
|
1017
|
-
/**
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1289
|
+
/** Shared insertion used by the survey catalog and a layout's element catalog. */
|
|
1290
|
+
insertElement(input, target, meta = { label: "Add element" }, initialContent) {
|
|
1291
|
+
const element = this.current.registry.elementSchema.parse(input);
|
|
1292
|
+
const layoutTarget = target && "layoutId" in target ? target : void 0;
|
|
1293
|
+
const bodyTarget = target && "bodyOwnerId" in target ? target : void 0;
|
|
1294
|
+
const body = bodyTarget ? this.current.bodies.get(bodyTarget.bodyOwnerId) : void 0;
|
|
1295
|
+
if (bodyTarget && !body) throw new Error(`Unknown body owner '${bodyTarget.bodyOwnerId}'`);
|
|
1296
|
+
const flowTarget = target && "parentId" in target ? target : { parentId: this.current.rootId };
|
|
1297
|
+
if (target?.index !== void 0 && (!Number.isInteger(target.index) || target.index < 0)) throw new Error("An insertion index must be a nonnegative integer");
|
|
1298
|
+
if (!layoutTarget && !bodyTarget && this.current.getItem(flowTarget.parentId)?.itemType !== "group") throw new Error(`Target parent '${flowTarget.parentId}' is not a group item`);
|
|
1299
|
+
const itemId = layoutTarget ? this.containingItem(layoutTarget.layoutId) : bodyTarget ? this.containingItem(bodyTarget.bodyOwnerId) : generateId();
|
|
1300
|
+
this.transact(meta, (draft) => {
|
|
1301
|
+
if (initialContent) {
|
|
1302
|
+
draft.translations ??= {};
|
|
1303
|
+
const translation = ownValue(draft.translations, initialContent.locale) ?? { content: {} };
|
|
1304
|
+
setOwnValue(draft.translations, initialContent.locale, translation);
|
|
1305
|
+
for (const [owner, roles] of Object.entries(initialContent.content)) {
|
|
1306
|
+
if (this.current.owners.has(owner)) throw new Error(`Initial localized owner '${owner}' already exists`);
|
|
1307
|
+
setOwnValue(translation.content, owner, structuredClone(roles));
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (layoutTarget) {
|
|
1311
|
+
const elements = arrayAt(draft, [...this.identityPath(layoutTarget.layoutId, "layout"), "elements"]);
|
|
1312
|
+
elements.splice(layoutTarget.index ?? elements.length, 0, element);
|
|
1313
|
+
} else if (body && bodyTarget) {
|
|
1314
|
+
const blocks = arrayAt(draft, [...body.path, "blocks"]);
|
|
1315
|
+
blocks.splice(bodyTarget.index ?? blocks.length, 0, {
|
|
1316
|
+
id: generateId(),
|
|
1317
|
+
kind: "layout",
|
|
1318
|
+
layout: {
|
|
1319
|
+
mode: "automatic",
|
|
1320
|
+
maxColumns: 1
|
|
1321
|
+
},
|
|
1322
|
+
elements: [element]
|
|
1323
|
+
});
|
|
1324
|
+
} else {
|
|
1325
|
+
draft.surveyItems.push({
|
|
1326
|
+
id: itemId,
|
|
1327
|
+
itemType: "content",
|
|
1328
|
+
config: { body: { blocks: [{
|
|
1329
|
+
id: generateId(),
|
|
1330
|
+
kind: "layout",
|
|
1331
|
+
layout: {
|
|
1332
|
+
mode: "automatic",
|
|
1333
|
+
maxColumns: 1
|
|
1334
|
+
},
|
|
1335
|
+
elements: [element]
|
|
1336
|
+
}] } }
|
|
1337
|
+
});
|
|
1338
|
+
const parent = draft.surveyItems.find((item) => item.id === flowTarget.parentId);
|
|
1339
|
+
if (parent?.itemType !== "group") throw new Error("Insertion requires a flow group");
|
|
1340
|
+
parent.config.items.splice(flowTarget.index ?? parent.config.items.length, 0, itemId);
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
return {
|
|
1344
|
+
itemId,
|
|
1345
|
+
elementId: element.id
|
|
1346
|
+
};
|
|
1024
1347
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1348
|
+
moveItem(itemId, target, meta = { label: "Move item" }) {
|
|
1349
|
+
return this.moveItems([itemId], target, meta);
|
|
1350
|
+
}
|
|
1351
|
+
/** Retain the outline's ordered-block move semantics, publishing one validated revision. */
|
|
1352
|
+
moveItems(itemIds, target, meta = { label: "Move items" }) {
|
|
1353
|
+
const survey = this.current;
|
|
1354
|
+
const requested = [...new Set(itemIds)];
|
|
1355
|
+
if (!requested.length) return false;
|
|
1356
|
+
for (const id of requested) {
|
|
1357
|
+
if (!survey.getItem(id)) throw new Error(`Item with id '${id}' not found`);
|
|
1358
|
+
if (id === survey.rootId) throw new Error("The root group cannot be moved");
|
|
1359
|
+
}
|
|
1360
|
+
const destination = survey.getItem(target.parentId);
|
|
1361
|
+
if (!destination) throw new Error(`Target parent with id '${target.parentId}' not found`);
|
|
1362
|
+
if (destination.itemType !== "group") throw new Error(`Target parent '${target.parentId}' is not a group item`);
|
|
1363
|
+
const selected = requested.filter((id) => !requested.some((other) => other !== id && survey.isDescendantOf(id, other)));
|
|
1364
|
+
for (const id of selected) if (survey.isDescendantOf(destination.id, id)) throw new Error(`Cannot move item '${id}' into its own subtree`);
|
|
1365
|
+
if (target.index !== void 0 && (!Number.isInteger(target.index) || target.index < 0)) throw new Error("An insertion index must be a nonnegative integer");
|
|
1366
|
+
const selectedSet = new Set(selected);
|
|
1367
|
+
const ordered = [];
|
|
1368
|
+
const visit = (id) => {
|
|
1369
|
+
if (selectedSet.has(id)) ordered.push(id);
|
|
1370
|
+
const item = survey.getItem(id);
|
|
1371
|
+
if (item?.itemType === "group") item.config.items.forEach(visit);
|
|
1372
|
+
};
|
|
1373
|
+
visit(survey.rootId);
|
|
1374
|
+
const requestedIndex = Math.min(target.index ?? destination.config.items.length, destination.config.items.length);
|
|
1375
|
+
const removedBefore = destination.config.items.slice(0, requestedIndex).filter((id) => selectedSet.has(id)).length;
|
|
1376
|
+
const parents = new Set(selected.map((id) => survey.getParentItem(id).id));
|
|
1377
|
+
parents.add(destination.id);
|
|
1378
|
+
const lists = /* @__PURE__ */ new Map();
|
|
1379
|
+
for (const id of parents) {
|
|
1380
|
+
const group = survey.getItem(id);
|
|
1381
|
+
if (group?.itemType !== "group") throw new Error(`Unknown parent group '${id}'`);
|
|
1382
|
+
lists.set(id, group.config.items.filter((child) => !selectedSet.has(child)));
|
|
1383
|
+
}
|
|
1384
|
+
lists.get(destination.id).splice(requestedIndex - removedBefore, 0, ...ordered);
|
|
1385
|
+
if (![...lists].some(([id, children]) => {
|
|
1386
|
+
const group = survey.getItem(id);
|
|
1387
|
+
return group?.itemType === "group" && !equal(group.config.items, children);
|
|
1388
|
+
})) return false;
|
|
1389
|
+
this.transact(meta, (draft) => {
|
|
1390
|
+
for (const item of draft.surveyItems) {
|
|
1391
|
+
const children = lists.get(item.id);
|
|
1392
|
+
if (item.itemType === "group" && children) item.config.items = children;
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
return true;
|
|
1396
|
+
}
|
|
1397
|
+
identityPath(id, kind) {
|
|
1398
|
+
const declaration = this.current.identities.get(id);
|
|
1399
|
+
if (!declaration || kind && declaration.kind !== kind) throw new Error(`Unknown ${kind ?? "identity"} '${id}'`);
|
|
1400
|
+
return declaration.path.slice(0, -1);
|
|
1401
|
+
}
|
|
1402
|
+
containingItem(id) {
|
|
1403
|
+
const declaration = this.current.identities.get(id);
|
|
1404
|
+
const owner = declaration?.ownerId ? this.current.owners.get(declaration.ownerId) : void 0;
|
|
1405
|
+
if (!owner) throw new Error(`No containing item for '${id}'`);
|
|
1406
|
+
return owner.itemId;
|
|
1407
|
+
}
|
|
1408
|
+
/** Edit wording only for this element and its added/removed descendants, in existing locales. */
|
|
1409
|
+
updateElement(elementId, update, meta = { label: "Edit element" }, renewOwnerIds = /* @__PURE__ */ new Map()) {
|
|
1410
|
+
const path = this.identityPath(elementId, "element");
|
|
1411
|
+
this.transact(meta, (draft) => {
|
|
1412
|
+
const schema = this.current.registry.elementSchema;
|
|
1413
|
+
const element = schema.parse(at(draft, path));
|
|
1414
|
+
const before = contentOwnerIds(schema, element);
|
|
1415
|
+
const elementType = element.elementType;
|
|
1416
|
+
const translations = draft.translations ?? {};
|
|
1417
|
+
update(element, translations);
|
|
1418
|
+
if (Object.keys(translations).length || draft.translations) draft.translations = translations;
|
|
1419
|
+
if (element.id !== elementId || element.elementType !== elementType) throw new Error("Element identity and type cannot change during a settings update");
|
|
1420
|
+
const next = schema.parse(element);
|
|
1421
|
+
const remaining = contentOwnerIds(schema, next);
|
|
1422
|
+
assertElementTranslationScope(this.current.data.translations, draft.translations, /* @__PURE__ */ new Set([...before, ...remaining]));
|
|
1423
|
+
for (const translation of Object.values(draft.translations ?? {})) for (const ownerId of before) if (!remaining.has(ownerId)) delete translation.content[ownerId];
|
|
1424
|
+
setAt(draft, path, next);
|
|
1425
|
+
for (const from of renewOwnerIds.keys()) if (from === elementId || !before.has(from) || !remaining.has(from)) throw new Error(`Cannot renew '${from}': it must be a surviving owner inside the element`);
|
|
1426
|
+
if (renewOwnerIds.size) {
|
|
1427
|
+
const visits = inspectAnnotated(this.current.registry.surveySchema, draft).sort((a, b) => b.path.length - a.path.length || Number(a.key) - Number(b.key));
|
|
1428
|
+
for (const visit of visits) {
|
|
1429
|
+
if (visit.annotation.kind === "expression") setAt(draft, visit.path, transformExpression(expressionSchema.parse(visit.value), {
|
|
1430
|
+
const: (node) => node,
|
|
1431
|
+
function: (node) => node,
|
|
1432
|
+
contextVariable: (node) => node,
|
|
1433
|
+
responseVariable: (node) => node,
|
|
1434
|
+
ownerState: (node) => ({
|
|
1435
|
+
...node,
|
|
1436
|
+
targetRef: { ownerId: renewOwnerIds.get(node.targetRef.ownerId) ?? node.targetRef.ownerId }
|
|
1437
|
+
})
|
|
1438
|
+
}));
|
|
1439
|
+
if (visit.annotation.kind !== "identity" && !(visit.annotation.kind === "reference" && !visit.annotation.external && (visit.annotation.target === "owner" || visit.annotation.target === "state")) || typeof visit.value !== "string") continue;
|
|
1440
|
+
const to = renewOwnerIds.get(visit.value);
|
|
1441
|
+
if (!to) continue;
|
|
1442
|
+
if (visit.key) {
|
|
1443
|
+
const parent = at(draft, visit.path.slice(0, -1));
|
|
1444
|
+
if (!record(parent) || Object.hasOwn(parent, to)) throw new Error(`Cannot move owned content to '${to}'`);
|
|
1445
|
+
setOwnValue(parent, to, ownValue(parent, visit.value));
|
|
1446
|
+
delete parent[visit.value];
|
|
1447
|
+
} else setAt(draft, visit.path, to);
|
|
1448
|
+
}
|
|
1042
1449
|
}
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
addLayout(bodyOwnerId) {
|
|
1453
|
+
const body = this.current.bodies.get(bodyOwnerId);
|
|
1454
|
+
if (!body) throw new Error(`Unknown body owner '${bodyOwnerId}'`);
|
|
1455
|
+
const id = generateId();
|
|
1456
|
+
this.transact({ label: "Add layout" }, (draft) => {
|
|
1457
|
+
arrayAt(draft, [...body.path, "blocks"]).push({
|
|
1458
|
+
id,
|
|
1459
|
+
kind: "layout",
|
|
1460
|
+
layout: {
|
|
1461
|
+
mode: "automatic",
|
|
1462
|
+
maxColumns: 2
|
|
1463
|
+
},
|
|
1464
|
+
elements: []
|
|
1465
|
+
});
|
|
1466
|
+
});
|
|
1467
|
+
return id;
|
|
1468
|
+
}
|
|
1469
|
+
addSection(bodyOwnerId) {
|
|
1470
|
+
const body = this.current.bodies.get(bodyOwnerId);
|
|
1471
|
+
if (!body) throw new Error(`Unknown body owner '${bodyOwnerId}'`);
|
|
1472
|
+
const id = generateId();
|
|
1473
|
+
this.transact({ label: "Add section" }, (draft) => {
|
|
1474
|
+
arrayAt(draft, [...body.path, "blocks"]).push({
|
|
1475
|
+
id,
|
|
1476
|
+
kind: "section",
|
|
1477
|
+
body: { blocks: [{
|
|
1478
|
+
id: generateId(),
|
|
1479
|
+
kind: "layout",
|
|
1480
|
+
layout: {
|
|
1481
|
+
mode: "automatic",
|
|
1482
|
+
maxColumns: 1
|
|
1483
|
+
},
|
|
1484
|
+
elements: []
|
|
1485
|
+
}] }
|
|
1486
|
+
});
|
|
1487
|
+
});
|
|
1488
|
+
return id;
|
|
1489
|
+
}
|
|
1490
|
+
updateLayout(layoutId, layout) {
|
|
1491
|
+
this.transact({ label: "Change columns" }, (draft) => setAt(draft, [...this.identityPath(layoutId, "layout"), "layout"], layout));
|
|
1492
|
+
}
|
|
1493
|
+
removalScope(ids) {
|
|
1494
|
+
const paths = [];
|
|
1495
|
+
for (const id of new Set(ids)) {
|
|
1496
|
+
if (id === this.current.rootId) throw new Error("The root group cannot be removed");
|
|
1497
|
+
const declaration = this.current.identities.get(id);
|
|
1498
|
+
if (!declaration || ![
|
|
1499
|
+
"element",
|
|
1500
|
+
"layout",
|
|
1501
|
+
"section",
|
|
1502
|
+
"item"
|
|
1503
|
+
].includes(declaration.kind)) throw new Error("Only structural nodes can be removed directly");
|
|
1504
|
+
if (declaration.kind === "item") {
|
|
1505
|
+
for (const item of this.current.data.surveyItems) if (this.current.isDescendantOf(item.id, id)) paths.push(this.identityPath(item.id));
|
|
1506
|
+
} else paths.push(this.identityPath(id));
|
|
1043
1507
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1508
|
+
const roots = paths.filter((path, index) => !paths.some((other, otherIndex) => otherIndex !== index && inside(other, path) && (other.length < path.length || otherIndex < index)));
|
|
1509
|
+
const includes = (path) => roots.some((root) => inside(root, path));
|
|
1510
|
+
return {
|
|
1511
|
+
roots,
|
|
1512
|
+
owned: new Set([...this.current.identities.values()].filter((entry) => includes(entry.path)).map((entry) => entry.id)),
|
|
1513
|
+
includes
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
/** Includes whole flow subtrees; deletion never silently rewrites an authored rule. */
|
|
1517
|
+
deletionImpact(id) {
|
|
1518
|
+
const { owned, includes } = this.removalScope(typeof id === "string" ? [id] : id);
|
|
1519
|
+
return annotatedReferences(inspectAnnotated(this.current.registry.surveySchema, this.current.data)).filter((ref) => owned.has(ref.id) && !includes(ref.path) && ref.path[0] !== "translations" && !(ref.target === "item" && ref.path[0] === "surveyItems" && ref.path.includes("items")));
|
|
1520
|
+
}
|
|
1521
|
+
remove(id) {
|
|
1522
|
+
return this.removeNodes([id]);
|
|
1523
|
+
}
|
|
1524
|
+
removeItem(itemId) {
|
|
1525
|
+
if (!this.current.getItem(itemId)) return false;
|
|
1526
|
+
return this.removeItems([itemId]);
|
|
1527
|
+
}
|
|
1528
|
+
removeItems(itemIds) {
|
|
1529
|
+
if (itemIds.some((id) => !this.current.getItem(id))) throw new Error("Unknown flow item in removal request");
|
|
1530
|
+
return this.removeNodes(itemIds);
|
|
1531
|
+
}
|
|
1532
|
+
removeNodes(ids) {
|
|
1533
|
+
if (!ids.length) return false;
|
|
1534
|
+
const { roots, owned } = this.removalScope(ids);
|
|
1535
|
+
roots.sort((left, right) => {
|
|
1536
|
+
for (let i = 0; i < Math.min(left.length, right.length); i++) {
|
|
1537
|
+
const a = left[i], b = right[i];
|
|
1538
|
+
if (a !== b) return typeof a === "number" && typeof b === "number" ? b - a : String(b).localeCompare(String(a));
|
|
1539
|
+
}
|
|
1540
|
+
return right.length - left.length;
|
|
1541
|
+
});
|
|
1542
|
+
this.transact({ label: "Remove content" }, (draft) => {
|
|
1543
|
+
for (const path of roots) {
|
|
1544
|
+
const index = path.at(-1);
|
|
1545
|
+
if (typeof index !== "number") throw new Error("Structural content must belong to an ordered list");
|
|
1546
|
+
arrayAt(draft, path.slice(0, -1)).splice(index, 1);
|
|
1547
|
+
}
|
|
1548
|
+
for (const item of draft.surveyItems) if (item.itemType === "group") item.config.items = item.config.items.filter((child) => !owned.has(child));
|
|
1549
|
+
for (const translation of Object.values(draft.translations ?? {})) for (const owner of owned) delete translation.content[owner];
|
|
1550
|
+
});
|
|
1551
|
+
return true;
|
|
1552
|
+
}
|
|
1553
|
+
/** index is the final position after removal, as reported by composition sortables. */
|
|
1554
|
+
moveElement(elementId, layoutId, index, ruleTransfer = "reject") {
|
|
1555
|
+
this.moveCompositionContent(elementId, this.identityPath(elementId, "element"), [...this.identityPath(layoutId, "layout"), "elements"], layoutId, index, ruleTransfer);
|
|
1556
|
+
}
|
|
1557
|
+
/** Reorders/moves vertical blocks; index is the final position after removal. */
|
|
1558
|
+
moveBlock(blockId, bodyOwnerId, index, ruleTransfer = "reject") {
|
|
1559
|
+
const identity = this.current.identities.get(blockId);
|
|
1560
|
+
if (!identity || identity.kind !== "layout" && identity.kind !== "section") throw new Error("Only layouts and sections can move between bodies");
|
|
1561
|
+
const body = this.current.bodies.get(bodyOwnerId);
|
|
1562
|
+
if (!body) throw new Error(`Unknown body owner '${bodyOwnerId}'`);
|
|
1563
|
+
this.moveCompositionContent(blockId, this.identityPath(blockId), [...body.path, "blocks"], bodyOwnerId, index, ruleTransfer);
|
|
1564
|
+
}
|
|
1565
|
+
moveCompositionContent(elementId, source, destination, targetOwnerId, index, ruleTransfer) {
|
|
1566
|
+
if (inside(source, destination)) throw new Error("Content cannot move into its own descendant");
|
|
1567
|
+
const fromItem = this.containingItem(elementId), toItem = this.containingItem(targetOwnerId);
|
|
1568
|
+
const ownedSlots = new Set(this.current.slots.definitions.filter((slot) => {
|
|
1569
|
+
const owner = this.current.owners.get(slot.elementId);
|
|
1570
|
+
return owner && inside(source, owner.path);
|
|
1571
|
+
}).map((slot) => slot.slotId));
|
|
1572
|
+
this.transact({ label: "Move content" }, (draft) => {
|
|
1573
|
+
const elements = arrayAt(draft, destination);
|
|
1574
|
+
if (index !== void 0 && (!Number.isInteger(index) || index < 0 || index > elements.length)) throw new Error("Invalid content insertion index");
|
|
1575
|
+
const parent = arrayAt(draft, source.slice(0, -1));
|
|
1576
|
+
const position = source.at(-1);
|
|
1577
|
+
if (typeof position !== "number") throw new Error("Content must belong to an ordered list");
|
|
1578
|
+
if (fromItem !== toItem) {
|
|
1579
|
+
const oldItem = draft.surveyItems.find((item) => item.id === fromItem), newItem = draft.surveyItems.find((item) => item.id === toItem);
|
|
1580
|
+
if (oldItem?.itemType !== "content" || newItem?.itemType !== "content") throw new Error("Invalid content-item move");
|
|
1581
|
+
const affected = oldItem.config.validationRules?.filter((rule) => rule.targets.some((id) => ownedSlots.has(id))) ?? [];
|
|
1582
|
+
if (affected.length && (ruleTransfer !== "move-with-targets" || affected.some((rule) => rule.targets.some((id) => !ownedSlots.has(id))))) throw new Error("This move changes validation-rule ownership. Move all targets with explicit ruleTransfer or repair the rules first.");
|
|
1583
|
+
if (affected.length) {
|
|
1584
|
+
oldItem.config.validationRules = oldItem.config.validationRules?.filter((rule) => !affected.includes(rule));
|
|
1585
|
+
newItem.config.validationRules = [...newItem.config.validationRules ?? [], ...affected];
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const [element] = parent.splice(position, 1);
|
|
1589
|
+
elements.splice(index ?? elements.length, 0, element);
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
/** Move selected roots into a new group under the first root's parent, as in the outline UI. */
|
|
1593
|
+
groupItems(itemIds, meta = { label: "Group selected items" }) {
|
|
1594
|
+
const requested = new Set(itemIds);
|
|
1595
|
+
if (!requested.size || requested.has(this.current.rootId) || [...requested].some((id) => !this.current.getItem(id))) throw new Error("Select existing non-root items to group");
|
|
1596
|
+
const roots = this.current.itemOutline.map((entry) => entry.itemId).filter((id) => requested.has(id) && ![...requested].some((other) => other !== id && this.current.isDescendantOf(id, other)));
|
|
1597
|
+
const parent = this.current.getParentItem(roots[0]);
|
|
1598
|
+
const id = generateId();
|
|
1599
|
+
this.transact(meta, (draft) => {
|
|
1600
|
+
const destination = draft.surveyItems.find((item) => item.id === parent.id);
|
|
1601
|
+
if (destination?.itemType !== "group") throw new Error("Grouping requires a flow parent");
|
|
1602
|
+
const index = destination.config.items.indexOf(roots[0]);
|
|
1603
|
+
destination.config.items.splice(index, 0, id);
|
|
1604
|
+
for (const item of draft.surveyItems) if (item.itemType === "group") item.config.items = item.config.items.filter((child) => !roots.includes(child));
|
|
1605
|
+
draft.surveyItems.push({
|
|
1606
|
+
id,
|
|
1607
|
+
itemType: "group",
|
|
1608
|
+
config: { items: roots }
|
|
1609
|
+
});
|
|
1610
|
+
});
|
|
1611
|
+
return id;
|
|
1612
|
+
}
|
|
1613
|
+
copyItem(itemId) {
|
|
1614
|
+
return copySurveyItem(this.current, itemId);
|
|
1615
|
+
}
|
|
1616
|
+
pasteItem(input, target) {
|
|
1617
|
+
if (this.current.getItem(target.parentId)?.itemType !== "group") throw new Error("Paste target must be a group");
|
|
1618
|
+
if (target.index !== void 0 && (!Number.isInteger(target.index) || target.index < 0)) throw new Error("Invalid paste index");
|
|
1619
|
+
const payload = prepareItemPaste(this.current, input);
|
|
1620
|
+
this.transact({ label: "Paste item" }, (draft) => {
|
|
1621
|
+
const group = draft.surveyItems.find((item) => item.id === target.parentId);
|
|
1622
|
+
if (group?.itemType !== "group") throw new Error("Paste target must be a group");
|
|
1623
|
+
group.config.items.splice(Math.min(target.index ?? group.config.items.length, group.config.items.length), 0, payload.rootItemId);
|
|
1624
|
+
draft.surveyItems.push(...payload.surveyItems);
|
|
1625
|
+
for (const [locale, translation] of Object.entries(payload.translations ?? {})) {
|
|
1626
|
+
if (!Object.values(translation.content).some((roles) => Object.keys(roles).length)) continue;
|
|
1627
|
+
draft.translations ??= {};
|
|
1628
|
+
const destination = ownValue(draft.translations, locale) ?? { content: {} };
|
|
1629
|
+
setOwnValue(draft.translations, locale, destination);
|
|
1630
|
+
for (const [ownerId, roles] of Object.entries(translation.content)) setOwnValue(destination.content, ownerId, roles);
|
|
1631
|
+
}
|
|
1632
|
+
if (Object.keys(payload.assets ?? {}).length) draft.assets = {
|
|
1633
|
+
...draft.assets,
|
|
1634
|
+
...payload.assets
|
|
1635
|
+
};
|
|
1636
|
+
});
|
|
1637
|
+
return payload.rootItemId;
|
|
1638
|
+
}
|
|
1639
|
+
copyElement(elementId, layoutId) {
|
|
1640
|
+
const sourcePath = this.identityPath(elementId, "element");
|
|
1641
|
+
const source = this.current.elements.get(elementId);
|
|
1642
|
+
const ids = new Map([...this.current.identities.values()].filter((declaration) => inside(sourcePath, declaration.path)).map((declaration) => [declaration.id, generateId()]));
|
|
1643
|
+
const copy = allocateCopiedResponseNames(this.current.registry.elementSchema, remapAnnotated(this.current.registry.elementSchema, source.node, ids), this.current.registry, new Set(this.current.slots.definitions.map((slot) => slot.variableName)));
|
|
1644
|
+
this.transact({ label: "Copy element" }, (draft) => {
|
|
1645
|
+
arrayAt(draft, [...this.identityPath(layoutId, "layout"), "elements"]).push(copy);
|
|
1646
|
+
for (const translation of Object.values(draft.translations ?? {})) for (const [oldId, newId] of ids) {
|
|
1647
|
+
const content = ownValue(translation.content, oldId);
|
|
1648
|
+
if (content) setOwnValue(translation.content, newId, structuredClone(content));
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
return copy.id;
|
|
1046
1652
|
}
|
|
1047
1653
|
};
|
|
1048
1654
|
//#endregion
|
|
1049
|
-
export { CommitSource,
|
|
1655
|
+
export { CommitSource, SURVEY_EDITOR_ITEM_COLORS, SurveyEditor, SurveyEditorUndoRedo, allocateCopiedResponseNames, copySurveyItem, createEditorSessionSchema, createItemClipboardSchema, getAnswerTypeChangeDiagnostics, prepareItemPaste, previewResponseNaming, previewResponseSettings };
|
|
1050
1656
|
|
|
1051
1657
|
//# sourceMappingURL=editor.mjs.map
|