@case-framework/survey-core 0.8.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 +126 -244
- 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 +11 -2
- package/build/editor/colors.d.mts.map +1 -0
- package/build/editor.d.mts +72913 -266
- package/build/editor.d.mts.map +1 -1
- package/build/editor.mjs +1268 -1001
- package/build/editor.mjs.map +1 -1
- package/build/index.d.mts +2 -3
- package/build/index.mjs +1620 -824
- 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 +9 -3
- 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 +9 -3
- package/build/index-SDbZ5WQM.d.mts +0 -2208
- package/build/index-SDbZ5WQM.d.mts.map +0 -1
- package/build/item-colors-DgoJeSCQ.d.mts +0 -12
- package/build/item-colors-DgoJeSCQ.d.mts.map +0 -1
- package/build/projection-Bl6qCinx.mjs +0 -3930
- package/build/projection-Bl6qCinx.mjs.map +0 -1
package/build/editor.mjs
CHANGED
|
@@ -1,442 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
return current;
|
|
7
|
-
}
|
|
8
|
-
function writePath(config, path, value) {
|
|
9
|
-
if (!path.length || path.some((key) => key === "__proto__" || key === "constructor" || key === "prototype")) throw new Error("Invalid response naming path");
|
|
10
|
-
const parent = readPath(config, path.slice(0, -1));
|
|
11
|
-
if (!parent || typeof parent !== "object") throw new Error("Response naming declaration does not match its config");
|
|
12
|
-
parent[path.at(-1)] = value;
|
|
13
|
-
}
|
|
14
|
-
const assertName = (name) => {
|
|
15
|
-
if (!isValidResponseVariableName(name)) throw new Error(`Invalid variable name "${name}". Use lowercase snake_case, starting with a letter, at most 32 characters.`);
|
|
16
|
-
};
|
|
17
|
-
/** Detached, validated preview; applying it uses the editor's ordinary mutation/history path. */
|
|
18
|
-
function previewNaming(survey, edits) {
|
|
19
|
-
const registry = survey.getResponseSlotRegistry();
|
|
20
|
-
const raw = structuredClone(survey.serialize());
|
|
21
|
-
const config = (slot) => raw.surveyItems.find((item) => item.id === slot.itemId).config;
|
|
22
|
-
for (const edit of edits) if ("slotId" in edit) {
|
|
23
|
-
const slot = registry.get(edit.slotId);
|
|
24
|
-
if (!slot) throw new Error(`Unknown response variable "${edit.slotId}"`);
|
|
25
|
-
if (slot.naming?.kind === "matrix") throw new Error("Matrix cell names are derived; edit the matrix prefix or row/column coding names");
|
|
26
|
-
if (!slot.naming) throw new Error(`The input owning "${edit.slotId}" does not expose a naming mutation`);
|
|
27
|
-
assertName(edit.variableName);
|
|
28
|
-
writePath(config(slot), slot.naming.path, edit.variableName);
|
|
29
|
-
} else {
|
|
30
|
-
const slots = registry.definitions.filter((slot) => slot.primitiveId === edit.primitiveId && slot.naming?.kind === "matrix");
|
|
31
|
-
if (!slots.length) throw new Error(`Unknown matrix primitive "${edit.primitiveId}"`);
|
|
32
|
-
for (const [ids, kind] of [[edit.rows, "row"], [edit.columns, "column"]]) for (const [id, name] of Object.entries(ids ?? {})) {
|
|
33
|
-
assertName(name);
|
|
34
|
-
const slot = slots.find((slot) => kind === "row" ? slot.rowId === id : slot.columnId === id);
|
|
35
|
-
const naming = slot?.naming;
|
|
36
|
-
const path = naming?.kind === "matrix" ? kind === "row" ? naming.rowPath : naming.columnPath : void 0;
|
|
37
|
-
if (!slot || !path) throw new Error(`Unknown matrix ${kind} "${id}"`);
|
|
38
|
-
writePath(config(slot), path, name);
|
|
39
|
-
}
|
|
40
|
-
if (edit.prefix !== void 0) {
|
|
41
|
-
assertName(edit.prefix);
|
|
42
|
-
const slot = slots[0], naming = slot.naming;
|
|
43
|
-
if (naming?.kind === "matrix") writePath(config(slot), naming.prefixPath, edit.prefix);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
const next = Survey.fromJson(raw, survey.getPluginRegistry());
|
|
47
|
-
const nextRegistry = next.getResponseSlotRegistry();
|
|
48
|
-
const beforeIssues = new Set(registry.diagnostics.map((issue) => JSON.stringify(issue)));
|
|
49
|
-
const issues = nextRegistry.diagnostics.filter((issue) => !beforeIssues.has(JSON.stringify(issue)));
|
|
50
|
-
if (issues.length) throw new Error(issues.map((issue) => issue.message).join("\n"));
|
|
51
|
-
const changes = nextRegistry.definitions.flatMap((slot) => {
|
|
52
|
-
const before = registry.get(slot.slotId).variableName;
|
|
53
|
-
return before === slot.variableName ? [] : [{
|
|
54
|
-
slotId: slot.slotId,
|
|
55
|
-
itemId: slot.itemId,
|
|
56
|
-
before,
|
|
57
|
-
after: slot.variableName
|
|
58
|
-
}];
|
|
59
|
-
});
|
|
60
|
-
return {
|
|
61
|
-
survey: next.serialize(),
|
|
62
|
-
changes
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
function assertProjectionChanges(before, after) {
|
|
66
|
-
const existing = new Set(getDefaultProjectionIssues(before));
|
|
67
|
-
const introduced = getDefaultProjectionIssues(after).filter((issue) => !existing.has(issue));
|
|
68
|
-
if (introduced.length) throw new Error(introduced.join("\n"));
|
|
69
|
-
}
|
|
70
|
-
function previewResponseNaming(survey, edits) {
|
|
71
|
-
const preview = previewNaming(survey, edits);
|
|
72
|
-
assertProjectionChanges(survey, Survey.fromJson(preview.survey, survey.getPluginRegistry()));
|
|
73
|
-
return preview;
|
|
74
|
-
}
|
|
75
|
-
/** Allocate whole matrix prefixes and individual names in deterministic declaration order. */
|
|
76
|
-
function allocateCopiedResponseNames(survey, items) {
|
|
77
|
-
const existingIds = new Set(survey.getResponseSlotDefinitions().flatMap(({ slots }) => slots.map((slot) => slot.slotId)));
|
|
78
|
-
for (const item of items) for (const slot of item.getResponseSlotDefinitions()) {
|
|
79
|
-
if (existingIds.has(slot.slotId)) throw new Error(`Inserted input must allocate a fresh slot identity: ${slot.slotId}`);
|
|
80
|
-
existingIds.add(slot.slotId);
|
|
81
|
-
}
|
|
82
|
-
const used = new Set(survey.getResponseSlotRegistry().definitions.map((slot) => slot.variableName));
|
|
83
|
-
const usedPrefixes = new Set(survey.getResponseSlotRegistry().definitions.flatMap((slot) => slot.naming?.kind === "matrix" ? [String(readPath(survey.surveyItems.get(slot.itemId).config, slot.naming.prefixPath))] : []));
|
|
84
|
-
const changes = [];
|
|
85
|
-
for (const item of items) {
|
|
86
|
-
const initial = item.getResponseSlotDefinitions();
|
|
87
|
-
const groups = /* @__PURE__ */ new Map();
|
|
88
|
-
for (const slot of initial) {
|
|
89
|
-
const key = slot.naming?.kind === "matrix" ? JSON.stringify(slot.naming.prefixPath) : slot.slotId;
|
|
90
|
-
groups.set(key, [...groups.get(key) ?? [], slot]);
|
|
91
|
-
}
|
|
92
|
-
for (const group of groups.values()) {
|
|
93
|
-
const first = group[0], naming = first.naming;
|
|
94
|
-
const raw = structuredClone(item.rawItem);
|
|
95
|
-
if (naming?.kind === "matrix") {
|
|
96
|
-
const prefix = String(readPath(raw.config, naming.prefixPath));
|
|
97
|
-
assertName(prefix);
|
|
98
|
-
const tails = group.map((slot) => slot.variableName.slice(prefix.length));
|
|
99
|
-
if (new Set(tails).size !== tails.length) throw new Error("Matrix coding components produce duplicate variable names");
|
|
100
|
-
let candidate = prefix;
|
|
101
|
-
const blocked = /* @__PURE__ */ new Set();
|
|
102
|
-
while (usedPrefixes.has(candidate) || tails.some((tail) => used.has(`${candidate}${tail}`))) {
|
|
103
|
-
blocked.add(candidate);
|
|
104
|
-
const longestTail = Math.max(...tails.map((tail) => tail.length));
|
|
105
|
-
try {
|
|
106
|
-
candidate = allocateResponseVariableName(prefix, blocked, 32 - longestTail);
|
|
107
|
-
} catch {
|
|
108
|
-
const longest = group[tails.findIndex((tail) => tail.length === longestTail)];
|
|
109
|
-
throw new Error(`Cannot allocate a unique matrix prefix for "${longest.variableName}". Shorten its row or column coding name to leave room for a unique prefix within 32 characters.`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
usedPrefixes.add(candidate);
|
|
113
|
-
if (candidate !== prefix) {
|
|
114
|
-
writePath(raw.config, naming.prefixPath, candidate);
|
|
115
|
-
item.updateRawItem(raw);
|
|
116
|
-
}
|
|
117
|
-
} else {
|
|
118
|
-
assertName(first.variableName);
|
|
119
|
-
const name = allocateResponseVariableName(first.variableName, used);
|
|
120
|
-
if (name !== first.variableName) {
|
|
121
|
-
if (!naming) throw new Error(`Cannot allocate a unique name for plugin variable "${first.variableName}"`);
|
|
122
|
-
writePath(raw.config, naming.path, name);
|
|
123
|
-
item.updateRawItem(raw);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
for (const slot of item.getResponseSlotDefinitions().filter((slot) => group.some((original) => original.slotId === slot.slotId))) {
|
|
127
|
-
assertName(slot.variableName);
|
|
128
|
-
used.add(slot.variableName);
|
|
129
|
-
const before = initial.find((original) => original.slotId === slot.slotId).variableName;
|
|
130
|
-
if (before !== slot.variableName) changes.push({
|
|
131
|
-
slotId: slot.slotId,
|
|
132
|
-
itemId: item.id,
|
|
133
|
-
before,
|
|
134
|
-
after: slot.variableName
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return changes;
|
|
140
|
-
}
|
|
141
|
-
/** Naming, categorical codes, and formats share one detached, atomic preview. */
|
|
142
|
-
function previewResponseSettings(survey, naming, settings) {
|
|
143
|
-
const preview = previewNaming(survey, naming);
|
|
144
|
-
const raw = preview.survey;
|
|
145
|
-
const registry = survey.getResponseSlotRegistry();
|
|
146
|
-
const codeWrites = /* @__PURE__ */ new Map();
|
|
147
|
-
for (const edit of settings) {
|
|
148
|
-
const slot = registry.get(edit.slotId);
|
|
149
|
-
if (!slot) throw new Error(`Unknown response variable "${edit.slotId}"`);
|
|
150
|
-
const item = raw.surveyItems.find((item) => item.id === slot.itemId);
|
|
151
|
-
for (const [id, code] of Object.entries(edit.optionCodes ?? {})) {
|
|
152
|
-
const path = slot.optionCodePaths?.[id];
|
|
153
|
-
if (!path) throw new Error(`Option "${id}" does not expose an export-code mutation`);
|
|
154
|
-
if (!code.trim()) throw new Error("Option export codes cannot be empty");
|
|
155
|
-
const target = JSON.stringify([slot.itemId, path]);
|
|
156
|
-
const previous = codeWrites.get(target);
|
|
157
|
-
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.`);
|
|
158
|
-
codeWrites.set(target, {
|
|
159
|
-
code,
|
|
160
|
-
slotId: slot.slotId
|
|
161
|
-
});
|
|
162
|
-
writePath(item.config, path, code);
|
|
163
|
-
}
|
|
164
|
-
if (edit.exportSettings) item.responseSettings = {
|
|
165
|
-
...item.responseSettings,
|
|
166
|
-
[slot.slotId]: structuredClone(edit.exportSettings)
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
const next = Survey.fromJson(raw, survey.getPluginRegistry());
|
|
170
|
-
for (const slot of next.getResponseSlotRegistry().definitions.filter((slot) => settings.some((edit) => edit.slotId === slot.slotId))) projectSlot(slot, {
|
|
171
|
-
mode: "default",
|
|
172
|
-
...slot.exportSettings
|
|
173
|
-
});
|
|
174
|
-
assertProjectionChanges(survey, next);
|
|
175
|
-
return {
|
|
176
|
-
...preview,
|
|
177
|
-
survey: next.serialize()
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
//#endregion
|
|
181
|
-
//#region src/editor/item-copy-paste.ts
|
|
182
|
-
var ItemCopyPaste = class ItemCopyPaste {
|
|
183
|
-
lastResponseNameChanges = [];
|
|
184
|
-
survey;
|
|
185
|
-
constructor(survey) {
|
|
186
|
-
this.survey = survey;
|
|
187
|
-
}
|
|
188
|
-
/**
|
|
189
|
-
* Copy a survey item and all its data to clipboard format
|
|
190
|
-
* When copying a group, automatically includes all items in the subtree
|
|
191
|
-
* @param itemId - The id of the item to copy
|
|
192
|
-
* @returns Clipboard data that can be serialized to JSON for clipboard
|
|
193
|
-
*/
|
|
194
|
-
copyItem(itemId) {
|
|
195
|
-
if (!this.survey.surveyItems.get(itemId)) throw new Error(`Item with id '${itemId}' not found`);
|
|
196
|
-
const itemsToCopy = this.collectItemsForCopy(itemId);
|
|
197
|
-
const items = itemsToCopy.map((id) => {
|
|
198
|
-
const item = this.survey.surveyItems.get(id);
|
|
199
|
-
if (!item) throw new Error(`Item with id '${id}' not found during copy`);
|
|
200
|
-
return {
|
|
201
|
-
itemId: id,
|
|
202
|
-
itemData: item.rawItem
|
|
203
|
-
};
|
|
204
|
-
});
|
|
205
|
-
const translations = Object.create(null);
|
|
206
|
-
itemsToCopy.forEach((id) => {
|
|
207
|
-
try {
|
|
208
|
-
const itemTranslations = this.survey.getItemTranslations(id);
|
|
209
|
-
if (itemTranslations?.locales?.length) {
|
|
210
|
-
const serializedTranslations = Object.create(null);
|
|
211
|
-
itemTranslations.locales.forEach((locale) => {
|
|
212
|
-
const localeContent = itemTranslations.getAllForLocale(locale);
|
|
213
|
-
if (localeContent) serializedTranslations[locale] = localeContent;
|
|
214
|
-
});
|
|
215
|
-
translations[id] = serializedTranslations;
|
|
216
|
-
} else translations[id] = {};
|
|
217
|
-
} catch {
|
|
218
|
-
translations[id] = {};
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
return {
|
|
222
|
-
type: "survey-item",
|
|
223
|
-
version: "1.0.0",
|
|
224
|
-
items,
|
|
225
|
-
translations,
|
|
226
|
-
rootItemId: itemId,
|
|
227
|
-
timestamp: Date.now()
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
/**
|
|
231
|
-
* Collect all items that should be copied, including subtree for groups
|
|
232
|
-
* @param itemId - The root item id
|
|
233
|
-
* @returns Array of item keys to copy
|
|
234
|
-
*/
|
|
235
|
-
collectItemsForCopy(itemId) {
|
|
236
|
-
const itemsToCopy = [itemId];
|
|
237
|
-
const item = this.survey.surveyItems.get(itemId);
|
|
238
|
-
if (!item) return [];
|
|
239
|
-
if (item instanceof GroupItemCore) {
|
|
240
|
-
const childIds = item.items;
|
|
241
|
-
if (childIds.length > 0) childIds.forEach((childId) => {
|
|
242
|
-
const childItems = this.collectItemsForCopy(childId);
|
|
243
|
-
itemsToCopy.push(...childItems);
|
|
244
|
-
});
|
|
245
|
-
}
|
|
246
|
-
return itemsToCopy;
|
|
247
|
-
}
|
|
248
|
-
/**
|
|
249
|
-
* Add an item to its parent group.
|
|
250
|
-
* Ensures key uniqueness among siblings.
|
|
251
|
-
* @param target - Target location information
|
|
252
|
-
* @param item - The item to add (may have key adjusted for uniqueness)
|
|
253
|
-
*/
|
|
254
|
-
addItemToParentGroup(target, item) {
|
|
255
|
-
const parentGroup = this.survey.surveyItems.get(target.parentId);
|
|
256
|
-
if (!parentGroup) throw new Error(`Parent item with id '${target.parentId}' not found`);
|
|
257
|
-
if (!(parentGroup instanceof GroupItemCore)) throw new Error(`Parent item '${target.parentId}' is not a group item`);
|
|
258
|
-
const insertIndex = target.index !== void 0 ? Math.min(target.index, parentGroup.items.length) : parentGroup.items.length;
|
|
259
|
-
parentGroup.addChild(item.id, insertIndex);
|
|
260
|
-
}
|
|
261
|
-
/**
|
|
262
|
-
* Paste a survey item from clipboard data to a target location
|
|
263
|
-
* Handles multiple items and subtrees from the clipboard data
|
|
264
|
-
* @param clipboardData - The clipboard data containing the item(s) to paste
|
|
265
|
-
* @param target - Target location where to paste the item
|
|
266
|
-
* @returns The id of the pasted root item
|
|
267
|
-
*/
|
|
268
|
-
pasteItem(clipboardData, target) {
|
|
269
|
-
if (!ItemCopyPaste.isValidClipboardData(clipboardData)) throw new Error("Invalid clipboard data format");
|
|
270
|
-
const itemIds = /* @__PURE__ */ new Set();
|
|
271
|
-
for (const { itemId, itemData } of clipboardData.items) {
|
|
272
|
-
if (itemIds.has(itemId) || itemId !== itemData.id) throw new Error(`Ambiguous clipboard item identity: ${itemId}`);
|
|
273
|
-
itemIds.add(itemId);
|
|
274
|
-
}
|
|
275
|
-
if (!itemIds.has(clipboardData.rootItemId)) throw new Error("Clipboard root item is missing");
|
|
276
|
-
if (!(this.survey.surveyItems.get(target.parentId) instanceof GroupItemCore)) throw new Error(`Paste target '${target.parentId}' must be a group item`);
|
|
277
|
-
const idMapping = Object.create(null);
|
|
278
|
-
clipboardData.items.forEach(({ itemId }) => {
|
|
279
|
-
idMapping[itemId] = generateId();
|
|
280
|
-
});
|
|
281
|
-
const componentMapping = Object.create(null);
|
|
282
|
-
const copiedSlotIds = /* @__PURE__ */ new Set();
|
|
283
|
-
clipboardData.items.forEach(({ itemData }) => {
|
|
284
|
-
const item = this.survey.createItemFromRaw(itemData);
|
|
285
|
-
for (const slot of item.getResponseSlotDefinitions()) {
|
|
286
|
-
if (copiedSlotIds.has(slot.slotId)) throw new Error(`Duplicate copied slot ID: ${slot.slotId}`);
|
|
287
|
-
copiedSlotIds.add(slot.slotId);
|
|
288
|
-
}
|
|
289
|
-
const declaredIds = item.getResponseSlotDefinitions().flatMap((slot) => [
|
|
290
|
-
slot.slotId,
|
|
291
|
-
slot.primitiveId,
|
|
292
|
-
slot.rowId,
|
|
293
|
-
slot.columnId
|
|
294
|
-
].filter((id) => !!id));
|
|
295
|
-
for (const id of new Set([...item.getOwnedComponentIds(), ...declaredIds].filter((id) => id !== item.id))) {
|
|
296
|
-
if (componentMapping[id]) throw new Error(`Duplicate owned component ID: ${id}`);
|
|
297
|
-
componentMapping[id] = generateId();
|
|
298
|
-
}
|
|
299
|
-
});
|
|
300
|
-
const stagedItems = clipboardData.items.map(({ itemId, itemData }) => {
|
|
301
|
-
const newId = idMapping[itemId];
|
|
302
|
-
if (newId) {
|
|
303
|
-
const updatedItemData = this.updateItemIdsInData(itemData, idMapping, componentMapping);
|
|
304
|
-
updatedItemData.id = newId;
|
|
305
|
-
return this.survey.createItemFromRaw(updatedItemData);
|
|
306
|
-
}
|
|
307
|
-
throw new Error(`Missing copied identity for '${itemId}'`);
|
|
308
|
-
});
|
|
309
|
-
this.lastResponseNameChanges = allocateCopiedResponseNames(this.survey, stagedItems);
|
|
310
|
-
for (const item of stagedItems) this.survey.surveyItems.set(item.id, item);
|
|
311
|
-
this.addItemToParentGroup(target, stagedItems.find((item) => item.id === idMapping[clipboardData.rootItemId]));
|
|
312
|
-
const updatedTranslations = this.updateTranslations(clipboardData.translations, idMapping, componentMapping);
|
|
313
|
-
Object.keys(updatedTranslations).forEach((itemId) => {
|
|
314
|
-
const itemTranslations = new SurveyItemTranslations();
|
|
315
|
-
const serializedData = updatedTranslations[itemId];
|
|
316
|
-
Object.keys(serializedData).forEach((locale) => {
|
|
317
|
-
itemTranslations.setAllForLocale(locale, serializedData[locale]);
|
|
318
|
-
});
|
|
319
|
-
this.survey.translations.setItemTranslations(itemId, itemTranslations);
|
|
320
|
-
});
|
|
321
|
-
return idMapping[clipboardData.rootItemId];
|
|
322
|
-
}
|
|
323
|
-
/**
|
|
324
|
-
* Update all item ids in the raw item data recursively
|
|
325
|
-
*/
|
|
326
|
-
updateItemIdsInData(itemData, idMapping, componentMapping) {
|
|
327
|
-
const updatedData = JSON.parse(JSON.stringify(itemData));
|
|
328
|
-
if (updatedData.itemType === "group" && updatedData.config) {
|
|
329
|
-
const config = updatedData.config;
|
|
330
|
-
if (config.items) config.items = config.items.map((childId) => idMapping[childId] ?? childId);
|
|
331
|
-
}
|
|
332
|
-
const remap = (value, key) => {
|
|
333
|
-
if (typeof value === "string") {
|
|
334
|
-
if ([
|
|
335
|
-
"key",
|
|
336
|
-
"prefix",
|
|
337
|
-
"variableName",
|
|
338
|
-
"rowName",
|
|
339
|
-
"columnName",
|
|
340
|
-
"code",
|
|
341
|
-
"label",
|
|
342
|
-
"description",
|
|
343
|
-
"errorMessage",
|
|
344
|
-
"placeholder",
|
|
345
|
-
"unit"
|
|
346
|
-
].includes(key ?? "")) return value;
|
|
347
|
-
if ((key === "id" || key === "slotId") && idMapping[value]) return idMapping[value];
|
|
348
|
-
return componentMapping[value] ?? value;
|
|
349
|
-
}
|
|
350
|
-
if (Array.isArray(value)) return value.map((entry) => remap(entry));
|
|
351
|
-
if (value && typeof value === "object" && value.type === "previousResponse") return structuredClone(value);
|
|
352
|
-
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([name, entry]) => [componentMapping[name] ?? idMapping[name] ?? name, name === "value" && (typeof entry === "string" || Array.isArray(entry)) && value.type !== ValueType.reference && value.type !== ValueType.referenceArray ? entry : remap(entry, name)]));
|
|
353
|
-
return value;
|
|
354
|
-
};
|
|
355
|
-
updatedData.config = remap(updatedData.config);
|
|
356
|
-
if (updatedData.responseSettings) updatedData.responseSettings = Object.fromEntries(Object.entries(updatedData.responseSettings).map(([slotId, settings]) => {
|
|
357
|
-
const value = structuredClone(settings);
|
|
358
|
-
for (const name of ["optionCodes", "optionLabels"]) if (value[name]) value[name] = Object.fromEntries(Object.entries(value[name]).map(([id, code]) => [componentMapping[id] ?? id, code]));
|
|
359
|
-
return [componentMapping[slotId] ?? idMapping[slotId] ?? slotId, value];
|
|
360
|
-
}));
|
|
361
|
-
if (Object.keys(componentMapping).length) {
|
|
362
|
-
updatedData.displayConditions = remap(updatedData.displayConditions);
|
|
363
|
-
updatedData.disabledConditions = remap(updatedData.disabledConditions);
|
|
364
|
-
updatedData.validations = remap(updatedData.validations);
|
|
365
|
-
updatedData.prefills = remap(updatedData.prefills);
|
|
366
|
-
}
|
|
367
|
-
this.updateExpressionsInItemData(updatedData, idMapping);
|
|
368
|
-
return updatedData;
|
|
369
|
-
}
|
|
370
|
-
/**
|
|
371
|
-
* Update expressions in item data that reference the old ids
|
|
372
|
-
*/
|
|
373
|
-
updateExpressionsInItemData(itemData, idMapping) {
|
|
374
|
-
const visitBounds = (value) => {
|
|
375
|
-
if (!value || typeof value !== "object") return;
|
|
376
|
-
for (const [key, child] of Object.entries(value)) if (key === "valueExpression" && child && typeof child === "object") this.updateExpressionReferences(child, idMapping);
|
|
377
|
-
else visitBounds(child);
|
|
378
|
-
};
|
|
379
|
-
visitBounds(itemData.config);
|
|
380
|
-
if (itemData.displayConditions?.root) this.updateExpressionReferences(itemData.displayConditions.root, idMapping);
|
|
381
|
-
if (itemData.displayConditions?.components) Object.values(itemData.displayConditions.components).forEach((expr) => {
|
|
382
|
-
if (expr) this.updateExpressionReferences(expr, idMapping);
|
|
383
|
-
});
|
|
384
|
-
if (itemData.disabledConditions?.components) Object.values(itemData.disabledConditions.components).forEach((expr) => {
|
|
385
|
-
if (expr) this.updateExpressionReferences(expr, idMapping);
|
|
386
|
-
});
|
|
387
|
-
if (itemData.validations) Object.values(itemData.validations).forEach((expr) => {
|
|
388
|
-
if (expr) this.updateExpressionReferences(expr, idMapping);
|
|
389
|
-
});
|
|
390
|
-
if (itemData.prefills) itemData.prefills.forEach((prefill) => {
|
|
391
|
-
if (prefill.when) this.updateExpressionReferences(prefill.when, idMapping);
|
|
392
|
-
if (prefill.source.type === "expression") this.updateExpressionReferences(prefill.source.expression, idMapping);
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
/**
|
|
396
|
-
* Update references in a single expression (recursive)
|
|
397
|
-
*/
|
|
398
|
-
updateExpressionReferences(expression, idMapping) {
|
|
399
|
-
if (!expression || typeof expression !== "object") return;
|
|
400
|
-
const expr = expression;
|
|
401
|
-
if (expr.type === ExpressionType.ResponseVariable && expr.variableRef && typeof expr.variableRef === "object") {
|
|
402
|
-
const reference = expr.variableRef;
|
|
403
|
-
if (reference.slotId && Object.prototype.hasOwnProperty.call(idMapping, reference.slotId)) reference.slotId = idMapping[reference.slotId];
|
|
404
|
-
}
|
|
405
|
-
if (Array.isArray(expr.data)) expr.data.forEach((arg) => {
|
|
406
|
-
if (arg && typeof arg === "object" && arg !== null && "str" in arg) {
|
|
407
|
-
const argObj = arg;
|
|
408
|
-
if (typeof argObj.str === "string" && Object.prototype.hasOwnProperty.call(idMapping, argObj.str)) argObj.str = idMapping[argObj.str];
|
|
409
|
-
this.updateExpressionReferences(arg, idMapping);
|
|
410
|
-
}
|
|
411
|
-
});
|
|
412
|
-
Object.keys(expr).forEach((key) => {
|
|
413
|
-
const val = expr[key];
|
|
414
|
-
if (val && typeof val === "object") this.updateExpressionReferences(val, idMapping);
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
/**
|
|
418
|
-
* Update translation keys for the pasted item
|
|
419
|
-
*/
|
|
420
|
-
updateTranslations(translations, idMapping, componentMapping) {
|
|
421
|
-
const newTranslations = Object.create(null);
|
|
422
|
-
Object.keys(translations).forEach((itemId) => {
|
|
423
|
-
const itemTranslations = translations[itemId];
|
|
424
|
-
const newItemId = idMapping[itemId];
|
|
425
|
-
newTranslations[newItemId] = Object.fromEntries(Object.entries(itemTranslations).map(([locale, content]) => [locale, Object.fromEntries(Object.entries(content).map(([key, value]) => [key.split(".").map((part) => componentMapping[part] ?? part).join("."), value]))]));
|
|
426
|
-
});
|
|
427
|
-
return newTranslations;
|
|
428
|
-
}
|
|
429
|
-
/**
|
|
430
|
-
* Validate clipboard data format
|
|
431
|
-
*/
|
|
432
|
-
static isValidClipboardData(data) {
|
|
433
|
-
if (typeof data !== "object" || data === null || data === void 0) return false;
|
|
434
|
-
const clipboardData = data;
|
|
435
|
-
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;
|
|
436
|
-
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);
|
|
437
|
-
}
|
|
438
|
-
};
|
|
439
|
-
//#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";
|
|
440
6
|
//#region src/editor/undo-redo.ts
|
|
441
7
|
const CommitSource = {
|
|
442
8
|
USER: "user",
|
|
@@ -470,31 +36,10 @@ function cloneAssets(assets) {
|
|
|
470
36
|
return structuredCloneMethod(assets);
|
|
471
37
|
}
|
|
472
38
|
function normalizeHistoryEntry(entry) {
|
|
473
|
-
if ("kind"
|
|
474
|
-
if (entry.kind === "survey-snapshot") return {
|
|
475
|
-
kind: "survey-snapshot",
|
|
476
|
-
survey: structuredCloneMethod(entry.survey),
|
|
477
|
-
id: entry.id ?? generateId(),
|
|
478
|
-
timestamp: entry.timestamp,
|
|
479
|
-
meta: entry.meta,
|
|
480
|
-
memorySize: entry.memorySize
|
|
481
|
-
};
|
|
482
|
-
return {
|
|
483
|
-
kind: "asset-change",
|
|
484
|
-
changes: structuredCloneMethod(entry.changes),
|
|
485
|
-
id: entry.id ?? generateId(),
|
|
486
|
-
timestamp: entry.timestamp,
|
|
487
|
-
meta: entry.meta,
|
|
488
|
-
memorySize: entry.memorySize
|
|
489
|
-
};
|
|
490
|
-
}
|
|
39
|
+
if (entry.kind !== "survey-snapshot" && entry.kind !== "asset-change") throw new Error("Unsupported history entry kind");
|
|
491
40
|
return {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
id: entry.id ?? generateId(),
|
|
495
|
-
timestamp: entry.timestamp,
|
|
496
|
-
meta: entry.meta,
|
|
497
|
-
memorySize: entry.memorySize
|
|
41
|
+
...structuredCloneMethod(entry),
|
|
42
|
+
id: entry.id ?? generateId()
|
|
498
43
|
};
|
|
499
44
|
}
|
|
500
45
|
var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
@@ -519,7 +64,8 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
519
64
|
saveEntry(entry) {
|
|
520
65
|
const payload = entry.kind === "survey-snapshot" ? {
|
|
521
66
|
kind: entry.kind,
|
|
522
|
-
survey: entry.survey
|
|
67
|
+
survey: entry.survey,
|
|
68
|
+
changes: entry.changes
|
|
523
69
|
} : {
|
|
524
70
|
kind: entry.kind,
|
|
525
71
|
changes: entry.changes
|
|
@@ -586,7 +132,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
586
132
|
}
|
|
587
133
|
applyAssetPatch(assets, patch) {
|
|
588
134
|
const nextAssets = assets;
|
|
589
|
-
if (patch.next) nextAssets
|
|
135
|
+
if (patch.next) setOwnValue(nextAssets, patch.assetId, structuredCloneMethod(patch.next));
|
|
590
136
|
else delete nextAssets[patch.assetId];
|
|
591
137
|
return nextAssets;
|
|
592
138
|
}
|
|
@@ -595,8 +141,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
595
141
|
let assets = cloneAssets(this._initialAssets) ?? {};
|
|
596
142
|
for (let i = 1; i <= index; i++) {
|
|
597
143
|
const entry = this.history[i];
|
|
598
|
-
|
|
599
|
-
for (const patch of entry.changes) assets = this.applyAssetPatch(assets, patch);
|
|
144
|
+
for (const patch of entry.changes ?? []) assets = this.applyAssetPatch(assets, patch);
|
|
600
145
|
}
|
|
601
146
|
return Object.keys(assets).length > 0 ? assets : void 0;
|
|
602
147
|
}
|
|
@@ -606,8 +151,13 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
606
151
|
findEntryIndex(id) {
|
|
607
152
|
return this.history.findIndex((entry) => entry.id === id);
|
|
608
153
|
}
|
|
609
|
-
commit(survey, meta) {
|
|
610
|
-
this.
|
|
154
|
+
commit(survey, meta, changes) {
|
|
155
|
+
this.saveEntry({
|
|
156
|
+
kind: "survey-snapshot",
|
|
157
|
+
survey,
|
|
158
|
+
meta,
|
|
159
|
+
...changes?.length ? { changes } : {}
|
|
160
|
+
});
|
|
611
161
|
}
|
|
612
162
|
commitAssetChange(changes, meta) {
|
|
613
163
|
if (changes.length === 0) return;
|
|
@@ -730,6 +280,7 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
730
280
|
if (entry.kind === "survey-snapshot") return {
|
|
731
281
|
kind: "survey-snapshot",
|
|
732
282
|
survey: entry.survey,
|
|
283
|
+
changes: entry.changes,
|
|
733
284
|
id: entry.id ?? generateId(),
|
|
734
285
|
timestamp: entry.timestamp,
|
|
735
286
|
meta: entry.meta,
|
|
@@ -771,620 +322,1336 @@ var SurveyEditorUndoRedo = class SurveyEditorUndoRedo {
|
|
|
771
322
|
}
|
|
772
323
|
};
|
|
773
324
|
//#endregion
|
|
774
|
-
//#region src/editor/
|
|
775
|
-
function
|
|
776
|
-
|
|
777
|
-
|
|
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;
|
|
778
332
|
}
|
|
779
|
-
function
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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);
|
|
784
340
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
this._pluginRegistry = pluginRegistry ?? survey.getPluginRegistry();
|
|
816
|
-
const serialized = survey.serialize();
|
|
817
|
-
this._undoRedo = new SurveyEditorUndoRedo(stripAssetsFromSurveySnapshot(serialized), undoRedoConfig, meta, serialized.assets);
|
|
818
|
-
}
|
|
819
|
-
/**
|
|
820
|
-
* An isolated field edit owns only its field value (held by the caller) and
|
|
821
|
-
* affected item metadata. Any intervening core action invalidates this handle.
|
|
822
|
-
* The caller must finish pre-existing work before opening a handle.
|
|
823
|
-
*/
|
|
824
|
-
beginFieldEdit(itemId) {
|
|
825
|
-
if (this.hasUncommittedChanges) throw new Error("Finish pending changes before editing a field");
|
|
826
|
-
let expectedEpoch = this.mutationEpoch;
|
|
827
|
-
const history = this._undoRedo;
|
|
828
|
-
const index = history.getCurrentIndex();
|
|
829
|
-
const locales = this._survey.translations.locales;
|
|
830
|
-
const metadata = itemId ? structuredCloneMethod(this._survey.surveyItems.get(itemId)?.rawItem.editorMetadata) : void 0;
|
|
831
|
-
const owns = () => this.mutationEpoch === expectedEpoch && this._undoRedo === history && history.getCurrentIndex() === index;
|
|
832
|
-
return {
|
|
833
|
-
run: (write) => {
|
|
834
|
-
if (!owns()) throw new Error("Field edit was superseded by another action");
|
|
835
|
-
const result = write();
|
|
836
|
-
expectedEpoch = this.mutationEpoch;
|
|
837
|
-
return result;
|
|
838
|
-
},
|
|
839
|
-
reconcile: (restoreValue) => {
|
|
840
|
-
if (!owns()) return false;
|
|
841
|
-
restoreValue?.();
|
|
842
|
-
if (itemId) {
|
|
843
|
-
const item = this._survey.surveyItems.get(itemId);
|
|
844
|
-
if (!item) return false;
|
|
845
|
-
this._survey.surveyItems.set(itemId, this._survey.createItemFromRaw({
|
|
846
|
-
...item.rawItem,
|
|
847
|
-
editorMetadata: structuredCloneMethod(metadata)
|
|
848
|
-
}));
|
|
849
|
-
}
|
|
850
|
-
for (const locale of this._survey.translations.locales) if (!locales.includes(locale)) {
|
|
851
|
-
const data = this._survey.translations.serialize()?.[locale];
|
|
852
|
-
if (data && !data.surveyCardContent && !data.navigationContent && !data.validationMessages && Object.keys(data.itemTranslations ?? {}).length === 0) this._survey.translations.removeLocale(locale);
|
|
853
|
-
}
|
|
854
|
-
this._hasUncommittedChanges = false;
|
|
855
|
-
this.mutationEpoch++;
|
|
856
|
-
expectedEpoch = this.mutationEpoch;
|
|
857
|
-
return true;
|
|
858
|
-
}
|
|
859
|
-
};
|
|
860
|
-
}
|
|
861
|
-
get hasCommittedChanges() {
|
|
862
|
-
return this._undoRedo.getCurrentIndex() > 0;
|
|
863
|
-
}
|
|
864
|
-
/** Returns an immutable copy of the current survey state. */
|
|
865
|
-
get survey() {
|
|
866
|
-
const serialized = structuredCloneMethod(this._survey.serialize());
|
|
867
|
-
const registry = this._pluginRegistry ?? this._survey.getPluginRegistry();
|
|
868
|
-
return Survey.fromJson(serialized, registry);
|
|
869
|
-
}
|
|
870
|
-
get hasUncommittedChanges() {
|
|
871
|
-
return this._hasUncommittedChanges || this.pendingAssets.size > 0;
|
|
872
|
-
}
|
|
873
|
-
get undoRedo() {
|
|
874
|
-
return this._undoRedo;
|
|
875
|
-
}
|
|
876
|
-
commit(meta) {
|
|
877
|
-
if (!this.hasUncommittedChanges) return;
|
|
878
|
-
this.mutationEpoch++;
|
|
879
|
-
if (this._hasUncommittedChanges) this._undoRedo.commit(stripAssetsFromSurveySnapshot(this._survey.serialize()), meta);
|
|
880
|
-
if (this.pendingAssets.size) {
|
|
881
|
-
this._undoRedo.commitAssetChange([...this.pendingAssets].map(([assetId, patch]) => ({
|
|
882
|
-
assetId,
|
|
883
|
-
...patch
|
|
884
|
-
})), meta);
|
|
885
|
-
this.pendingAssets.clear();
|
|
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);
|
|
886
371
|
}
|
|
887
|
-
this._hasUncommittedChanges = false;
|
|
888
|
-
}
|
|
889
|
-
restoreHistoryState(snapshot, assets) {
|
|
890
|
-
this.mutationEpoch++;
|
|
891
|
-
this.lastResponseNameChanges = [];
|
|
892
|
-
this.pendingAssets.clear();
|
|
893
|
-
this._survey = Survey.fromJson(mergeSurveySnapshotWithAssets(snapshot, assets), this._pluginRegistry);
|
|
894
372
|
}
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
|
633
|
+
//#region src/editor/survey-editor.ts
|
|
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;
|
|
656
|
+
return snapshot;
|
|
657
|
+
}
|
|
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);
|
|
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. */
|
|
722
|
+
var SurveyEditor = class SurveyEditor {
|
|
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);
|
|
742
|
+
const serialized = survey.serialize();
|
|
743
|
+
this._history = new SurveyEditorUndoRedo(stripAssets(serialized), config, meta, serialized.assets);
|
|
744
|
+
}
|
|
745
|
+
get survey() {
|
|
746
|
+
return this.current;
|
|
747
|
+
}
|
|
748
|
+
get history() {
|
|
749
|
+
return this._history;
|
|
750
|
+
}
|
|
751
|
+
getAnswerTypes() {
|
|
752
|
+
return [...this.answerTypes];
|
|
753
|
+
}
|
|
754
|
+
get hasUncommittedChanges() {
|
|
755
|
+
return this.pendingSurvey || this.pendingAssets.length > 0;
|
|
756
|
+
}
|
|
757
|
+
get hasCommittedChanges() {
|
|
758
|
+
return this.history.getCurrentIndex() > 0;
|
|
759
|
+
}
|
|
760
|
+
subscribe(listener) {
|
|
761
|
+
this.listeners.add(listener);
|
|
762
|
+
return () => {
|
|
763
|
+
this.listeners.delete(listener);
|
|
764
|
+
};
|
|
765
|
+
}
|
|
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();
|
|
950
|
+
}
|
|
951
|
+
commitIfNeeded() {
|
|
952
|
+
this.commit(this.pendingMeta ?? {
|
|
953
|
+
label: "Latest content changes",
|
|
954
|
+
source: CommitSource.SYSTEM
|
|
955
|
+
});
|
|
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"
|
|
899
965
|
});
|
|
966
|
+
this.pendingSurvey = false;
|
|
967
|
+
this.pendingAssets = [];
|
|
968
|
+
this.pendingMeta = void 0;
|
|
969
|
+
this.publish(next);
|
|
900
970
|
}
|
|
901
971
|
undo() {
|
|
972
|
+
if (this.actionDepth) throw new Error("History navigation cannot run inside an edit action");
|
|
902
973
|
if (this.hasUncommittedChanges) {
|
|
903
|
-
this.restoreHistoryState(this.
|
|
904
|
-
this._hasUncommittedChanges = false;
|
|
974
|
+
this.restoreHistoryState(this.history.getCurrentState());
|
|
905
975
|
return true;
|
|
906
|
-
} else {
|
|
907
|
-
const previousState = this._undoRedo.undo();
|
|
908
|
-
if (previousState) {
|
|
909
|
-
this.restoreHistoryState(previousState, this._undoRedo.getCurrentAssets());
|
|
910
|
-
this._hasUncommittedChanges = false;
|
|
911
|
-
return true;
|
|
912
|
-
}
|
|
913
|
-
return false;
|
|
914
976
|
}
|
|
977
|
+
const value = this.history.undo();
|
|
978
|
+
if (!value) return false;
|
|
979
|
+
this.restoreHistoryState(value);
|
|
980
|
+
return true;
|
|
915
981
|
}
|
|
916
982
|
redo() {
|
|
983
|
+
if (this.actionDepth) throw new Error("History navigation cannot run inside an edit action");
|
|
917
984
|
if (this.hasUncommittedChanges) return false;
|
|
918
|
-
const
|
|
919
|
-
if (
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
return true;
|
|
923
|
-
}
|
|
924
|
-
return false;
|
|
985
|
+
const value = this.history.redo();
|
|
986
|
+
if (!value) return false;
|
|
987
|
+
this.restoreHistoryState(value);
|
|
988
|
+
return true;
|
|
925
989
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
*/
|
|
929
|
-
jumpToIndex(targetIndex) {
|
|
990
|
+
jumpToIndex(index) {
|
|
991
|
+
if (this.actionDepth) throw new Error("History navigation cannot run inside an edit action");
|
|
930
992
|
if (this.hasUncommittedChanges) return false;
|
|
931
|
-
const
|
|
932
|
-
if (
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
return true;
|
|
936
|
-
}
|
|
937
|
-
return false;
|
|
993
|
+
const value = this.history.jumpToIndex(index);
|
|
994
|
+
if (!value) return false;
|
|
995
|
+
this.restoreHistoryState(value);
|
|
996
|
+
return true;
|
|
938
997
|
}
|
|
939
998
|
canUndo() {
|
|
940
|
-
return this.hasUncommittedChanges || this.
|
|
999
|
+
return this.hasUncommittedChanges || this.history.canUndo();
|
|
941
1000
|
}
|
|
942
1001
|
canRedo() {
|
|
943
|
-
return !this.hasUncommittedChanges && this.
|
|
1002
|
+
return !this.hasUncommittedChanges && this.history.canRedo();
|
|
944
1003
|
}
|
|
945
1004
|
getUndoMeta() {
|
|
946
|
-
|
|
947
|
-
label: "Latest content changes",
|
|
948
|
-
source: CommitSource.SYSTEM
|
|
949
|
-
};
|
|
950
|
-
return this._undoRedo.getUndoMeta();
|
|
1005
|
+
return this.hasUncommittedChanges ? this.pendingMeta ?? null : this.history.getUndoMeta();
|
|
951
1006
|
}
|
|
952
1007
|
getRedoMeta() {
|
|
953
|
-
|
|
954
|
-
return this._undoRedo.getRedoMeta();
|
|
1008
|
+
return this.hasUncommittedChanges ? null : this.history.getRedoMeta();
|
|
955
1009
|
}
|
|
956
1010
|
getMemoryUsage() {
|
|
957
|
-
return this.
|
|
1011
|
+
return this.history.getMemoryUsage();
|
|
958
1012
|
}
|
|
959
1013
|
getUndoRedoConfig() {
|
|
960
|
-
return this.
|
|
1014
|
+
return this.history.getConfig();
|
|
961
1015
|
}
|
|
962
|
-
/**
|
|
963
|
-
* Get committed changes after the initial editor state up to the current undo/redo position.
|
|
964
|
-
*/
|
|
965
1016
|
getCommitsSinceInitial() {
|
|
966
|
-
return this.
|
|
1017
|
+
return this.history.getCommitsSinceInitial();
|
|
967
1018
|
}
|
|
968
|
-
/**
|
|
969
|
-
* Serialize the SurveyEditor state to JSON
|
|
970
|
-
* @returns A JSON-serializable object containing the complete editor state
|
|
971
|
-
*/
|
|
972
1019
|
toJson() {
|
|
973
1020
|
return {
|
|
974
|
-
version:
|
|
975
|
-
survey:
|
|
976
|
-
undoRedo: this.
|
|
977
|
-
hasUncommittedChanges: this.hasUncommittedChanges
|
|
1021
|
+
version: 2,
|
|
1022
|
+
survey: this.current.serialize(),
|
|
1023
|
+
undoRedo: structuredClone(this.history.serialize()),
|
|
1024
|
+
hasUncommittedChanges: this.hasUncommittedChanges,
|
|
1025
|
+
answerTypes: this.getAnswerTypes()
|
|
978
1026
|
};
|
|
979
1027
|
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
const
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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");
|
|
1007
1073
|
return editor;
|
|
1008
1074
|
}
|
|
1009
|
-
|
|
1010
|
-
this.
|
|
1011
|
-
this._hasUncommittedChanges = true;
|
|
1075
|
+
getAsset(id) {
|
|
1076
|
+
return structuredClone(ownValue(this.current.data.assets, id));
|
|
1012
1077
|
}
|
|
1013
|
-
|
|
1014
|
-
return
|
|
1015
|
-
}
|
|
1016
|
-
touchItemModifiedMetadata(itemId, timestamp = this.createEditorTimestamp()) {
|
|
1017
|
-
const item = this._survey.surveyItems.get(itemId);
|
|
1018
|
-
if (!item) return;
|
|
1019
|
-
item.setModifiedAt(timestamp);
|
|
1078
|
+
getAssets() {
|
|
1079
|
+
return new Map(Object.entries(structuredClone(this.current.data.assets ?? {})));
|
|
1020
1080
|
}
|
|
1021
1081
|
addItem(target, item, content) {
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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);
|
|
1035
1097
|
}
|
|
1036
|
-
if (
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
const timestamp = this.createEditorTimestamp();
|
|
1052
|
-
const item = this._survey.surveyItems.get(itemId);
|
|
1053
|
-
if (!item) return false;
|
|
1054
|
-
const parentItem = this._survey.getParentItem(itemId);
|
|
1055
|
-
if (!parentItem) throw new Error(`Item with id '${itemId}' is the root item`);
|
|
1056
|
-
if (item instanceof GroupItemCore) for (const childId of item.getChildrenIds()) this.removeItem(childId, true);
|
|
1057
|
-
this._survey.surveyItems.delete(itemId);
|
|
1058
|
-
this._survey.translations?.onItemDeleted(itemId);
|
|
1059
|
-
if (!nested) {
|
|
1060
|
-
parentItem.removeChild(itemId);
|
|
1061
|
-
this.touchItemModifiedMetadata(parentItem.id, timestamp);
|
|
1062
|
-
}
|
|
1063
|
-
return true;
|
|
1064
|
-
}
|
|
1065
|
-
moveItem(itemId, newTarget) {
|
|
1066
|
-
this.markAsModified();
|
|
1067
|
-
const timestamp = this.createEditorTimestamp();
|
|
1068
|
-
if (!this._survey.surveyItems.get(itemId)) throw new Error(`Item with id '${itemId}' not found`);
|
|
1069
|
-
const targetItem = this._survey.surveyItems.get(newTarget.parentId);
|
|
1070
|
-
if (!targetItem) throw new Error(`Target parent with id '${newTarget.parentId}' not found`);
|
|
1071
|
-
if (!(targetItem instanceof GroupItemCore)) throw new Error(`Target parent '${newTarget.parentId}' is not a group item (${targetItem.type})`);
|
|
1072
|
-
if (this._survey.isDescendantOf(newTarget.parentId, itemId)) throw new Error(`Cannot move item '${itemId}' to its descendant '${newTarget.parentId}'`);
|
|
1073
|
-
const currentParentItem = this._survey.getParentItem(itemId);
|
|
1074
|
-
if (currentParentItem?.id === newTarget.parentId) throw new Error(`Item '${itemId}' is already in the target parent '${newTarget.parentId}'`);
|
|
1075
|
-
if (currentParentItem) {
|
|
1076
|
-
const currentParentGroup = currentParentItem;
|
|
1077
|
-
currentParentGroup.removeChild(itemId);
|
|
1078
|
-
this.touchItemModifiedMetadata(currentParentGroup.id, timestamp);
|
|
1079
|
-
}
|
|
1080
|
-
const targetGroup = targetItem;
|
|
1081
|
-
const insertIndex = newTarget.index !== void 0 ? Math.min(newTarget.index, targetGroup.items.length) : targetGroup.items.length;
|
|
1082
|
-
targetGroup.addChild(itemId, insertIndex);
|
|
1083
|
-
this.touchItemModifiedMetadata(targetGroup.id, timestamp);
|
|
1084
|
-
this.touchItemModifiedMetadata(itemId, timestamp);
|
|
1085
|
-
return true;
|
|
1086
|
-
}
|
|
1087
|
-
/** Move several independent item subtrees as one ordered block. */
|
|
1088
|
-
moveItems(itemIds, newTarget) {
|
|
1089
|
-
const requestedIds = [...new Set(itemIds)];
|
|
1090
|
-
const selectedIds = requestedIds.filter((itemId) => !requestedIds.some((candidateId) => candidateId !== itemId && this._survey.isDescendantOf(itemId, candidateId)));
|
|
1091
|
-
if (selectedIds.length === 0) return false;
|
|
1092
|
-
const targetGroup = this._survey.surveyItems.get(newTarget.parentId);
|
|
1093
|
-
if (!(targetGroup instanceof GroupItemCore)) throw new Error(`Target parent '${newTarget.parentId}' is not a group item`);
|
|
1094
|
-
const entries = selectedIds.map((itemId) => {
|
|
1095
|
-
const item = this._survey.surveyItems.get(itemId);
|
|
1096
|
-
const parent = this._survey.getParentItem(itemId);
|
|
1097
|
-
if (!item || !(parent instanceof GroupItemCore)) throw new Error(`Item '${itemId}' cannot be moved`);
|
|
1098
|
-
if (itemId === newTarget.parentId || this._survey.isDescendantOf(newTarget.parentId, itemId)) throw new Error(`Cannot move item '${itemId}' into its own subtree`);
|
|
1099
|
-
return {
|
|
1100
|
-
item,
|
|
1101
|
-
parent,
|
|
1102
|
-
index: parent.items.indexOf(itemId)
|
|
1103
|
-
};
|
|
1104
|
-
});
|
|
1105
|
-
const order = /* @__PURE__ */ new Map();
|
|
1106
|
-
let orderIndex = 0;
|
|
1107
|
-
const visit = (group) => {
|
|
1108
|
-
for (const childId of group.items) {
|
|
1109
|
-
order.set(childId, orderIndex++);
|
|
1110
|
-
const child = this._survey.surveyItems.get(childId);
|
|
1111
|
-
if (child instanceof GroupItemCore) visit(child);
|
|
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
|
+
}
|
|
1112
1113
|
}
|
|
1113
|
-
};
|
|
1114
|
-
const root = this._survey.rootItem;
|
|
1115
|
-
if (root) visit(root);
|
|
1116
|
-
entries.sort((a, b) => (order.get(a.item.id) ?? 0) - (order.get(b.item.id) ?? 0));
|
|
1117
|
-
const selectedSet = new Set(entries.map(({ item }) => item.id));
|
|
1118
|
-
const requestedIndex = newTarget.index ?? targetGroup.items.length;
|
|
1119
|
-
const removedBeforeTarget = targetGroup.items.slice(0, requestedIndex).filter((id) => selectedSet.has(id)).length;
|
|
1120
|
-
const insertionIndex = Math.max(0, requestedIndex - removedBeforeTarget);
|
|
1121
|
-
const timestamp = this.createEditorTimestamp();
|
|
1122
|
-
this.markAsModified();
|
|
1123
|
-
for (const { item, parent } of entries) {
|
|
1124
|
-
parent.removeChild(item.id);
|
|
1125
|
-
this.touchItemModifiedMetadata(parent.id, timestamp);
|
|
1126
|
-
}
|
|
1127
|
-
entries.forEach(({ item }, offset) => {
|
|
1128
|
-
targetGroup.addChild(item.id, insertionIndex + offset);
|
|
1129
|
-
this.touchItemModifiedMetadata(item.id, timestamp);
|
|
1130
1114
|
});
|
|
1131
|
-
this.touchItemModifiedMetadata(targetGroup.id, timestamp);
|
|
1132
|
-
return true;
|
|
1133
1115
|
}
|
|
1134
|
-
/**
|
|
1135
|
-
* Get a deep copy of an item's raw data.
|
|
1136
|
-
* The returned object is independent of the survey—mutating it will not affect the survey.
|
|
1137
|
-
* Use this when you need to edit an item in a form or pass it to updateItem after modifications.
|
|
1138
|
-
*
|
|
1139
|
-
* @param itemId - The ID of the item to copy
|
|
1140
|
-
* @returns A deep copy of the item's RawSurveyItem data
|
|
1141
|
-
* @throws Error if the item is not found
|
|
1142
|
-
*/
|
|
1143
1116
|
getItemDataCopy(itemId) {
|
|
1144
|
-
const item = this.
|
|
1145
|
-
if (!item) throw new Error(`
|
|
1146
|
-
return
|
|
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);
|
|
1133
|
+
});
|
|
1147
1134
|
}
|
|
1148
|
-
/**
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
const nextSlotIds = new Set(this._survey.createItemFromRaw(merged).getResponseSlotDefinitions().map((slot) => slot.slotId));
|
|
1172
|
-
for (const slot of existingItem.getResponseSlotDefinitions()) if (!nextSlotIds.has(slot.slotId)) delete merged.responseSettings[slot.slotId];
|
|
1173
|
-
}
|
|
1174
|
-
const stagedRaw = this._survey.serialize();
|
|
1175
|
-
stagedRaw.surveyItems = stagedRaw.surveyItems.map((item) => item.id === itemId ? merged : item);
|
|
1176
|
-
const beforeIssues = new Set(this._survey.getResponseSlotRegistry().diagnostics.map((issue) => JSON.stringify(issue)));
|
|
1177
|
-
const stagedSurvey = Survey.fromJson(stagedRaw, this._survey.getPluginRegistry());
|
|
1178
|
-
const introduced = stagedSurvey.getResponseSlotRegistry().diagnostics.filter((issue) => !beforeIssues.has(JSON.stringify(issue)));
|
|
1179
|
-
if (introduced.length) throw new Error(introduced.map((issue) => issue.message).join("\n"));
|
|
1180
|
-
const previousProjectionIssues = new Set(getDefaultProjectionIssues(this._survey));
|
|
1181
|
-
const projectionIssues = getDefaultProjectionIssues(stagedSurvey).filter((issue) => !previousProjectionIssues.has(issue));
|
|
1182
|
-
if (projectionIssues.length) throw new Error(projectionIssues.join("\n"));
|
|
1183
|
-
const newItem = this._survey.createItemFromRaw({
|
|
1184
|
-
...merged,
|
|
1185
|
-
editorMetadata: existingItem.rawItem.editorMetadata
|
|
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
|
+
});
|
|
1186
1158
|
});
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
return this._survey.translations.getItemTranslationContent(itemId, locale, contentKey, fallbackLocale);
|
|
1194
|
-
}
|
|
1195
|
-
/** Publish accepted content without committing history. Equivalent writes are no-ops. */
|
|
1196
|
-
setItemTranslationContent(itemId, locale, contentKey, content) {
|
|
1197
|
-
const item = this._survey.surveyItems.get(itemId);
|
|
1198
|
-
if (!item) return false;
|
|
1199
|
-
if (!this._survey.translations.setItemTranslationContent(itemId, locale, contentKey, content)) return false;
|
|
1200
|
-
item.setModifiedAt(this.createEditorTimestamp());
|
|
1201
|
-
this.markAsModified();
|
|
1202
|
-
return true;
|
|
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" });
|
|
1163
|
+
}
|
|
1164
|
+
return changed;
|
|
1203
1165
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
if (!item) return false;
|
|
1207
|
-
this.markAsModified();
|
|
1208
|
-
item.setModifiedAt(this.createEditorTimestamp());
|
|
1209
|
-
this._survey.translations.setItemTranslations(itemId, updatedContent);
|
|
1210
|
-
return true;
|
|
1166
|
+
renameResponseVariables(edits) {
|
|
1167
|
+
return this.updateResponseSettings(edits, []);
|
|
1211
1168
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
if (updates.surveyCard) this._survey.translations.setSurveyCardContent(updates.surveyCard.locale, updates.surveyCard.content);
|
|
1219
|
-
if (updates.navigation) this._survey.translations.setNavigationContent(updates.navigation.locale, updates.navigation.content);
|
|
1220
|
-
if (updates.validationMessages) this._survey.translations.setValidationMessages(updates.validationMessages.locale, updates.validationMessages.content);
|
|
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;
|
|
1221
1175
|
}
|
|
1222
|
-
/** Add an empty survey locale so it can be authored before translated content exists. */
|
|
1223
1176
|
addLocale(locale) {
|
|
1224
|
-
|
|
1225
|
-
if (
|
|
1226
|
-
|
|
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: {} });
|
|
1182
|
+
});
|
|
1183
|
+
return true;
|
|
1227
1184
|
}
|
|
1228
|
-
/** Remove a locale and every survey- and item-level translation stored for it. */
|
|
1229
1185
|
removeLocale(locale) {
|
|
1230
|
-
if (!this.
|
|
1231
|
-
this.
|
|
1232
|
-
|
|
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
|
+
});
|
|
1233
1195
|
return true;
|
|
1234
1196
|
}
|
|
1235
|
-
/**
|
|
1236
|
-
|
|
1237
|
-
|
|
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;
|
|
1203
|
+
});
|
|
1204
|
+
return true;
|
|
1205
|
+
}
|
|
1206
|
+
updateSurveyTranslations(updates) {
|
|
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
|
+
});
|
|
1223
|
+
}
|
|
1238
1224
|
getMaxItemsPerPage() {
|
|
1239
|
-
|
|
1240
|
-
return val ? { ...val } : void 0;
|
|
1225
|
+
return structuredClone(this.current.data.maxItemsPerPage);
|
|
1241
1226
|
}
|
|
1242
|
-
/**
|
|
1243
|
-
* Update maxItemsPerPage.
|
|
1244
|
-
*/
|
|
1245
1227
|
updateMaxItemsPerPage(value) {
|
|
1246
|
-
this.
|
|
1247
|
-
|
|
1228
|
+
this.transact({ label: "Change page size" }, (draft) => {
|
|
1229
|
+
if (value === void 0) delete draft.maxItemsPerPage;
|
|
1230
|
+
else draft.maxItemsPerPage = structuredClone(value);
|
|
1231
|
+
});
|
|
1248
1232
|
}
|
|
1249
|
-
/**
|
|
1250
|
-
* Get metadata immutably (returns a copy).
|
|
1251
|
-
*/
|
|
1252
1233
|
getMetadata() {
|
|
1253
|
-
|
|
1254
|
-
return val ? { ...val } : void 0;
|
|
1234
|
+
return structuredClone(this.current.data.metadata);
|
|
1255
1235
|
}
|
|
1256
|
-
/**
|
|
1257
|
-
* Update metadata.
|
|
1258
|
-
*/
|
|
1259
1236
|
updateMetadata(metadata) {
|
|
1260
|
-
this.
|
|
1261
|
-
|
|
1237
|
+
this.transact({ label: "Edit survey metadata" }, (draft) => {
|
|
1238
|
+
if (metadata === void 0) delete draft.metadata;
|
|
1239
|
+
else draft.metadata = structuredClone(metadata);
|
|
1240
|
+
});
|
|
1262
1241
|
}
|
|
1263
|
-
/**
|
|
1264
|
-
* Get a template value immutably (returns a deep copy).
|
|
1265
|
-
*/
|
|
1266
1242
|
getTemplateValue(key) {
|
|
1267
|
-
|
|
1268
|
-
return val ? structuredCloneMethod(val) : void 0;
|
|
1243
|
+
return structuredClone(this.current.getTemplateValue(key));
|
|
1269
1244
|
}
|
|
1270
|
-
/**
|
|
1271
|
-
* Get all template values immutably (returns a new Map with deep-copied values).
|
|
1272
|
-
*/
|
|
1273
1245
|
getTemplateValues() {
|
|
1274
|
-
|
|
1275
|
-
const result = /* @__PURE__ */ new Map();
|
|
1276
|
-
for (const key of keys) {
|
|
1277
|
-
const val = this._survey.getTemplateValue(key);
|
|
1278
|
-
if (val) result.set(key, structuredCloneMethod(val));
|
|
1279
|
-
}
|
|
1280
|
-
return result;
|
|
1246
|
+
return new Map(Object.entries(structuredClone(this.current.data.templateValues ?? {})));
|
|
1281
1247
|
}
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
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
|
+
});
|
|
1288
1253
|
}
|
|
1289
|
-
/**
|
|
1290
|
-
* Remove a template value.
|
|
1291
|
-
*/
|
|
1292
1254
|
removeTemplateValue(key) {
|
|
1293
|
-
this.
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
/**
|
|
1297
|
-
* Get a survey asset immutably.
|
|
1298
|
-
*/
|
|
1299
|
-
getAsset(assetId) {
|
|
1300
|
-
const asset = this._survey.getAsset(assetId);
|
|
1301
|
-
return asset ? structuredCloneMethod(asset) : void 0;
|
|
1255
|
+
this.transact({ label: `Remove template: ${key}` }, (draft) => {
|
|
1256
|
+
delete draft.templateValues?.[key];
|
|
1257
|
+
});
|
|
1302
1258
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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);
|
|
1308
1268
|
}
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
if (asset) this._survey.setAsset(assetId, structuredCloneMethod(asset));
|
|
1317
|
-
else this._survey.deleteAsset(assetId);
|
|
1318
|
-
if (JSON.stringify(baseline) === JSON.stringify(asset)) this.pendingAssets.delete(assetId);
|
|
1319
|
-
else this.pendingAssets.set(assetId, {
|
|
1320
|
-
prev: baseline,
|
|
1321
|
-
next: structuredCloneMethod(asset)
|
|
1269
|
+
setAsset(id, asset) {
|
|
1270
|
+
this.transact({
|
|
1271
|
+
label: `Update asset: ${id}`,
|
|
1272
|
+
source: CommitSource.USER
|
|
1273
|
+
}, (draft) => {
|
|
1274
|
+
draft.assets ??= {};
|
|
1275
|
+
setOwnValue(draft.assets, id, asset);
|
|
1322
1276
|
});
|
|
1323
|
-
this.mutationEpoch++;
|
|
1324
1277
|
}
|
|
1325
|
-
|
|
1326
|
-
this.
|
|
1327
|
-
|
|
1328
|
-
this._survey.setAsset(assetId, structuredCloneMethod(asset));
|
|
1329
|
-
this._undoRedo.commitAssetChange([{
|
|
1330
|
-
assetId,
|
|
1331
|
-
prev: previousAsset,
|
|
1332
|
-
next: structuredCloneMethod(asset)
|
|
1333
|
-
}], {
|
|
1334
|
-
label: `Update asset: ${assetId}`,
|
|
1278
|
+
removeAsset(id) {
|
|
1279
|
+
this.transact({
|
|
1280
|
+
label: `Remove asset: ${id}`,
|
|
1335
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
|
+
}
|
|
1336
1287
|
});
|
|
1337
1288
|
}
|
|
1338
|
-
/**
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
const
|
|
1344
|
-
if (!
|
|
1345
|
-
this.
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
+
}
|
|
1352
1342
|
});
|
|
1343
|
+
return {
|
|
1344
|
+
itemId,
|
|
1345
|
+
elementId: element.id
|
|
1346
|
+
};
|
|
1353
1347
|
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
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;
|
|
1361
1396
|
}
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
+
}
|
|
1381
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));
|
|
1382
1507
|
}
|
|
1383
|
-
|
|
1384
|
-
|
|
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;
|
|
1385
1652
|
}
|
|
1386
1653
|
};
|
|
1387
1654
|
//#endregion
|
|
1388
|
-
export { CommitSource,
|
|
1655
|
+
export { CommitSource, SURVEY_EDITOR_ITEM_COLORS, SurveyEditor, SurveyEditorUndoRedo, allocateCopiedResponseNames, copySurveyItem, createEditorSessionSchema, createItemClipboardSchema, getAnswerTypeChangeDiagnostics, prepareItemPaste, previewResponseNaming, previewResponseSettings };
|
|
1389
1656
|
|
|
1390
1657
|
//# sourceMappingURL=editor.mjs.map
|