@micha.bigler/survey-renderer 0.1.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/dist/SurveyCompletionLayout.js +26 -0
- package/dist/SurveyDiscoverySelect.js +271 -0
- package/dist/SurveyFilteredPlaceSelect.js +74 -0
- package/dist/SurveyGeoSelect.js +361 -0
- package/dist/SurveyMapSelect.js +5 -0
- package/dist/SurveyQuestionHeader.js +50 -0
- package/dist/SurveyRenderer.js +985 -0
- package/dist/discoveryDatasets.js +17 -0
- package/dist/discoveryEngine.js +160 -0
- package/dist/geoDatasets.js +17 -0
- package/dist/i18n/surveyRendererTranslations.js +242 -0
- package/dist/imageDownscale.js +64 -0
- package/dist/index.js +11 -0
- package/dist/localizeSurvey.js +24 -0
- package/dist/questionDefaults.js +13 -0
- package/dist/questionUiConfig.js +43 -0
- package/dist/stagedGroups.js +258 -0
- package/dist/types.js +22 -0
- package/package.json +36 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { resolveQuestionUiConfig } from "./questionUiConfig";
|
|
2
|
+
function tokenizeAppearance(appearance) {
|
|
3
|
+
return String(appearance || "")
|
|
4
|
+
.split(/\s+/)
|
|
5
|
+
.map((token) => token.trim())
|
|
6
|
+
.filter(Boolean);
|
|
7
|
+
}
|
|
8
|
+
function hasStagedGroupAppearance(group) {
|
|
9
|
+
return tokenizeAppearance(group === null || group === void 0 ? void 0 : group.appearance).includes("staged-group");
|
|
10
|
+
}
|
|
11
|
+
function stripOuterParentheses(expression) {
|
|
12
|
+
let source = String(expression || "").trim();
|
|
13
|
+
while (source.startsWith("(") && source.endsWith(")")) {
|
|
14
|
+
let depth = 0;
|
|
15
|
+
let balanced = true;
|
|
16
|
+
let quoteCharacter = "";
|
|
17
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
18
|
+
const character = source[index];
|
|
19
|
+
if (quoteCharacter) {
|
|
20
|
+
if (character === quoteCharacter && source[index - 1] !== "\\") {
|
|
21
|
+
quoteCharacter = "";
|
|
22
|
+
}
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (character === "'" || character === "\"") {
|
|
26
|
+
quoteCharacter = character;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (character === "(") {
|
|
30
|
+
depth += 1;
|
|
31
|
+
}
|
|
32
|
+
else if (character === ")") {
|
|
33
|
+
depth -= 1;
|
|
34
|
+
}
|
|
35
|
+
if (depth === 0 && index < source.length - 1) {
|
|
36
|
+
balanced = false;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!balanced) {
|
|
41
|
+
return source;
|
|
42
|
+
}
|
|
43
|
+
source = source.slice(1, -1).trim();
|
|
44
|
+
}
|
|
45
|
+
return source;
|
|
46
|
+
}
|
|
47
|
+
function splitTopLevel(expression, keyword) {
|
|
48
|
+
const source = stripOuterParentheses(expression);
|
|
49
|
+
const lowerKeyword = ` ${String(keyword || "").toLowerCase()} `;
|
|
50
|
+
const parts = [];
|
|
51
|
+
let current = "";
|
|
52
|
+
let depth = 0;
|
|
53
|
+
let quoteCharacter = "";
|
|
54
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
55
|
+
const character = source[index];
|
|
56
|
+
if (quoteCharacter) {
|
|
57
|
+
current += character;
|
|
58
|
+
if (character === quoteCharacter && source[index - 1] !== "\\") {
|
|
59
|
+
quoteCharacter = "";
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (character === "'" || character === "\"") {
|
|
64
|
+
quoteCharacter = character;
|
|
65
|
+
current += character;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (character === "(") {
|
|
69
|
+
depth += 1;
|
|
70
|
+
current += character;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (character === ")") {
|
|
74
|
+
depth -= 1;
|
|
75
|
+
current += character;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (depth === 0) {
|
|
79
|
+
const nextSlice = source.slice(index, index + lowerKeyword.length).toLowerCase();
|
|
80
|
+
if (nextSlice === lowerKeyword) {
|
|
81
|
+
parts.push(current.trim());
|
|
82
|
+
current = "";
|
|
83
|
+
index += lowerKeyword.length - 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
current += character;
|
|
88
|
+
}
|
|
89
|
+
if (current.trim()) {
|
|
90
|
+
parts.push(current.trim());
|
|
91
|
+
}
|
|
92
|
+
return parts;
|
|
93
|
+
}
|
|
94
|
+
function parseLiteral(rawValue) {
|
|
95
|
+
const source = String(rawValue || "").trim();
|
|
96
|
+
if (!source) {
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
if ((source.startsWith("'") && source.endsWith("'")) || (source.startsWith("\"") && source.endsWith("\""))) {
|
|
100
|
+
return source.slice(1, -1);
|
|
101
|
+
}
|
|
102
|
+
if (/^\d+(\.\d+)?$/.test(source)) {
|
|
103
|
+
return Number(source);
|
|
104
|
+
}
|
|
105
|
+
if (/^true\(\)$/i.test(source) || /^true$/i.test(source)) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (/^false\(\)$/i.test(source) || /^false$/i.test(source)) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const fieldMatch = source.match(/^\$\{([^}]+)\}$/);
|
|
112
|
+
if (fieldMatch) {
|
|
113
|
+
return { fieldName: fieldMatch[1] };
|
|
114
|
+
}
|
|
115
|
+
return source;
|
|
116
|
+
}
|
|
117
|
+
function resolveOperandValue(operand, answers) {
|
|
118
|
+
if (operand && typeof operand === "object" && operand.fieldName) {
|
|
119
|
+
return answers === null || answers === void 0 ? void 0 : answers[operand.fieldName];
|
|
120
|
+
}
|
|
121
|
+
return operand;
|
|
122
|
+
}
|
|
123
|
+
function selected(value, option) {
|
|
124
|
+
if (value == null) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
if (Array.isArray(value)) {
|
|
128
|
+
return value.map((entry) => String(entry)).includes(String(option));
|
|
129
|
+
}
|
|
130
|
+
return String(value) === String(option);
|
|
131
|
+
}
|
|
132
|
+
function countSelected(value) {
|
|
133
|
+
if (value == null || value === "") {
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
if (Array.isArray(value)) {
|
|
137
|
+
return value.length;
|
|
138
|
+
}
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
function compareValues(left, operator, right) {
|
|
142
|
+
switch (operator) {
|
|
143
|
+
case "=":
|
|
144
|
+
case "==":
|
|
145
|
+
return left === right;
|
|
146
|
+
case "!=":
|
|
147
|
+
case "<>":
|
|
148
|
+
return left !== right;
|
|
149
|
+
case "<":
|
|
150
|
+
return left < right;
|
|
151
|
+
case "<=":
|
|
152
|
+
return left <= right;
|
|
153
|
+
case ">":
|
|
154
|
+
return left > right;
|
|
155
|
+
case ">=":
|
|
156
|
+
return left >= right;
|
|
157
|
+
default:
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function evaluateAtomicExpression(expression, answers) {
|
|
162
|
+
const source = stripOuterParentheses(expression);
|
|
163
|
+
if (!source) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (/^true\(\)$/i.test(source) || /^true$/i.test(source)) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
if (/^false\(\)$/i.test(source) || /^false$/i.test(source)) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
// Unterstützt XLSForm-Syntax selected(${field}, 'val') und einfaches selected(field, 'val')
|
|
173
|
+
const selectedMatch = source.match(/^selected\(\s*(?:\$\{([^}]+)\}|([^,\s)]+))\s*,\s*(['"])(.*?)\3\s*\)$/i);
|
|
174
|
+
if (selectedMatch) {
|
|
175
|
+
const fieldName = selectedMatch[1] || selectedMatch[2];
|
|
176
|
+
return selected(answers === null || answers === void 0 ? void 0 : answers[fieldName], selectedMatch[4]);
|
|
177
|
+
}
|
|
178
|
+
const countSelectedMatch = source.match(/^count-selected\(\s*\$\{([^}]+)\}\s*\)\s*(<=|>=|!=|=|==|<|>)\s*(.+)$/i);
|
|
179
|
+
if (countSelectedMatch) {
|
|
180
|
+
const left = countSelected(answers === null || answers === void 0 ? void 0 : answers[countSelectedMatch[1]]);
|
|
181
|
+
const right = resolveOperandValue(parseLiteral(countSelectedMatch[3]), answers);
|
|
182
|
+
return compareValues(left, countSelectedMatch[2], right);
|
|
183
|
+
}
|
|
184
|
+
const fieldComparisonMatch = source.match(/^\$\{([^}]+)\}\s*(<=|>=|!=|=|==|<|>)\s*(.+)$/i);
|
|
185
|
+
if (fieldComparisonMatch) {
|
|
186
|
+
const left = answers === null || answers === void 0 ? void 0 : answers[fieldComparisonMatch[1]];
|
|
187
|
+
const right = resolveOperandValue(parseLiteral(fieldComparisonMatch[3]), answers);
|
|
188
|
+
return compareValues(left, fieldComparisonMatch[2], right);
|
|
189
|
+
}
|
|
190
|
+
const directFieldMatch = source.match(/^\$\{([^}]+)\}$/);
|
|
191
|
+
if (directFieldMatch) {
|
|
192
|
+
return Boolean(answers === null || answers === void 0 ? void 0 : answers[directFieldMatch[1]]);
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
export function evaluateRelevantExpression(expression, answers = {}) {
|
|
197
|
+
const source = stripOuterParentheses(expression);
|
|
198
|
+
if (!source) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
const orParts = splitTopLevel(source, "or");
|
|
202
|
+
if (orParts.length > 1) {
|
|
203
|
+
return orParts.some((part) => evaluateRelevantExpression(part, answers));
|
|
204
|
+
}
|
|
205
|
+
const andParts = splitTopLevel(source, "and");
|
|
206
|
+
if (andParts.length > 1) {
|
|
207
|
+
return andParts.every((part) => evaluateRelevantExpression(part, answers));
|
|
208
|
+
}
|
|
209
|
+
return evaluateAtomicExpression(source, answers);
|
|
210
|
+
}
|
|
211
|
+
export function mergeStagedAnswers(baseAnswers = {}, stagedAnswersByGroup = {}) {
|
|
212
|
+
return Object.values(stagedAnswersByGroup || {}).reduce((currentAnswers, groupAnswers) => (Object.assign(Object.assign({}, currentAnswers), (groupAnswers || {}))), Object.assign({}, (baseAnswers || {})));
|
|
213
|
+
}
|
|
214
|
+
export function getGroupsByName(definition = {}) {
|
|
215
|
+
return Object.fromEntries(((definition === null || definition === void 0 ? void 0 : definition.groups) || []).map((group) => [group.name, group]));
|
|
216
|
+
}
|
|
217
|
+
export function getQuestionStagedGroupKey(question, questionUiConfig = {}, groupsByName = {}) {
|
|
218
|
+
var _a;
|
|
219
|
+
const configuredGroupKey = ((_a = resolveQuestionUiConfig(question, questionUiConfig)) === null || _a === void 0 ? void 0 : _a.stagedGroup) || "";
|
|
220
|
+
if (configuredGroupKey) {
|
|
221
|
+
return configuredGroupKey;
|
|
222
|
+
}
|
|
223
|
+
const groupPath = Array.isArray(question === null || question === void 0 ? void 0 : question.group_path) ? question.group_path : [];
|
|
224
|
+
const matchedGroup = [...groupPath]
|
|
225
|
+
.reverse()
|
|
226
|
+
.map((groupName) => groupsByName === null || groupsByName === void 0 ? void 0 : groupsByName[groupName])
|
|
227
|
+
.find((group) => hasStagedGroupAppearance(group));
|
|
228
|
+
return (matchedGroup === null || matchedGroup === void 0 ? void 0 : matchedGroup.name) || "";
|
|
229
|
+
}
|
|
230
|
+
export function getPageStagedGroupKey(pageItem, questionUiConfig = {}, groupsByName = {}) {
|
|
231
|
+
if ((pageItem === null || pageItem === void 0 ? void 0 : pageItem.pageType) !== "section") {
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
const stagedGroupKeys = Array.from(new Set((pageItem.questions || [])
|
|
235
|
+
.map((question) => getQuestionStagedGroupKey(question, questionUiConfig, groupsByName))
|
|
236
|
+
.filter(Boolean)));
|
|
237
|
+
return stagedGroupKeys.length === 1 ? stagedGroupKeys[0] : "";
|
|
238
|
+
}
|
|
239
|
+
export function getStagedGroupQuestionNames(definition = {}, groupKey = "", questionUiConfig = {}, groupsByName = {}) {
|
|
240
|
+
if (!groupKey) {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
return ((definition === null || definition === void 0 ? void 0 : definition.questions) || [])
|
|
244
|
+
.filter((question) => getQuestionStagedGroupKey(question, questionUiConfig, groupsByName) === groupKey)
|
|
245
|
+
.map((question) => question.name);
|
|
246
|
+
}
|
|
247
|
+
export function getStagedQuestionNames(definition = {}, questionUiConfig = {}, groupsByName = {}) {
|
|
248
|
+
return ((definition === null || definition === void 0 ? void 0 : definition.questions) || [])
|
|
249
|
+
.filter((question) => Boolean(getQuestionStagedGroupKey(question, questionUiConfig, groupsByName)))
|
|
250
|
+
.map((question) => question.name);
|
|
251
|
+
}
|
|
252
|
+
export function isQuestionVisible(question, answers, serverVisibleQuestionNames, questionUiConfig = {}, groupsByName = {}) {
|
|
253
|
+
const stagedGroupKey = getQuestionStagedGroupKey(question, questionUiConfig, groupsByName);
|
|
254
|
+
if (!stagedGroupKey) {
|
|
255
|
+
return serverVisibleQuestionNames.has(question.name);
|
|
256
|
+
}
|
|
257
|
+
return evaluateRelevantExpression(question === null || question === void 0 ? void 0 : question.relevant, answers);
|
|
258
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} SurveyRuntimeAdapter
|
|
3
|
+
*
|
|
4
|
+
* Documents the FULL contract a host app implements around this package -- it is NOT a single
|
|
5
|
+
* prop object consumed by `SurveyRenderer` itself. `SurveyRenderer` is a pure, stateless
|
|
6
|
+
* component that only ever receives a `mediaUrl` FUNCTION as one of its own flat props (see
|
|
7
|
+
* `SurveyRenderer.jsx`); it has no `saveDraft`/`submit`/`uploadImage`/`verifyCaptcha` props and
|
|
8
|
+
* makes no network calls. Those four operations are owned entirely by the HOST'S OWN wrapper
|
|
9
|
+
* component (e.g. survey_app's `PublicSurveyRuntime.jsx`/`PreviewSurveyRuntime.jsx`, which stay
|
|
10
|
+
* outside this package) -- the host wires its own API client into an object shaped like this,
|
|
11
|
+
* then derives the flat props it passes down to `SurveyRenderer` (in particular `mediaUrl:
|
|
12
|
+
* (mediaId) => adapter.mediaUrl(mediaId)`) and calls `adapter.saveDraft`/`submit`/`uploadImage`/
|
|
13
|
+
* `verifyCaptcha` itself around the renderer, not through it. This typedef exists purely to
|
|
14
|
+
* document that expected host-side shape for implementers building a new host integration.
|
|
15
|
+
*
|
|
16
|
+
* @property {(answers: object, meta: object) => Promise<{evaluation: object, validation: object}>} saveDraft
|
|
17
|
+
* @property {(answers: object, meta: object) => Promise<object>} submit
|
|
18
|
+
* @property {(file: File) => Promise<{mediaId: number}>} uploadImage
|
|
19
|
+
* @property {(mediaId: number) => string} mediaUrl
|
|
20
|
+
* @property {(token: string) => Promise<void>} [verifyCaptcha]
|
|
21
|
+
*/
|
|
22
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@micha.bigler/survey-renderer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"module": "dist/index.js",
|
|
6
|
+
"files": ["dist"],
|
|
7
|
+
"repository": { "type": "git", "url": "https://github.com/bigler-webapps/survey-renderer" },
|
|
8
|
+
"private": false,
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"react": "^19.2.7",
|
|
11
|
+
"react-dom": "^19.2.7",
|
|
12
|
+
"@mui/material": "^7.3.11",
|
|
13
|
+
"@mui/icons-material": "^7.3.11",
|
|
14
|
+
"react-i18next": "^17.0.8",
|
|
15
|
+
"i18next": "^26.2.0"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"leaflet": "^1.9.4",
|
|
19
|
+
"react-leaflet": "^5.0.0"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.build.json",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"prepare": "tsc -p tsconfig.build.json"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@emotion/react": "^11.14.0",
|
|
28
|
+
"@emotion/styled": "^11.14.1",
|
|
29
|
+
"@testing-library/react": "^16.3.2",
|
|
30
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
31
|
+
"@testing-library/user-event": "^14.6.1",
|
|
32
|
+
"jsdom": "^29.1.1",
|
|
33
|
+
"typescript": "^6.0.3",
|
|
34
|
+
"vitest": "^4.1.10"
|
|
35
|
+
}
|
|
36
|
+
}
|