@cxpinsight/survey-spec 0.1.0 → 0.2.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/LICENSE +21 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/pageFlow.d.ts +49 -0
- package/dist/pageFlow.d.ts.map +1 -0
- package/dist/pageFlow.js +133 -0
- package/dist/validate.d.ts +54 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +243 -0
- package/package.json +4 -4
- package/src/expressionEval.d.ts +0 -7
- package/src/expressionEval.d.ts.map +0 -1
- package/src/expressionEval.js +0 -337
- package/src/expressionEval.js.map +0 -1
- package/src/expressionEval.ts +0 -321
- package/src/index.d.ts +0 -16
- package/src/index.d.ts.map +0 -1
- package/src/index.js +0 -22
- package/src/index.js.map +0 -1
- package/src/index.ts +0 -15
- package/src/resolveVariable.d.ts +0 -19
- package/src/resolveVariable.d.ts.map +0 -1
- package/src/resolveVariable.js +0 -52
- package/src/resolveVariable.js.map +0 -1
- package/src/resolveVariable.ts +0 -50
- package/src/types.d.ts +0 -17
- package/src/types.d.ts.map +0 -1
- package/src/types.js +0 -3
- package/src/types.js.map +0 -1
- package/src/types.ts +0 -18
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CXPinsight
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
CHANGED
|
@@ -12,5 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export { evaluateExpression, evaluateCondition, isParseable } from './expressionEval';
|
|
14
14
|
export { resolveVariable } from './resolveVariable';
|
|
15
|
+
export { splitIntoPages } from './pageFlow';
|
|
16
|
+
export { validateAnswer } from './validate';
|
|
17
|
+
export type { ValidatableQuestion } from './validate';
|
|
18
|
+
export type { Page, PageItem, PageFlowOptions, FlowQuestion, FlowSection } from './pageFlow';
|
|
15
19
|
export type { AnswersMap, VariablesMap, EvalContext } from './types';
|
|
16
20
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACtF,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACtF,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,YAAY,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,YAAY,EAAE,IAAI,EAAE,QAAQ,EAAE,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC7F,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.resolveVariable = exports.isParseable = exports.evaluateCondition = exports.evaluateExpression = void 0;
|
|
3
|
+
exports.validateAnswer = exports.splitIntoPages = exports.resolveVariable = exports.isParseable = exports.evaluateCondition = exports.evaluateExpression = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* @cxpinsight/survey-spec
|
|
6
6
|
*
|
|
@@ -19,3 +19,7 @@ Object.defineProperty(exports, "evaluateCondition", { enumerable: true, get: fun
|
|
|
19
19
|
Object.defineProperty(exports, "isParseable", { enumerable: true, get: function () { return expressionEval_1.isParseable; } });
|
|
20
20
|
var resolveVariable_1 = require("./resolveVariable");
|
|
21
21
|
Object.defineProperty(exports, "resolveVariable", { enumerable: true, get: function () { return resolveVariable_1.resolveVariable; } });
|
|
22
|
+
var pageFlow_1 = require("./pageFlow");
|
|
23
|
+
Object.defineProperty(exports, "splitIntoPages", { enumerable: true, get: function () { return pageFlow_1.splitIntoPages; } });
|
|
24
|
+
var validate_1 = require("./validate");
|
|
25
|
+
Object.defineProperty(exports, "validateAnswer", { enumerable: true, get: function () { return validate_1.validateAnswer; } });
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { EvalContext } from './types';
|
|
2
|
+
export interface FlowQuestion {
|
|
3
|
+
id: string;
|
|
4
|
+
type?: string;
|
|
5
|
+
/** Multi-flow (spec §28). Undefined means the root flow. */
|
|
6
|
+
flow?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface FlowSection {
|
|
9
|
+
id: string;
|
|
10
|
+
questionIds?: string[];
|
|
11
|
+
hidden?: boolean;
|
|
12
|
+
showWhen?: string;
|
|
13
|
+
hideWhen?: string;
|
|
14
|
+
/** true → its own page. Absent/false → an inline heading in the current page. */
|
|
15
|
+
pageBreak?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export type PageItem<Q extends FlowQuestion = FlowQuestion, S extends FlowSection = FlowSection> = {
|
|
18
|
+
kind: 'question';
|
|
19
|
+
question: Q;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'section-heading';
|
|
22
|
+
section: S;
|
|
23
|
+
};
|
|
24
|
+
export interface Page<Q extends FlowQuestion = FlowQuestion, S extends FlowSection = FlowSection> {
|
|
25
|
+
section?: S;
|
|
26
|
+
items: PageItem<Q, S>[];
|
|
27
|
+
questions: Q[];
|
|
28
|
+
}
|
|
29
|
+
export interface PageFlowOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Multi-flow filter. `undefined` = the root flow (a root respondent never
|
|
32
|
+
* sees fork questions inline); a string = that named flow; `null` bypasses
|
|
33
|
+
* the filter entirely, which mobile uses for cross-flow lookups such as
|
|
34
|
+
* resolving a `rejoinAt` target.
|
|
35
|
+
*/
|
|
36
|
+
flow?: string | null;
|
|
37
|
+
presentationMode?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Typeform / conversation only. See the header: this is an open product
|
|
40
|
+
* decision, carried as a parameter so neither channel changes under the
|
|
41
|
+
* other's rule before it is made.
|
|
42
|
+
*/
|
|
43
|
+
typeformSections?: 'per-section' | 'per-question';
|
|
44
|
+
}
|
|
45
|
+
export declare function splitIntoPages<Q extends FlowQuestion = FlowQuestion, S extends FlowSection = FlowSection>(survey: {
|
|
46
|
+
questions?: Q[];
|
|
47
|
+
sections?: S[];
|
|
48
|
+
}, ctx: EvalContext, options?: PageFlowOptions): Page<Q, S>[];
|
|
49
|
+
//# sourceMappingURL=pageFlow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pageFlow.d.ts","sourceRoot":"","sources":["../src/pageFlow.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAK3C,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAMD,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,CAAC,SAAS,WAAW,GAAG,WAAW,IAC3F;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,CAAC,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,OAAO,EAAE,CAAC,CAAA;CAAE,CAAC;AAE5C,MAAM,WAAW,IAAI,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,CAAC,SAAS,WAAW,GAAG,WAAW;IAC9F,OAAO,CAAC,EAAE,CAAC,CAAC;IACZ,KAAK,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACxB,SAAS,EAAE,CAAC,EAAE,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,aAAa,GAAG,cAAc,CAAC;CACnD;AAoBD,wBAAgB,cAAc,CAC5B,CAAC,SAAS,YAAY,GAAG,YAAY,EACrC,CAAC,SAAS,WAAW,GAAG,WAAW,EAEnC,MAAM,EAAE;IAAE,SAAS,CAAC,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAA;CAAE,EAC3C,GAAG,EAAE,WAAW,EAChB,OAAO,GAAE,eAAoB,GAC5B,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAyEd"}
|
package/dist/pageFlow.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.splitIntoPages = splitIntoPages;
|
|
4
|
+
/**
|
|
5
|
+
* Page flow — how a flat `questions[]` plus a `sections[]` overlay becomes pages.
|
|
6
|
+
*
|
|
7
|
+
* This was written three times and the implementations disagreed on 6 of 17
|
|
8
|
+
* fixtures (`tools/survey-conformance/pageFlow.js`). The differences were not
|
|
9
|
+
* cosmetic: a survey with a loose question before a section was asked in a
|
|
10
|
+
* DIFFERENT ORDER on React than on mobile, and the answers were recorded as
|
|
11
|
+
* though it were the same instrument.
|
|
12
|
+
*
|
|
13
|
+
* ## What this module settles, and what it deliberately does not
|
|
14
|
+
*
|
|
15
|
+
* Two of the three divergences were bugs and are closed here:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Page order follows QUESTION order.** A section is emitted when the walk
|
|
18
|
+
* first reaches a question that belongs to it. Mobile previously walked
|
|
19
|
+
* `sections[]` and appended unclaimed questions at the end, so section order
|
|
20
|
+
* in the definition — which the builder does not treat as meaningful — could
|
|
21
|
+
* reorder the survey.
|
|
22
|
+
*
|
|
23
|
+
* 2. **Section conditions are evaluated.** `showWhen` / `hideWhen` on a section
|
|
24
|
+
* are honoured, not just `hidden`. Mobile ignored them, so a section
|
|
25
|
+
* conditioned away still split pages there.
|
|
26
|
+
*
|
|
27
|
+
* The third is a product decision, not a defect, so it is a PARAMETER rather
|
|
28
|
+
* than a silent choice: in typeform and conversation modes, does a section
|
|
29
|
+
* become one page, or does each of its questions get its own? React has always
|
|
30
|
+
* done per-section; mobile has always done per-question, and says so in its own
|
|
31
|
+
* comments. Encoding either one here would change page counts on live surveys
|
|
32
|
+
* for whichever channel lost, so `typeformSections` carries the difference in
|
|
33
|
+
* the open until someone decides.
|
|
34
|
+
*/
|
|
35
|
+
const expressionEval_1 = require("./expressionEval");
|
|
36
|
+
const isPageBreak = (q) => String(q.type || '').toLowerCase() === 'pagebreak';
|
|
37
|
+
/** Sections that survive `hidden`, `showWhen` and `hideWhen`, keyed for lookup. */
|
|
38
|
+
function activeSections(sections, ctx) {
|
|
39
|
+
const claimed = new Map();
|
|
40
|
+
const active = new Map();
|
|
41
|
+
for (const s of sections) {
|
|
42
|
+
if (s.hidden === true)
|
|
43
|
+
continue;
|
|
44
|
+
if (!(0, expressionEval_1.evaluateCondition)(s.showWhen, ctx))
|
|
45
|
+
continue;
|
|
46
|
+
if (s.hideWhen && (0, expressionEval_1.evaluateCondition)(s.hideWhen, ctx))
|
|
47
|
+
continue;
|
|
48
|
+
for (const id of s.questionIds || [])
|
|
49
|
+
claimed.set(id, s.id);
|
|
50
|
+
active.set(s.id, s);
|
|
51
|
+
}
|
|
52
|
+
// A question claimed by a SKIPPED section is deliberately left unclaimed, so
|
|
53
|
+
// it falls through into whichever page it lands in rather than disappearing.
|
|
54
|
+
return { claimed, active };
|
|
55
|
+
}
|
|
56
|
+
function splitIntoPages(survey, ctx, options = {}) {
|
|
57
|
+
const { flow, presentationMode, typeformSections = 'per-section' } = options;
|
|
58
|
+
const raw = survey.questions || [];
|
|
59
|
+
const all = flow === null
|
|
60
|
+
? raw
|
|
61
|
+
: raw.filter((q) => (q.flow || undefined) === (flow || undefined));
|
|
62
|
+
const sections = survey.sections || [];
|
|
63
|
+
const { claimed, active } = activeSections(sections, ctx);
|
|
64
|
+
if (presentationMode === 'typeform' || presentationMode === 'conversation') {
|
|
65
|
+
const pages = [];
|
|
66
|
+
const emitted = new Set();
|
|
67
|
+
for (const q of all) {
|
|
68
|
+
if (isPageBreak(q))
|
|
69
|
+
continue;
|
|
70
|
+
const sid = typeformSections === 'per-section' ? claimed.get(q.id) : undefined;
|
|
71
|
+
if (sid) {
|
|
72
|
+
if (emitted.has(sid))
|
|
73
|
+
continue;
|
|
74
|
+
const section = active.get(sid);
|
|
75
|
+
if (!section)
|
|
76
|
+
continue;
|
|
77
|
+
const qs = all.filter((qq) => claimed.get(qq.id) === sid && !isPageBreak(qq));
|
|
78
|
+
pages.push({ section, items: qs.map((qq) => ({ kind: 'question', question: qq })), questions: qs });
|
|
79
|
+
emitted.add(sid);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
pages.push({ items: [{ kind: 'question', question: q }], questions: [q] });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return pages;
|
|
86
|
+
}
|
|
87
|
+
const pages = [];
|
|
88
|
+
const emitted = new Set();
|
|
89
|
+
let current = [];
|
|
90
|
+
const flush = () => {
|
|
91
|
+
if (!current.length)
|
|
92
|
+
return;
|
|
93
|
+
pages.push({
|
|
94
|
+
items: current,
|
|
95
|
+
questions: current
|
|
96
|
+
.filter((it) => it.kind === 'question')
|
|
97
|
+
.map((it) => it.question),
|
|
98
|
+
});
|
|
99
|
+
current = [];
|
|
100
|
+
};
|
|
101
|
+
for (const q of all) {
|
|
102
|
+
// A pageBreak splits the current page and never renders itself. One inside
|
|
103
|
+
// a section is ignored — the section already controls its own boundary.
|
|
104
|
+
if (isPageBreak(q)) {
|
|
105
|
+
if (!claimed.has(q.id))
|
|
106
|
+
flush();
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const sid = claimed.get(q.id);
|
|
110
|
+
if (!sid) {
|
|
111
|
+
current.push({ kind: 'question', question: q });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (emitted.has(sid))
|
|
115
|
+
continue;
|
|
116
|
+
const section = active.get(sid);
|
|
117
|
+
if (!section)
|
|
118
|
+
continue;
|
|
119
|
+
const qs = all.filter((qq) => claimed.get(qq.id) === sid && !isPageBreak(qq));
|
|
120
|
+
if (section.pageBreak === true) {
|
|
121
|
+
flush();
|
|
122
|
+
pages.push({ section, items: qs.map((qq) => ({ kind: 'question', question: qq })), questions: qs });
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
current.push({ kind: 'section-heading', section });
|
|
126
|
+
for (const qq of qs)
|
|
127
|
+
current.push({ kind: 'question', question: qq });
|
|
128
|
+
}
|
|
129
|
+
emitted.add(sid);
|
|
130
|
+
}
|
|
131
|
+
flush();
|
|
132
|
+
return pages;
|
|
133
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answer validation — whether a respondent may submit.
|
|
3
|
+
*
|
|
4
|
+
* A divergence here means the same answer is accepted on one channel and
|
|
5
|
+
* rejected on another. The respondent hits a wall that does not exist for
|
|
6
|
+
* someone who arrived by a different route, and an analyst comparing the two
|
|
7
|
+
* sets of responses is comparing two instruments.
|
|
8
|
+
*
|
|
9
|
+
* There were three implementations and all three disagreed:
|
|
10
|
+
*
|
|
11
|
+
* • The WEB SDK had none. `survey.js` checked emptiness inline at the Next
|
|
12
|
+
* button and nothing else, so it accepted 8 of 32 answers the others
|
|
13
|
+
* rejected — including whitespace against a required question, `nope` as an
|
|
14
|
+
* email address, and any string against `minLength`.
|
|
15
|
+
*
|
|
16
|
+
* • REACT and MOBILE agreed on 26 fixtures and still differed, because every
|
|
17
|
+
* one of those fixtures used `type: 'text'` with an `inputType`. React's
|
|
18
|
+
* dispatcher has no case for `email`, `number`, `csat` or `ces` as question
|
|
19
|
+
* TYPES, so a question of `type: 'email'` was format-checked on the phone
|
|
20
|
+
* and waved through on a link.
|
|
21
|
+
*
|
|
22
|
+
* This module is the union, and each half was chosen for a reason:
|
|
23
|
+
*
|
|
24
|
+
* • `isEmpty` is React's, because it is type-aware. The web renderer's values
|
|
25
|
+
* are compound — `{ choice }`, `{ choices }`, `{ text, attachments }` — and
|
|
26
|
+
* mobile's primitives pass through the same code unharmed. Mobile's
|
|
27
|
+
* primitive-only version would have called `{ choice: undefined }` non-empty
|
|
28
|
+
* and let a blank required radio through.
|
|
29
|
+
*
|
|
30
|
+
* • The type dispatcher is mobile's, because it is the superset, and the
|
|
31
|
+
* effective format is derived from `inputType || type` so `type: 'email'`
|
|
32
|
+
* validates as an email whether or not the author also set an inputType.
|
|
33
|
+
*
|
|
34
|
+
* Order is spec §8 and it matters: required, then type-specific format, then
|
|
35
|
+
* the author's `rules[]`. An empty answer should read "this is required", not
|
|
36
|
+
* "that is not a valid email" — which sounds like the shape is wrong rather
|
|
37
|
+
* than the field being blank.
|
|
38
|
+
*/
|
|
39
|
+
export interface ValidatableQuestion {
|
|
40
|
+
type?: string;
|
|
41
|
+
inputType?: string;
|
|
42
|
+
required?: boolean;
|
|
43
|
+
isRequired?: boolean;
|
|
44
|
+
minLength?: number;
|
|
45
|
+
maxLength?: number;
|
|
46
|
+
minValue?: number;
|
|
47
|
+
maxValue?: number;
|
|
48
|
+
minCount?: number;
|
|
49
|
+
maxCount?: number;
|
|
50
|
+
rules?: unknown;
|
|
51
|
+
}
|
|
52
|
+
/** Null when the answer is valid; otherwise the message to show. */
|
|
53
|
+
export declare function validateAnswer(question: ValidatableQuestion, value: unknown): string | null;
|
|
54
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAYH,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,oEAAoE;AACpE,wBAAgB,cAAc,CAAC,QAAQ,EAAE,mBAAmB,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAgB3F"}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Answer validation — whether a respondent may submit.
|
|
4
|
+
*
|
|
5
|
+
* A divergence here means the same answer is accepted on one channel and
|
|
6
|
+
* rejected on another. The respondent hits a wall that does not exist for
|
|
7
|
+
* someone who arrived by a different route, and an analyst comparing the two
|
|
8
|
+
* sets of responses is comparing two instruments.
|
|
9
|
+
*
|
|
10
|
+
* There were three implementations and all three disagreed:
|
|
11
|
+
*
|
|
12
|
+
* • The WEB SDK had none. `survey.js` checked emptiness inline at the Next
|
|
13
|
+
* button and nothing else, so it accepted 8 of 32 answers the others
|
|
14
|
+
* rejected — including whitespace against a required question, `nope` as an
|
|
15
|
+
* email address, and any string against `minLength`.
|
|
16
|
+
*
|
|
17
|
+
* • REACT and MOBILE agreed on 26 fixtures and still differed, because every
|
|
18
|
+
* one of those fixtures used `type: 'text'` with an `inputType`. React's
|
|
19
|
+
* dispatcher has no case for `email`, `number`, `csat` or `ces` as question
|
|
20
|
+
* TYPES, so a question of `type: 'email'` was format-checked on the phone
|
|
21
|
+
* and waved through on a link.
|
|
22
|
+
*
|
|
23
|
+
* This module is the union, and each half was chosen for a reason:
|
|
24
|
+
*
|
|
25
|
+
* • `isEmpty` is React's, because it is type-aware. The web renderer's values
|
|
26
|
+
* are compound — `{ choice }`, `{ choices }`, `{ text, attachments }` — and
|
|
27
|
+
* mobile's primitives pass through the same code unharmed. Mobile's
|
|
28
|
+
* primitive-only version would have called `{ choice: undefined }` non-empty
|
|
29
|
+
* and let a blank required radio through.
|
|
30
|
+
*
|
|
31
|
+
* • The type dispatcher is mobile's, because it is the superset, and the
|
|
32
|
+
* effective format is derived from `inputType || type` so `type: 'email'`
|
|
33
|
+
* validates as an email whether or not the author also set an inputType.
|
|
34
|
+
*
|
|
35
|
+
* Order is spec §8 and it matters: required, then type-specific format, then
|
|
36
|
+
* the author's `rules[]`. An empty answer should read "this is required", not
|
|
37
|
+
* "that is not a valid email" — which sounds like the shape is wrong rather
|
|
38
|
+
* than the field being blank.
|
|
39
|
+
*/
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.validateAnswer = validateAnswer;
|
|
42
|
+
/**
|
|
43
|
+
* Practical email format — matches what the HTML5 email validator accepts.
|
|
44
|
+
* Not RFC 5322-strict: that regex is a page long and rejects addresses real
|
|
45
|
+
* mail servers deliver to every day.
|
|
46
|
+
*/
|
|
47
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
48
|
+
/** URL with a scheme. No guessing at whether `http://` was meant. */
|
|
49
|
+
const URL_RE = /^https?:\/\/[\w.\-]+(?:\.[\w.\-]+)+(?:[/?#][^\s]*)?$/i;
|
|
50
|
+
/** Null when the answer is valid; otherwise the message to show. */
|
|
51
|
+
function validateAnswer(question, value) {
|
|
52
|
+
// BOTH spellings. The v1 vocabulary and the builder both emit `isRequired`,
|
|
53
|
+
// and a renderer reading only `required` let a required question be submitted
|
|
54
|
+
// blank while the other channels rejected it.
|
|
55
|
+
const empty = isEmpty(question, value);
|
|
56
|
+
if ((question.required || question.isRequired) && empty) {
|
|
57
|
+
return 'This field is required';
|
|
58
|
+
}
|
|
59
|
+
// An optional field left blank is valid. Complaining about the shape of an
|
|
60
|
+
// answer nobody gave is noise.
|
|
61
|
+
if (empty)
|
|
62
|
+
return null;
|
|
63
|
+
const typeErr = validateByType(question, value);
|
|
64
|
+
if (typeErr)
|
|
65
|
+
return typeErr;
|
|
66
|
+
return validateRules(question, value);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Emptiness, per value shape.
|
|
70
|
+
*
|
|
71
|
+
* `0` and `false` are ANSWERS. A rating of zero and a "no" on a yes/no question
|
|
72
|
+
* are both real responses, and treating them as blank rejects a respondent who
|
|
73
|
+
* answered.
|
|
74
|
+
*/
|
|
75
|
+
function isEmpty(question, value) {
|
|
76
|
+
if (value === undefined || value === null)
|
|
77
|
+
return true;
|
|
78
|
+
if (typeof value === 'string')
|
|
79
|
+
return value.trim() === '';
|
|
80
|
+
if (typeof value === 'number')
|
|
81
|
+
return Number.isNaN(value);
|
|
82
|
+
if (typeof value === 'boolean')
|
|
83
|
+
return false;
|
|
84
|
+
if (Array.isArray(value))
|
|
85
|
+
return value.length === 0;
|
|
86
|
+
if (typeof value === 'object') {
|
|
87
|
+
const v = value;
|
|
88
|
+
if (question.type === 'radio')
|
|
89
|
+
return !v.choice;
|
|
90
|
+
if (question.type === 'checkbox') {
|
|
91
|
+
return !Array.isArray(v.choices) || v.choices.length === 0;
|
|
92
|
+
}
|
|
93
|
+
if (question.type === 'multiText') {
|
|
94
|
+
return Object.values(v).every((x) => x === undefined || x === null || (typeof x === 'string' && x.trim() === ''));
|
|
95
|
+
}
|
|
96
|
+
if (question.type === 'text' || question.type === 'comment') {
|
|
97
|
+
// allowAttachments makes this { text, attachments }. Empty means both:
|
|
98
|
+
// a respondent who attached a file and typed nothing has answered.
|
|
99
|
+
const textEmpty = typeof v.text !== 'string' || v.text.trim() === '';
|
|
100
|
+
const noFiles = !Array.isArray(v.attachments) || v.attachments.length === 0;
|
|
101
|
+
return textEmpty && noFiles;
|
|
102
|
+
}
|
|
103
|
+
// matrixDropdown / matrixDynamic / panelDynamic / signature — any content
|
|
104
|
+
// counts as answered.
|
|
105
|
+
return Object.keys(v).length === 0;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
function validateByType(question, value) {
|
|
110
|
+
switch (question.type) {
|
|
111
|
+
case 'text':
|
|
112
|
+
case 'comment':
|
|
113
|
+
case 'email':
|
|
114
|
+
case 'number':
|
|
115
|
+
return validateTextish(question, value);
|
|
116
|
+
case 'checkbox':
|
|
117
|
+
return validateCheckbox(question, value);
|
|
118
|
+
case 'rating':
|
|
119
|
+
case 'csat':
|
|
120
|
+
case 'ces':
|
|
121
|
+
case 'nps':
|
|
122
|
+
return validateNumericRange(question, value);
|
|
123
|
+
default:
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function validateTextish(question, value) {
|
|
128
|
+
// Unwrap the compound { text, attachments } shape the web renderer uses.
|
|
129
|
+
let raw;
|
|
130
|
+
if (typeof value === 'string') {
|
|
131
|
+
raw = value;
|
|
132
|
+
}
|
|
133
|
+
else if (value && typeof value === 'object' && 'text' in value) {
|
|
134
|
+
const t = value.text;
|
|
135
|
+
raw = typeof t === 'string' ? t : '';
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
raw = String(value ?? '');
|
|
139
|
+
}
|
|
140
|
+
const trimmed = raw.trim();
|
|
141
|
+
// An author who picks the "Email" question type has declared the format just
|
|
142
|
+
// as much as one who sets inputType on a text question.
|
|
143
|
+
const inputType = question.inputType
|
|
144
|
+
|| (question.type === 'email' ? 'email' : question.type === 'number' ? 'number' : undefined);
|
|
145
|
+
if (inputType === 'email' && !EMAIL_RE.test(trimmed)) {
|
|
146
|
+
return 'Please enter a valid email address (e.g., you@example.com)';
|
|
147
|
+
}
|
|
148
|
+
if (inputType === 'url' && !URL_RE.test(trimmed)) {
|
|
149
|
+
return 'Please enter a full URL, including https://';
|
|
150
|
+
}
|
|
151
|
+
if (inputType === 'number' && Number.isNaN(Number(trimmed))) {
|
|
152
|
+
return 'Please enter a number';
|
|
153
|
+
}
|
|
154
|
+
if (inputType === 'tel' && !/^[\d\s+\-()]{3,}$/.test(trimmed)) {
|
|
155
|
+
// Deliberately permissive. Country-specific phone validation inline is a
|
|
156
|
+
// losing game that mostly rejects real numbers.
|
|
157
|
+
return 'Please enter a valid phone number';
|
|
158
|
+
}
|
|
159
|
+
if (typeof question.maxLength === 'number' && trimmed.length > question.maxLength) {
|
|
160
|
+
return `Please keep this under ${question.maxLength} characters`;
|
|
161
|
+
}
|
|
162
|
+
if (typeof question.minLength === 'number' && trimmed.length < question.minLength) {
|
|
163
|
+
return `Please enter at least ${question.minLength} characters`;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
function validateCheckbox(question, value) {
|
|
168
|
+
// Web sends { choices }, mobile sends an array. Both are the same answer.
|
|
169
|
+
const choices = Array.isArray(value)
|
|
170
|
+
? value
|
|
171
|
+
: (value && typeof value === 'object' && Array.isArray(value.choices)
|
|
172
|
+
? value.choices
|
|
173
|
+
: []);
|
|
174
|
+
const count = choices.length;
|
|
175
|
+
if (typeof question.minCount === 'number' && count < question.minCount) {
|
|
176
|
+
return `Please select at least ${question.minCount}`;
|
|
177
|
+
}
|
|
178
|
+
if (typeof question.maxCount === 'number' && count > question.maxCount) {
|
|
179
|
+
return `Please select at most ${question.maxCount}`;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
function validateNumericRange(question, value) {
|
|
184
|
+
if (typeof value !== 'number')
|
|
185
|
+
return null;
|
|
186
|
+
if (typeof question.minValue === 'number' && value < question.minValue) {
|
|
187
|
+
return `Must be at least ${question.minValue}`;
|
|
188
|
+
}
|
|
189
|
+
if (typeof question.maxValue === 'number' && value > question.maxValue) {
|
|
190
|
+
return `Must be at most ${question.maxValue}`;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
function validateRules(question, value) {
|
|
195
|
+
const rules = Array.isArray(question.rules) ? question.rules : [];
|
|
196
|
+
if (rules.length === 0)
|
|
197
|
+
return null;
|
|
198
|
+
const str = typeof value === 'string' ? value : String(value ?? '');
|
|
199
|
+
for (const r of rules) {
|
|
200
|
+
const err = applyRule(r, str);
|
|
201
|
+
// The author's own message wins when they wrote one.
|
|
202
|
+
if (err)
|
|
203
|
+
return r.message || err;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
function applyRule(rule, str) {
|
|
208
|
+
switch (rule.type) {
|
|
209
|
+
case 'minLength':
|
|
210
|
+
return typeof rule.value === 'number' && str.length < rule.value
|
|
211
|
+
? `At least ${rule.value} characters` : null;
|
|
212
|
+
case 'maxLength':
|
|
213
|
+
return typeof rule.value === 'number' && str.length > rule.value
|
|
214
|
+
? `At most ${rule.value} characters` : null;
|
|
215
|
+
case 'min':
|
|
216
|
+
return typeof rule.value === 'number' && Number(str) < rule.value
|
|
217
|
+
? `Must be at least ${rule.value}` : null;
|
|
218
|
+
case 'max':
|
|
219
|
+
return typeof rule.value === 'number' && Number(str) > rule.value
|
|
220
|
+
? `Must be at most ${rule.value}` : null;
|
|
221
|
+
case 'email':
|
|
222
|
+
return EMAIL_RE.test(str) ? null : 'Please enter a valid email address';
|
|
223
|
+
case 'url':
|
|
224
|
+
return URL_RE.test(str) ? null : 'Please enter a valid URL';
|
|
225
|
+
case 'pattern': {
|
|
226
|
+
if (typeof rule.value !== 'string')
|
|
227
|
+
return null;
|
|
228
|
+
try {
|
|
229
|
+
// An unparseable pattern is an authoring mistake. Failing OPEN is
|
|
230
|
+
// deliberate: blocking every respondent on a survey because of a typo
|
|
231
|
+
// in a regex is worse than letting the answer through.
|
|
232
|
+
return new RegExp(rule.value).test(str) ? null : "Format doesn't match";
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
default:
|
|
239
|
+
// Unknown rule types skip rather than block — the spec can add one
|
|
240
|
+
// before every renderer knows about it.
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cxpinsight/survey-spec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The survey specification the renderers share — expression evaluation first. Source of truth for behaviour that must be identical on web, links and mobile.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
8
|
"dist/",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
14
14
|
"typecheck": "tsc --noEmit",
|
|
15
15
|
"test": "node --test test/*.test.js",
|
|
16
|
-
"prepublishOnly": "npm run typecheck && npm run build"
|
|
16
|
+
"prepublishOnly": "node scripts/check-toolchain.js && npm run typecheck && npm run build"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"typescript": ">=5.0.0"
|
package/src/expressionEval.d.ts
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export type { AnswersMap, VariablesMap, EvalContext } from './types';
|
|
2
|
-
import type { EvalContext } from './types';
|
|
3
|
-
export declare function evaluateExpression(source: string, ctx: EvalContext): unknown;
|
|
4
|
-
export declare function evaluateCondition(source: string | undefined, ctx: EvalContext): boolean;
|
|
5
|
-
/** Whether `source` parses at all, independent of what it evaluates to. */
|
|
6
|
-
export declare function isParseable(source: string | undefined): boolean;
|
|
7
|
-
//# sourceMappingURL=expressionEval.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"expressionEval.d.ts","sourceRoot":"","sources":["expressionEval.ts"],"names":[],"mappings":"AAwBA,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AACrE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAI3C,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAY5E;AAID,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAcvF;AAED,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAS/D"}
|