@questify/core 1.0.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/README.md +174 -0
- package/dist/index.d.mts +132 -0
- package/dist/index.d.ts +132 -0
- package/dist/index.js +314 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +286 -0
- package/dist/index.mjs.map +1 -0
- package/dist/react/index.d.mts +116 -0
- package/dist/react/index.d.ts +116 -0
- package/dist/react/index.js +350 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/index.mjs +325 -0
- package/dist/react/index.mjs.map +1 -0
- package/dist/vue/index.d.mts +164 -0
- package/dist/vue/index.d.ts +164 -0
- package/dist/vue/index.js +347 -0
- package/dist/vue/index.js.map +1 -0
- package/dist/vue/index.mjs +322 -0
- package/dist/vue/index.mjs.map +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/react/index.ts","../../src/core/index.ts"],"sourcesContent":["import { useState, useEffect, useCallback, useRef } from \"react\";\nimport { Questionnaire } from \"../core\";\nimport type { QuestionnaireConfig, QuestionnaireState } from \"../core/types\";\n\nexport type UseQuestionnaireReturn = QuestionnaireState & {\n /**\n * Answer the current step question (step mode).\n * In `mode: \"all\"` use `answerById` instead.\n */\n answer: (value: unknown) => void;\n /**\n * Answer any visible question by its id — use this in `mode: \"all\"`.\n * Safe to call in step mode too.\n */\n answerById: (questionId: string, value: unknown) => void;\n /** Advance to the next step. Validates the current required question first. */\n next: () => void;\n /** Go back one step. No-op on step 0. */\n back: () => void;\n /** Jump directly to a step by index (0-based within visible questions). */\n jumpTo: (index: number) => void;\n /**\n * Validate all visible required questions.\n * Useful for a \"Submit\" gate in `mode: \"all\"`.\n * Returns a map of questionId → error message for any invalid fields.\n */\n validate: () => Record<string, string>;\n /** Reset all responses and restart from step 0. */\n reset: () => void;\n /**\n * Returns only responses for currently visible questions.\n * Use for form submission — avoids submitting hidden-branch data.\n *\n * NOTE: if you pass `config` with different `questions` after the initial\n * render, the hook will NOT reinitialize. Use a `key` prop on the parent\n * component to force remount when the question set changes.\n */\n getSubmittableResponses: () => Record<string, unknown>;\n};\n\nexport function useQuestionnaire(config: QuestionnaireConfig): UseQuestionnaireReturn {\n // Create the Questionnaire instance exactly once per hook lifecycle.\n // The ref persists across React StrictMode double-invocations safely.\n const qRef = useRef<Questionnaire | null>(null);\n if (!qRef.current) {\n qRef.current = new Questionnaire(config);\n }\n\n // Initialise state synchronously — no render without state.\n const [state, setState] = useState<QuestionnaireState>(() =>\n qRef.current!.getState()\n );\n\n useEffect(() => {\n // Subscribe and immediately sync any state that changed between render and effect.\n // In React 18 Strict Mode this effect runs twice (mount→cleanup→mount);\n // both cycles are safe because subscribe() emits the current state immediately.\n const unsubscribe = qRef.current!.subscribe(setState);\n return unsubscribe;\n }, []);\n\n // Stable action callbacks — safe to pass as props without triggering child rerenders.\n const answer = useCallback((value: unknown) => qRef.current!.answer(value), []);\n const answerById = useCallback((id: string, value: unknown) => qRef.current!.answerById(id, value), []);\n const next = useCallback(() => qRef.current!.next(), []);\n const back = useCallback(() => qRef.current!.back(), []);\n const jumpTo = useCallback((index: number) => qRef.current!.jumpTo(index), []);\n const validate = useCallback(() => qRef.current!.validate(), []);\n const reset = useCallback(() => qRef.current!.reset(), []);\n const getSubmittableResponses = useCallback(\n () => qRef.current!.getSubmittableResponses(),\n []\n );\n\n return {\n ...state,\n answer,\n answerById,\n next,\n back,\n jumpTo,\n validate,\n reset,\n getSubmittableResponses,\n };\n}\n","import type {\n Question,\n QuestionnaireConfig,\n QuestionnaireState,\n ShowIfCondition,\n ShowIfRule,\n} from \"./types\";\n\nexport * from \"./types\";\n\n// ─── Conditional visibility ───────────────────────────────────────────────────\n\nfunction evaluateSimple(\n condition: ShowIfCondition,\n responses: Record<string, unknown>\n): boolean {\n const response = responses[condition.questionId];\n if (response === undefined || response === null) return false;\n\n const op = condition.operator ?? \"eq\";\n const val = condition.value;\n\n switch (op) {\n case \"eq\": return response === val;\n case \"neq\": return response !== val;\n case \"gt\":\n return typeof response === \"number\" && typeof val === \"number\" && response > val;\n case \"lt\":\n return typeof response === \"number\" && typeof val === \"number\" && response < val;\n case \"gte\":\n return typeof response === \"number\" && typeof val === \"number\" && response >= val;\n case \"lte\":\n return typeof response === \"number\" && typeof val === \"number\" && response <= val;\n case \"includes\":\n return Array.isArray(response)\n ? response.includes(val)\n : String(response).includes(String(val));\n default: return response === val;\n }\n}\n\n// Maximum nesting depth for showIf condition trees.\n// Guards against stack overflows from accidentally deep or circular configs.\nconst MAX_CONDITION_DEPTH = 20;\n\nfunction evaluateRule(\n rule: ShowIfRule,\n responses: Record<string, unknown>,\n depth = 0\n): boolean {\n if (depth > MAX_CONDITION_DEPTH) {\n // Tree too deep — default to visible so questions don't silently disappear.\n console.warn(\"[questify] showIf condition tree exceeds maximum depth. Defaulting to visible.\");\n return true;\n }\n if (\"and\" in rule && rule.and) {\n if (rule.and.length === 0) return true;\n return rule.and.every((r) => evaluateRule(r, responses, depth + 1));\n }\n if (\"or\" in rule && rule.or) {\n if (rule.or.length === 0) return false;\n return rule.or.some((r) => evaluateRule(r, responses, depth + 1));\n }\n return evaluateSimple(rule as ShowIfCondition, responses);\n}\n\nfunction getVisibleQuestions(\n questions: Question[],\n responses: Record<string, unknown>\n): Question[] {\n return questions.filter((q) => (q.showIf ? evaluateRule(q.showIf, responses) : true));\n}\n\n// ─── Validation ───────────────────────────────────────────────────────────────\n\nfunction isEmpty(value: unknown): boolean {\n if (value === undefined || value === null) return true;\n if (typeof value === \"string\" && value.trim() === \"\") return true;\n if (Array.isArray(value) && value.length === 0) return true;\n return false;\n}\n\nexport function validateAnswer(question: Question, value: unknown): string | null {\n // Required check — uses isEmpty which trims whitespace\n if (question.required && isEmpty(value)) {\n return question.validation?.message ?? \"This field is required.\";\n }\n\n // No further validation on empty optional fields\n if (isEmpty(value)) return null;\n\n const v = question.validation;\n\n // Number / rating\n if (question.type === \"number\" || question.type === \"rating\") {\n const num = Number(value);\n if (isNaN(num) || !isFinite(num)) return \"Must be a valid number.\";\n if (v?.min !== undefined && num < v.min) return v.message ?? `Minimum value is ${v.min}.`;\n if (v?.max !== undefined && num > v.max) return v.message ?? `Maximum value is ${v.max}.`;\n }\n\n // Text (length + regex)\n if (question.type === \"text\") {\n const str = String(value).trim();\n if (v?.minLength !== undefined && str.length < v.minLength)\n return v.message ?? `Minimum ${v.minLength} characters required.`;\n if (v?.maxLength !== undefined && str.length > v.maxLength)\n return v.message ?? `Maximum ${v.maxLength} characters allowed.`;\n if (v?.regex) {\n try {\n if (!new RegExp(v.regex).test(str)) return v.message ?? \"Invalid format.\";\n } catch {\n // Malformed regex in config — skip silently in production\n }\n }\n }\n\n // Email — format check always runs, regardless of whether a validation object exists\n if (question.type === \"email\") {\n const str = String(value).trim();\n if (v?.minLength !== undefined && str.length < v.minLength)\n return v.message ?? `Minimum ${v.minLength} characters required.`;\n if (v?.maxLength !== undefined && str.length > v.maxLength)\n return v.message ?? `Maximum ${v.maxLength} characters allowed.`;\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(str))\n return \"Please enter a valid email address.\";\n }\n\n // Date — always validate YYYY-MM-DD format to avoid timezone surprises\n if (question.type === \"date\") {\n const str = String(value);\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(str)) return \"Please enter a valid date (YYYY-MM-DD).\";\n const d = new Date(str + \"T00:00:00\");\n if (isNaN(d.getTime())) return \"Please enter a valid date.\";\n }\n\n return null;\n}\n\n// ─── Progress + completion ────────────────────────────────────────────────────\n\nfunction computeProgress(\n visibleQuestions: Question[],\n responses: Record<string, unknown>\n): number {\n if (visibleQuestions.length === 0) return 0;\n const answered = visibleQuestions.filter((q) => !isEmpty(responses[q.id])).length;\n return answered / visibleQuestions.length;\n}\n\nfunction checkComplete(\n visibleQuestions: Question[],\n responses: Record<string, unknown>\n): boolean {\n return visibleQuestions\n .filter((q) => q.required)\n .every((q) => !isEmpty(responses[q.id]) && validateAnswer(q, responses[q.id]) === null);\n}\n\n// ─── State builder ────────────────────────────────────────────────────────────\n\nfunction buildState(\n config: QuestionnaireConfig,\n responses: Record<string, unknown>,\n questionIndex: number,\n errors: Record<string, string>\n): QuestionnaireState {\n const visibleQuestions = getVisibleQuestions(config.questions, responses);\n const safeIndex = Math.min(questionIndex, Math.max(0, visibleQuestions.length - 1));\n const question = visibleQuestions[safeIndex] ?? null;\n\n // [H-1] Filter stale errors for hidden questions\n const visibleIds = new Set(visibleQuestions.map((q) => q.id));\n const visibleErrors = Object.fromEntries(\n Object.entries(errors).filter(([id]) => visibleIds.has(id))\n );\n\n return {\n question,\n visibleQuestions,\n questionIndex: safeIndex,\n totalQuestions: visibleQuestions.length,\n progress: computeProgress(visibleQuestions, responses),\n // [H-2] Defensive copy — prevents external mutation of internal state\n responses: { ...responses },\n isComplete: checkComplete(visibleQuestions, responses),\n errors: visibleErrors,\n canGoBack: safeIndex > 0,\n canGoNext: safeIndex < visibleQuestions.length - 1,\n };\n}\n\n// ─── Questionnaire class ──────────────────────────────────────────────────────\n\n// Keys that must never become property names on a plain object.\n// Guards against prototype pollution via developer-supplied question IDs.\nconst UNSAFE_KEYS = new Set([\"__proto__\", \"constructor\", \"prototype\"]);\n\ntype Subscriber = (state: QuestionnaireState) => void;\n\nexport class Questionnaire {\n private config: QuestionnaireConfig;\n private responses: Record<string, unknown>;\n private questionIndex: number;\n private errors: Record<string, string>;\n private subscribers: Subscriber[] = [];\n private cachedState: QuestionnaireState;\n\n constructor(config: QuestionnaireConfig) {\n // [M-1] Warn on duplicate IDs — always a config bug, regardless of environment\n const ids = config.questions.map((q) => q.id);\n const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);\n if (dupes.length > 0) {\n console.warn(\n `[questify] Duplicate question IDs detected: ${[...new Set(dupes)].join(\", \")}. ` +\n `Each question must have a unique id.`\n );\n }\n\n this.config = { mode: \"step\", ...config };\n this.responses = { ...(config.initialResponses ?? {}) };\n this.questionIndex = 0;\n this.errors = {};\n this.cachedState = buildState(this.config, this.responses, 0, {});\n }\n\n private recompute(): void {\n this.cachedState = buildState(\n this.config,\n this.responses,\n this.questionIndex,\n this.errors\n );\n }\n\n private notify(): void {\n // [M-5] Snapshot before iterating — safe against subscribe/unsubscribe during emit\n const snapshot = this.subscribers.slice();\n snapshot.forEach((s) => s(this.cachedState));\n }\n\n // ── Read ──────────────────────────────────────────────────────────────────\n\n getState(): QuestionnaireState {\n return this.cachedState;\n }\n\n /**\n * Returns only responses for currently visible questions.\n * Use this for form submission — avoids sending hidden-branch data to the server.\n *\n * `state.responses` retains all answers for back-navigation; this method prunes them.\n */\n getSubmittableResponses(): Record<string, unknown> {\n const { visibleQuestions } = this.cachedState;\n const visibleIds = new Set(visibleQuestions.map((q) => q.id));\n return Object.fromEntries(\n Object.entries(this.responses).filter(([id]) => visibleIds.has(id))\n );\n }\n\n subscribe(callback: Subscriber): () => void {\n this.subscribers.push(callback);\n callback(this.cachedState);\n return () => {\n this.subscribers = this.subscribers.filter((s) => s !== callback);\n };\n }\n\n // ── Write ─────────────────────────────────────────────────────────────────\n\n /**\n * Answer the current question (step mode).\n * In `mode: \"all\"` prefer `answerById()`.\n */\n answer(value: unknown): void {\n const { visibleQuestions, questionIndex } = this.cachedState;\n const q = visibleQuestions[questionIndex];\n if (!q) return;\n this._setAnswer(q, value);\n }\n\n /**\n * Answer any visible question by its id — the right API for `mode: \"all\"`.\n * Safe to call in step mode too (allows answering any step by id).\n */\n answerById(questionId: string, value: unknown): void {\n const q = this.cachedState.visibleQuestions.find((q) => q.id === questionId);\n if (!q) return;\n this._setAnswer(q, value);\n }\n\n private _setAnswer(question: Question, value: unknown): void {\n // Guard against prototype pollution — unsafe IDs are silently ignored.\n if (UNSAFE_KEYS.has(question.id)) return;\n this.responses = { ...this.responses, [question.id]: value };\n\n const error = validateAnswer(question, value);\n if (error) {\n this.errors = { ...this.errors, [question.id]: error };\n } else {\n const { [question.id]: _dropped, ...rest } = this.errors;\n this.errors = rest;\n }\n\n this.recompute();\n this.notify();\n }\n\n /**\n * Advance to the next step (step mode).\n * Validates the current required question first; populates `errors` if invalid.\n */\n next(): void {\n const { visibleQuestions, questionIndex } = this.cachedState;\n const q = visibleQuestions[questionIndex];\n\n if (q) {\n const error = validateAnswer(q, this.responses[q.id]);\n if (error) {\n this.errors = { ...this.errors, [q.id]: error };\n this.recompute();\n this.notify();\n return;\n }\n }\n\n if (questionIndex < visibleQuestions.length - 1) {\n this.questionIndex = questionIndex + 1;\n this.recompute();\n this.notify();\n }\n }\n\n /** Go to the previous step. No-op on step 0. */\n back(): void {\n if (this.questionIndex > 0) {\n this.questionIndex -= 1;\n this.recompute();\n this.notify();\n }\n }\n\n /**\n * Jump to a specific step index (0-based, within visible questions).\n * Out-of-range indices are silently ignored.\n */\n jumpTo(index: number): void {\n const { visibleQuestions } = this.cachedState;\n if (index >= 0 && index < visibleQuestions.length) {\n this.questionIndex = index;\n this.recompute();\n this.notify();\n }\n }\n\n /**\n * Validate all visible required questions and return a map of errors.\n * Useful for \"submit\" gates in `mode: \"all\"`.\n */\n validate(): Record<string, string> {\n const newErrors: Record<string, string> = {};\n for (const q of this.cachedState.visibleQuestions) {\n const error = validateAnswer(q, this.responses[q.id]);\n if (error) newErrors[q.id] = error;\n }\n if (Object.keys(newErrors).length > 0) {\n this.errors = { ...this.errors, ...newErrors };\n this.recompute();\n this.notify();\n }\n return newErrors;\n }\n\n /** Reset all responses and errors, restart from step 0. */\n reset(): void {\n this.responses = { ...(this.config.initialResponses ?? {}) };\n this.questionIndex = 0;\n this.errors = {};\n this.recompute();\n this.notify();\n }\n}\n"],"mappings":"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","names":["q"]}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// src/react/index.ts
|
|
2
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
3
|
+
|
|
4
|
+
// src/core/index.ts
|
|
5
|
+
function evaluateSimple(condition, responses) {
|
|
6
|
+
var _a;
|
|
7
|
+
const response = responses[condition.questionId];
|
|
8
|
+
if (response === void 0 || response === null) return false;
|
|
9
|
+
const op = (_a = condition.operator) != null ? _a : "eq";
|
|
10
|
+
const val = condition.value;
|
|
11
|
+
switch (op) {
|
|
12
|
+
case "eq":
|
|
13
|
+
return response === val;
|
|
14
|
+
case "neq":
|
|
15
|
+
return response !== val;
|
|
16
|
+
case "gt":
|
|
17
|
+
return typeof response === "number" && typeof val === "number" && response > val;
|
|
18
|
+
case "lt":
|
|
19
|
+
return typeof response === "number" && typeof val === "number" && response < val;
|
|
20
|
+
case "gte":
|
|
21
|
+
return typeof response === "number" && typeof val === "number" && response >= val;
|
|
22
|
+
case "lte":
|
|
23
|
+
return typeof response === "number" && typeof val === "number" && response <= val;
|
|
24
|
+
case "includes":
|
|
25
|
+
return Array.isArray(response) ? response.includes(val) : String(response).includes(String(val));
|
|
26
|
+
default:
|
|
27
|
+
return response === val;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
var MAX_CONDITION_DEPTH = 20;
|
|
31
|
+
function evaluateRule(rule, responses, depth = 0) {
|
|
32
|
+
if (depth > MAX_CONDITION_DEPTH) {
|
|
33
|
+
console.warn("[questify] showIf condition tree exceeds maximum depth. Defaulting to visible.");
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if ("and" in rule && rule.and) {
|
|
37
|
+
if (rule.and.length === 0) return true;
|
|
38
|
+
return rule.and.every((r) => evaluateRule(r, responses, depth + 1));
|
|
39
|
+
}
|
|
40
|
+
if ("or" in rule && rule.or) {
|
|
41
|
+
if (rule.or.length === 0) return false;
|
|
42
|
+
return rule.or.some((r) => evaluateRule(r, responses, depth + 1));
|
|
43
|
+
}
|
|
44
|
+
return evaluateSimple(rule, responses);
|
|
45
|
+
}
|
|
46
|
+
function getVisibleQuestions(questions, responses) {
|
|
47
|
+
return questions.filter((q) => q.showIf ? evaluateRule(q.showIf, responses) : true);
|
|
48
|
+
}
|
|
49
|
+
function isEmpty(value) {
|
|
50
|
+
if (value === void 0 || value === null) return true;
|
|
51
|
+
if (typeof value === "string" && value.trim() === "") return true;
|
|
52
|
+
if (Array.isArray(value) && value.length === 0) return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
function validateAnswer(question, value) {
|
|
56
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i;
|
|
57
|
+
if (question.required && isEmpty(value)) {
|
|
58
|
+
return (_b = (_a = question.validation) == null ? void 0 : _a.message) != null ? _b : "This field is required.";
|
|
59
|
+
}
|
|
60
|
+
if (isEmpty(value)) return null;
|
|
61
|
+
const v = question.validation;
|
|
62
|
+
if (question.type === "number" || question.type === "rating") {
|
|
63
|
+
const num = Number(value);
|
|
64
|
+
if (isNaN(num) || !isFinite(num)) return "Must be a valid number.";
|
|
65
|
+
if ((v == null ? void 0 : v.min) !== void 0 && num < v.min) return (_c = v.message) != null ? _c : `Minimum value is ${v.min}.`;
|
|
66
|
+
if ((v == null ? void 0 : v.max) !== void 0 && num > v.max) return (_d = v.message) != null ? _d : `Maximum value is ${v.max}.`;
|
|
67
|
+
}
|
|
68
|
+
if (question.type === "text") {
|
|
69
|
+
const str = String(value).trim();
|
|
70
|
+
if ((v == null ? void 0 : v.minLength) !== void 0 && str.length < v.minLength)
|
|
71
|
+
return (_e = v.message) != null ? _e : `Minimum ${v.minLength} characters required.`;
|
|
72
|
+
if ((v == null ? void 0 : v.maxLength) !== void 0 && str.length > v.maxLength)
|
|
73
|
+
return (_f = v.message) != null ? _f : `Maximum ${v.maxLength} characters allowed.`;
|
|
74
|
+
if (v == null ? void 0 : v.regex) {
|
|
75
|
+
try {
|
|
76
|
+
if (!new RegExp(v.regex).test(str)) return (_g = v.message) != null ? _g : "Invalid format.";
|
|
77
|
+
} catch {
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (question.type === "email") {
|
|
82
|
+
const str = String(value).trim();
|
|
83
|
+
if ((v == null ? void 0 : v.minLength) !== void 0 && str.length < v.minLength)
|
|
84
|
+
return (_h = v.message) != null ? _h : `Minimum ${v.minLength} characters required.`;
|
|
85
|
+
if ((v == null ? void 0 : v.maxLength) !== void 0 && str.length > v.maxLength)
|
|
86
|
+
return (_i = v.message) != null ? _i : `Maximum ${v.maxLength} characters allowed.`;
|
|
87
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str))
|
|
88
|
+
return "Please enter a valid email address.";
|
|
89
|
+
}
|
|
90
|
+
if (question.type === "date") {
|
|
91
|
+
const str = String(value);
|
|
92
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(str)) return "Please enter a valid date (YYYY-MM-DD).";
|
|
93
|
+
const d = /* @__PURE__ */ new Date(str + "T00:00:00");
|
|
94
|
+
if (isNaN(d.getTime())) return "Please enter a valid date.";
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function computeProgress(visibleQuestions, responses) {
|
|
99
|
+
if (visibleQuestions.length === 0) return 0;
|
|
100
|
+
const answered = visibleQuestions.filter((q) => !isEmpty(responses[q.id])).length;
|
|
101
|
+
return answered / visibleQuestions.length;
|
|
102
|
+
}
|
|
103
|
+
function checkComplete(visibleQuestions, responses) {
|
|
104
|
+
return visibleQuestions.filter((q) => q.required).every((q) => !isEmpty(responses[q.id]) && validateAnswer(q, responses[q.id]) === null);
|
|
105
|
+
}
|
|
106
|
+
function buildState(config, responses, questionIndex, errors) {
|
|
107
|
+
var _a;
|
|
108
|
+
const visibleQuestions = getVisibleQuestions(config.questions, responses);
|
|
109
|
+
const safeIndex = Math.min(questionIndex, Math.max(0, visibleQuestions.length - 1));
|
|
110
|
+
const question = (_a = visibleQuestions[safeIndex]) != null ? _a : null;
|
|
111
|
+
const visibleIds = new Set(visibleQuestions.map((q) => q.id));
|
|
112
|
+
const visibleErrors = Object.fromEntries(
|
|
113
|
+
Object.entries(errors).filter(([id]) => visibleIds.has(id))
|
|
114
|
+
);
|
|
115
|
+
return {
|
|
116
|
+
question,
|
|
117
|
+
visibleQuestions,
|
|
118
|
+
questionIndex: safeIndex,
|
|
119
|
+
totalQuestions: visibleQuestions.length,
|
|
120
|
+
progress: computeProgress(visibleQuestions, responses),
|
|
121
|
+
// [H-2] Defensive copy — prevents external mutation of internal state
|
|
122
|
+
responses: { ...responses },
|
|
123
|
+
isComplete: checkComplete(visibleQuestions, responses),
|
|
124
|
+
errors: visibleErrors,
|
|
125
|
+
canGoBack: safeIndex > 0,
|
|
126
|
+
canGoNext: safeIndex < visibleQuestions.length - 1
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
130
|
+
var Questionnaire = class {
|
|
131
|
+
constructor(config) {
|
|
132
|
+
this.subscribers = [];
|
|
133
|
+
var _a;
|
|
134
|
+
const ids = config.questions.map((q) => q.id);
|
|
135
|
+
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
|
136
|
+
if (dupes.length > 0) {
|
|
137
|
+
console.warn(
|
|
138
|
+
`[questify] Duplicate question IDs detected: ${[...new Set(dupes)].join(", ")}. Each question must have a unique id.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
this.config = { mode: "step", ...config };
|
|
142
|
+
this.responses = { ...(_a = config.initialResponses) != null ? _a : {} };
|
|
143
|
+
this.questionIndex = 0;
|
|
144
|
+
this.errors = {};
|
|
145
|
+
this.cachedState = buildState(this.config, this.responses, 0, {});
|
|
146
|
+
}
|
|
147
|
+
recompute() {
|
|
148
|
+
this.cachedState = buildState(
|
|
149
|
+
this.config,
|
|
150
|
+
this.responses,
|
|
151
|
+
this.questionIndex,
|
|
152
|
+
this.errors
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
notify() {
|
|
156
|
+
const snapshot = this.subscribers.slice();
|
|
157
|
+
snapshot.forEach((s) => s(this.cachedState));
|
|
158
|
+
}
|
|
159
|
+
// ── Read ──────────────────────────────────────────────────────────────────
|
|
160
|
+
getState() {
|
|
161
|
+
return this.cachedState;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Returns only responses for currently visible questions.
|
|
165
|
+
* Use this for form submission — avoids sending hidden-branch data to the server.
|
|
166
|
+
*
|
|
167
|
+
* `state.responses` retains all answers for back-navigation; this method prunes them.
|
|
168
|
+
*/
|
|
169
|
+
getSubmittableResponses() {
|
|
170
|
+
const { visibleQuestions } = this.cachedState;
|
|
171
|
+
const visibleIds = new Set(visibleQuestions.map((q) => q.id));
|
|
172
|
+
return Object.fromEntries(
|
|
173
|
+
Object.entries(this.responses).filter(([id]) => visibleIds.has(id))
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
subscribe(callback) {
|
|
177
|
+
this.subscribers.push(callback);
|
|
178
|
+
callback(this.cachedState);
|
|
179
|
+
return () => {
|
|
180
|
+
this.subscribers = this.subscribers.filter((s) => s !== callback);
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// ── Write ─────────────────────────────────────────────────────────────────
|
|
184
|
+
/**
|
|
185
|
+
* Answer the current question (step mode).
|
|
186
|
+
* In `mode: "all"` prefer `answerById()`.
|
|
187
|
+
*/
|
|
188
|
+
answer(value) {
|
|
189
|
+
const { visibleQuestions, questionIndex } = this.cachedState;
|
|
190
|
+
const q = visibleQuestions[questionIndex];
|
|
191
|
+
if (!q) return;
|
|
192
|
+
this._setAnswer(q, value);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Answer any visible question by its id — the right API for `mode: "all"`.
|
|
196
|
+
* Safe to call in step mode too (allows answering any step by id).
|
|
197
|
+
*/
|
|
198
|
+
answerById(questionId, value) {
|
|
199
|
+
const q = this.cachedState.visibleQuestions.find((q2) => q2.id === questionId);
|
|
200
|
+
if (!q) return;
|
|
201
|
+
this._setAnswer(q, value);
|
|
202
|
+
}
|
|
203
|
+
_setAnswer(question, value) {
|
|
204
|
+
if (UNSAFE_KEYS.has(question.id)) return;
|
|
205
|
+
this.responses = { ...this.responses, [question.id]: value };
|
|
206
|
+
const error = validateAnswer(question, value);
|
|
207
|
+
if (error) {
|
|
208
|
+
this.errors = { ...this.errors, [question.id]: error };
|
|
209
|
+
} else {
|
|
210
|
+
const { [question.id]: _dropped, ...rest } = this.errors;
|
|
211
|
+
this.errors = rest;
|
|
212
|
+
}
|
|
213
|
+
this.recompute();
|
|
214
|
+
this.notify();
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Advance to the next step (step mode).
|
|
218
|
+
* Validates the current required question first; populates `errors` if invalid.
|
|
219
|
+
*/
|
|
220
|
+
next() {
|
|
221
|
+
const { visibleQuestions, questionIndex } = this.cachedState;
|
|
222
|
+
const q = visibleQuestions[questionIndex];
|
|
223
|
+
if (q) {
|
|
224
|
+
const error = validateAnswer(q, this.responses[q.id]);
|
|
225
|
+
if (error) {
|
|
226
|
+
this.errors = { ...this.errors, [q.id]: error };
|
|
227
|
+
this.recompute();
|
|
228
|
+
this.notify();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (questionIndex < visibleQuestions.length - 1) {
|
|
233
|
+
this.questionIndex = questionIndex + 1;
|
|
234
|
+
this.recompute();
|
|
235
|
+
this.notify();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Go to the previous step. No-op on step 0. */
|
|
239
|
+
back() {
|
|
240
|
+
if (this.questionIndex > 0) {
|
|
241
|
+
this.questionIndex -= 1;
|
|
242
|
+
this.recompute();
|
|
243
|
+
this.notify();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Jump to a specific step index (0-based, within visible questions).
|
|
248
|
+
* Out-of-range indices are silently ignored.
|
|
249
|
+
*/
|
|
250
|
+
jumpTo(index) {
|
|
251
|
+
const { visibleQuestions } = this.cachedState;
|
|
252
|
+
if (index >= 0 && index < visibleQuestions.length) {
|
|
253
|
+
this.questionIndex = index;
|
|
254
|
+
this.recompute();
|
|
255
|
+
this.notify();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Validate all visible required questions and return a map of errors.
|
|
260
|
+
* Useful for "submit" gates in `mode: "all"`.
|
|
261
|
+
*/
|
|
262
|
+
validate() {
|
|
263
|
+
const newErrors = {};
|
|
264
|
+
for (const q of this.cachedState.visibleQuestions) {
|
|
265
|
+
const error = validateAnswer(q, this.responses[q.id]);
|
|
266
|
+
if (error) newErrors[q.id] = error;
|
|
267
|
+
}
|
|
268
|
+
if (Object.keys(newErrors).length > 0) {
|
|
269
|
+
this.errors = { ...this.errors, ...newErrors };
|
|
270
|
+
this.recompute();
|
|
271
|
+
this.notify();
|
|
272
|
+
}
|
|
273
|
+
return newErrors;
|
|
274
|
+
}
|
|
275
|
+
/** Reset all responses and errors, restart from step 0. */
|
|
276
|
+
reset() {
|
|
277
|
+
var _a;
|
|
278
|
+
this.responses = { ...(_a = this.config.initialResponses) != null ? _a : {} };
|
|
279
|
+
this.questionIndex = 0;
|
|
280
|
+
this.errors = {};
|
|
281
|
+
this.recompute();
|
|
282
|
+
this.notify();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// src/react/index.ts
|
|
287
|
+
function useQuestionnaire(config) {
|
|
288
|
+
const qRef = useRef(null);
|
|
289
|
+
if (!qRef.current) {
|
|
290
|
+
qRef.current = new Questionnaire(config);
|
|
291
|
+
}
|
|
292
|
+
const [state, setState] = useState(
|
|
293
|
+
() => qRef.current.getState()
|
|
294
|
+
);
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
const unsubscribe = qRef.current.subscribe(setState);
|
|
297
|
+
return unsubscribe;
|
|
298
|
+
}, []);
|
|
299
|
+
const answer = useCallback((value) => qRef.current.answer(value), []);
|
|
300
|
+
const answerById = useCallback((id, value) => qRef.current.answerById(id, value), []);
|
|
301
|
+
const next = useCallback(() => qRef.current.next(), []);
|
|
302
|
+
const back = useCallback(() => qRef.current.back(), []);
|
|
303
|
+
const jumpTo = useCallback((index) => qRef.current.jumpTo(index), []);
|
|
304
|
+
const validate = useCallback(() => qRef.current.validate(), []);
|
|
305
|
+
const reset = useCallback(() => qRef.current.reset(), []);
|
|
306
|
+
const getSubmittableResponses = useCallback(
|
|
307
|
+
() => qRef.current.getSubmittableResponses(),
|
|
308
|
+
[]
|
|
309
|
+
);
|
|
310
|
+
return {
|
|
311
|
+
...state,
|
|
312
|
+
answer,
|
|
313
|
+
answerById,
|
|
314
|
+
next,
|
|
315
|
+
back,
|
|
316
|
+
jumpTo,
|
|
317
|
+
validate,
|
|
318
|
+
reset,
|
|
319
|
+
getSubmittableResponses
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
export {
|
|
323
|
+
useQuestionnaire
|
|
324
|
+
};
|
|
325
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/react/index.ts","../../src/core/index.ts"],"sourcesContent":["import { useState, useEffect, useCallback, useRef } from \"react\";\nimport { Questionnaire } from \"../core\";\nimport type { QuestionnaireConfig, QuestionnaireState } from \"../core/types\";\n\nexport type UseQuestionnaireReturn = QuestionnaireState & {\n /**\n * Answer the current step question (step mode).\n * In `mode: \"all\"` use `answerById` instead.\n */\n answer: (value: unknown) => void;\n /**\n * Answer any visible question by its id — use this in `mode: \"all\"`.\n * Safe to call in step mode too.\n */\n answerById: (questionId: string, value: unknown) => void;\n /** Advance to the next step. Validates the current required question first. */\n next: () => void;\n /** Go back one step. No-op on step 0. */\n back: () => void;\n /** Jump directly to a step by index (0-based within visible questions). */\n jumpTo: (index: number) => void;\n /**\n * Validate all visible required questions.\n * Useful for a \"Submit\" gate in `mode: \"all\"`.\n * Returns a map of questionId → error message for any invalid fields.\n */\n validate: () => Record<string, string>;\n /** Reset all responses and restart from step 0. */\n reset: () => void;\n /**\n * Returns only responses for currently visible questions.\n * Use for form submission — avoids submitting hidden-branch data.\n *\n * NOTE: if you pass `config` with different `questions` after the initial\n * render, the hook will NOT reinitialize. Use a `key` prop on the parent\n * component to force remount when the question set changes.\n */\n getSubmittableResponses: () => Record<string, unknown>;\n};\n\nexport function useQuestionnaire(config: QuestionnaireConfig): UseQuestionnaireReturn {\n // Create the Questionnaire instance exactly once per hook lifecycle.\n // The ref persists across React StrictMode double-invocations safely.\n const qRef = useRef<Questionnaire | null>(null);\n if (!qRef.current) {\n qRef.current = new Questionnaire(config);\n }\n\n // Initialise state synchronously — no render without state.\n const [state, setState] = useState<QuestionnaireState>(() =>\n qRef.current!.getState()\n );\n\n useEffect(() => {\n // Subscribe and immediately sync any state that changed between render and effect.\n // In React 18 Strict Mode this effect runs twice (mount→cleanup→mount);\n // both cycles are safe because subscribe() emits the current state immediately.\n const unsubscribe = qRef.current!.subscribe(setState);\n return unsubscribe;\n }, []);\n\n // Stable action callbacks — safe to pass as props without triggering child rerenders.\n const answer = useCallback((value: unknown) => qRef.current!.answer(value), []);\n const answerById = useCallback((id: string, value: unknown) => qRef.current!.answerById(id, value), []);\n const next = useCallback(() => qRef.current!.next(), []);\n const back = useCallback(() => qRef.current!.back(), []);\n const jumpTo = useCallback((index: number) => qRef.current!.jumpTo(index), []);\n const validate = useCallback(() => qRef.current!.validate(), []);\n const reset = useCallback(() => qRef.current!.reset(), []);\n const getSubmittableResponses = useCallback(\n () => qRef.current!.getSubmittableResponses(),\n []\n );\n\n return {\n ...state,\n answer,\n answerById,\n next,\n back,\n jumpTo,\n validate,\n reset,\n getSubmittableResponses,\n };\n}\n","import type {\n Question,\n QuestionnaireConfig,\n QuestionnaireState,\n ShowIfCondition,\n ShowIfRule,\n} from \"./types\";\n\nexport * from \"./types\";\n\n// ─── Conditional visibility ───────────────────────────────────────────────────\n\nfunction evaluateSimple(\n condition: ShowIfCondition,\n responses: Record<string, unknown>\n): boolean {\n const response = responses[condition.questionId];\n if (response === undefined || response === null) return false;\n\n const op = condition.operator ?? \"eq\";\n const val = condition.value;\n\n switch (op) {\n case \"eq\": return response === val;\n case \"neq\": return response !== val;\n case \"gt\":\n return typeof response === \"number\" && typeof val === \"number\" && response > val;\n case \"lt\":\n return typeof response === \"number\" && typeof val === \"number\" && response < val;\n case \"gte\":\n return typeof response === \"number\" && typeof val === \"number\" && response >= val;\n case \"lte\":\n return typeof response === \"number\" && typeof val === \"number\" && response <= val;\n case \"includes\":\n return Array.isArray(response)\n ? response.includes(val)\n : String(response).includes(String(val));\n default: return response === val;\n }\n}\n\n// Maximum nesting depth for showIf condition trees.\n// Guards against stack overflows from accidentally deep or circular configs.\nconst MAX_CONDITION_DEPTH = 20;\n\nfunction evaluateRule(\n rule: ShowIfRule,\n responses: Record<string, unknown>,\n depth = 0\n): boolean {\n if (depth > MAX_CONDITION_DEPTH) {\n // Tree too deep — default to visible so questions don't silently disappear.\n console.warn(\"[questify] showIf condition tree exceeds maximum depth. Defaulting to visible.\");\n return true;\n }\n if (\"and\" in rule && rule.and) {\n if (rule.and.length === 0) return true;\n return rule.and.every((r) => evaluateRule(r, responses, depth + 1));\n }\n if (\"or\" in rule && rule.or) {\n if (rule.or.length === 0) return false;\n return rule.or.some((r) => evaluateRule(r, responses, depth + 1));\n }\n return evaluateSimple(rule as ShowIfCondition, responses);\n}\n\nfunction getVisibleQuestions(\n questions: Question[],\n responses: Record<string, unknown>\n): Question[] {\n return questions.filter((q) => (q.showIf ? evaluateRule(q.showIf, responses) : true));\n}\n\n// ─── Validation ───────────────────────────────────────────────────────────────\n\nfunction isEmpty(value: unknown): boolean {\n if (value === undefined || value === null) return true;\n if (typeof value === \"string\" && value.trim() === \"\") return true;\n if (Array.isArray(value) && value.length === 0) return true;\n return false;\n}\n\nexport function validateAnswer(question: Question, value: unknown): string | null {\n // Required check — uses isEmpty which trims whitespace\n if (question.required && isEmpty(value)) {\n return question.validation?.message ?? \"This field is required.\";\n }\n\n // No further validation on empty optional fields\n if (isEmpty(value)) return null;\n\n const v = question.validation;\n\n // Number / rating\n if (question.type === \"number\" || question.type === \"rating\") {\n const num = Number(value);\n if (isNaN(num) || !isFinite(num)) return \"Must be a valid number.\";\n if (v?.min !== undefined && num < v.min) return v.message ?? `Minimum value is ${v.min}.`;\n if (v?.max !== undefined && num > v.max) return v.message ?? `Maximum value is ${v.max}.`;\n }\n\n // Text (length + regex)\n if (question.type === \"text\") {\n const str = String(value).trim();\n if (v?.minLength !== undefined && str.length < v.minLength)\n return v.message ?? `Minimum ${v.minLength} characters required.`;\n if (v?.maxLength !== undefined && str.length > v.maxLength)\n return v.message ?? `Maximum ${v.maxLength} characters allowed.`;\n if (v?.regex) {\n try {\n if (!new RegExp(v.regex).test(str)) return v.message ?? \"Invalid format.\";\n } catch {\n // Malformed regex in config — skip silently in production\n }\n }\n }\n\n // Email — format check always runs, regardless of whether a validation object exists\n if (question.type === \"email\") {\n const str = String(value).trim();\n if (v?.minLength !== undefined && str.length < v.minLength)\n return v.message ?? `Minimum ${v.minLength} characters required.`;\n if (v?.maxLength !== undefined && str.length > v.maxLength)\n return v.message ?? `Maximum ${v.maxLength} characters allowed.`;\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(str))\n return \"Please enter a valid email address.\";\n }\n\n // Date — always validate YYYY-MM-DD format to avoid timezone surprises\n if (question.type === \"date\") {\n const str = String(value);\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(str)) return \"Please enter a valid date (YYYY-MM-DD).\";\n const d = new Date(str + \"T00:00:00\");\n if (isNaN(d.getTime())) return \"Please enter a valid date.\";\n }\n\n return null;\n}\n\n// ─── Progress + completion ────────────────────────────────────────────────────\n\nfunction computeProgress(\n visibleQuestions: Question[],\n responses: Record<string, unknown>\n): number {\n if (visibleQuestions.length === 0) return 0;\n const answered = visibleQuestions.filter((q) => !isEmpty(responses[q.id])).length;\n return answered / visibleQuestions.length;\n}\n\nfunction checkComplete(\n visibleQuestions: Question[],\n responses: Record<string, unknown>\n): boolean {\n return visibleQuestions\n .filter((q) => q.required)\n .every((q) => !isEmpty(responses[q.id]) && validateAnswer(q, responses[q.id]) === null);\n}\n\n// ─── State builder ────────────────────────────────────────────────────────────\n\nfunction buildState(\n config: QuestionnaireConfig,\n responses: Record<string, unknown>,\n questionIndex: number,\n errors: Record<string, string>\n): QuestionnaireState {\n const visibleQuestions = getVisibleQuestions(config.questions, responses);\n const safeIndex = Math.min(questionIndex, Math.max(0, visibleQuestions.length - 1));\n const question = visibleQuestions[safeIndex] ?? null;\n\n // [H-1] Filter stale errors for hidden questions\n const visibleIds = new Set(visibleQuestions.map((q) => q.id));\n const visibleErrors = Object.fromEntries(\n Object.entries(errors).filter(([id]) => visibleIds.has(id))\n );\n\n return {\n question,\n visibleQuestions,\n questionIndex: safeIndex,\n totalQuestions: visibleQuestions.length,\n progress: computeProgress(visibleQuestions, responses),\n // [H-2] Defensive copy — prevents external mutation of internal state\n responses: { ...responses },\n isComplete: checkComplete(visibleQuestions, responses),\n errors: visibleErrors,\n canGoBack: safeIndex > 0,\n canGoNext: safeIndex < visibleQuestions.length - 1,\n };\n}\n\n// ─── Questionnaire class ──────────────────────────────────────────────────────\n\n// Keys that must never become property names on a plain object.\n// Guards against prototype pollution via developer-supplied question IDs.\nconst UNSAFE_KEYS = new Set([\"__proto__\", \"constructor\", \"prototype\"]);\n\ntype Subscriber = (state: QuestionnaireState) => void;\n\nexport class Questionnaire {\n private config: QuestionnaireConfig;\n private responses: Record<string, unknown>;\n private questionIndex: number;\n private errors: Record<string, string>;\n private subscribers: Subscriber[] = [];\n private cachedState: QuestionnaireState;\n\n constructor(config: QuestionnaireConfig) {\n // [M-1] Warn on duplicate IDs — always a config bug, regardless of environment\n const ids = config.questions.map((q) => q.id);\n const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);\n if (dupes.length > 0) {\n console.warn(\n `[questify] Duplicate question IDs detected: ${[...new Set(dupes)].join(\", \")}. ` +\n `Each question must have a unique id.`\n );\n }\n\n this.config = { mode: \"step\", ...config };\n this.responses = { ...(config.initialResponses ?? {}) };\n this.questionIndex = 0;\n this.errors = {};\n this.cachedState = buildState(this.config, this.responses, 0, {});\n }\n\n private recompute(): void {\n this.cachedState = buildState(\n this.config,\n this.responses,\n this.questionIndex,\n this.errors\n );\n }\n\n private notify(): void {\n // [M-5] Snapshot before iterating — safe against subscribe/unsubscribe during emit\n const snapshot = this.subscribers.slice();\n snapshot.forEach((s) => s(this.cachedState));\n }\n\n // ── Read ──────────────────────────────────────────────────────────────────\n\n getState(): QuestionnaireState {\n return this.cachedState;\n }\n\n /**\n * Returns only responses for currently visible questions.\n * Use this for form submission — avoids sending hidden-branch data to the server.\n *\n * `state.responses` retains all answers for back-navigation; this method prunes them.\n */\n getSubmittableResponses(): Record<string, unknown> {\n const { visibleQuestions } = this.cachedState;\n const visibleIds = new Set(visibleQuestions.map((q) => q.id));\n return Object.fromEntries(\n Object.entries(this.responses).filter(([id]) => visibleIds.has(id))\n );\n }\n\n subscribe(callback: Subscriber): () => void {\n this.subscribers.push(callback);\n callback(this.cachedState);\n return () => {\n this.subscribers = this.subscribers.filter((s) => s !== callback);\n };\n }\n\n // ── Write ─────────────────────────────────────────────────────────────────\n\n /**\n * Answer the current question (step mode).\n * In `mode: \"all\"` prefer `answerById()`.\n */\n answer(value: unknown): void {\n const { visibleQuestions, questionIndex } = this.cachedState;\n const q = visibleQuestions[questionIndex];\n if (!q) return;\n this._setAnswer(q, value);\n }\n\n /**\n * Answer any visible question by its id — the right API for `mode: \"all\"`.\n * Safe to call in step mode too (allows answering any step by id).\n */\n answerById(questionId: string, value: unknown): void {\n const q = this.cachedState.visibleQuestions.find((q) => q.id === questionId);\n if (!q) return;\n this._setAnswer(q, value);\n }\n\n private _setAnswer(question: Question, value: unknown): void {\n // Guard against prototype pollution — unsafe IDs are silently ignored.\n if (UNSAFE_KEYS.has(question.id)) return;\n this.responses = { ...this.responses, [question.id]: value };\n\n const error = validateAnswer(question, value);\n if (error) {\n this.errors = { ...this.errors, [question.id]: error };\n } else {\n const { [question.id]: _dropped, ...rest } = this.errors;\n this.errors = rest;\n }\n\n this.recompute();\n this.notify();\n }\n\n /**\n * Advance to the next step (step mode).\n * Validates the current required question first; populates `errors` if invalid.\n */\n next(): void {\n const { visibleQuestions, questionIndex } = this.cachedState;\n const q = visibleQuestions[questionIndex];\n\n if (q) {\n const error = validateAnswer(q, this.responses[q.id]);\n if (error) {\n this.errors = { ...this.errors, [q.id]: error };\n this.recompute();\n this.notify();\n return;\n }\n }\n\n if (questionIndex < visibleQuestions.length - 1) {\n this.questionIndex = questionIndex + 1;\n this.recompute();\n this.notify();\n }\n }\n\n /** Go to the previous step. No-op on step 0. */\n back(): void {\n if (this.questionIndex > 0) {\n this.questionIndex -= 1;\n this.recompute();\n this.notify();\n }\n }\n\n /**\n * Jump to a specific step index (0-based, within visible questions).\n * Out-of-range indices are silently ignored.\n */\n jumpTo(index: number): void {\n const { visibleQuestions } = this.cachedState;\n if (index >= 0 && index < visibleQuestions.length) {\n this.questionIndex = index;\n this.recompute();\n this.notify();\n }\n }\n\n /**\n * Validate all visible required questions and return a map of errors.\n * Useful for \"submit\" gates in `mode: \"all\"`.\n */\n validate(): Record<string, string> {\n const newErrors: Record<string, string> = {};\n for (const q of this.cachedState.visibleQuestions) {\n const error = validateAnswer(q, this.responses[q.id]);\n if (error) newErrors[q.id] = error;\n }\n if (Object.keys(newErrors).length > 0) {\n this.errors = { ...this.errors, ...newErrors };\n this.recompute();\n this.notify();\n }\n return newErrors;\n }\n\n /** Reset all responses and errors, restart from step 0. */\n reset(): void {\n this.responses = { ...(this.config.initialResponses ?? {}) };\n this.questionIndex = 0;\n this.errors = {};\n this.recompute();\n this.notify();\n }\n}\n"],"mappings":"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","names":["q"]}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import * as vue from 'vue';
|
|
2
|
+
|
|
3
|
+
type QuestionType = "text" | "number" | "boolean" | "single" | "multi" | "date" | "rating" | "email";
|
|
4
|
+
type ConditionOperator = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "includes";
|
|
5
|
+
/** A single condition against one question's response. */
|
|
6
|
+
interface ShowIfCondition {
|
|
7
|
+
questionId: string;
|
|
8
|
+
value: unknown;
|
|
9
|
+
operator?: ConditionOperator;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Compound condition — nest `and`/`or` arrays for full branching logic.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // Show only when user has diabetes AND is on insulin
|
|
16
|
+
* showIf: {
|
|
17
|
+
* and: [
|
|
18
|
+
* { questionId: 'conditions', value: 'diabetes', operator: 'includes' },
|
|
19
|
+
* { questionId: 'on_insulin', value: true },
|
|
20
|
+
* ]
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
interface ShowIfCompound {
|
|
24
|
+
and?: ShowIfRule[];
|
|
25
|
+
or?: ShowIfRule[];
|
|
26
|
+
}
|
|
27
|
+
/** A single condition OR a compound AND/OR tree — fully nestable. */
|
|
28
|
+
type ShowIfRule = ShowIfCondition | ShowIfCompound;
|
|
29
|
+
interface QuestionValidation {
|
|
30
|
+
min?: number;
|
|
31
|
+
max?: number;
|
|
32
|
+
minLength?: number;
|
|
33
|
+
maxLength?: number;
|
|
34
|
+
regex?: string;
|
|
35
|
+
message?: string;
|
|
36
|
+
}
|
|
37
|
+
interface QuestionOption {
|
|
38
|
+
label: string;
|
|
39
|
+
value: string | number;
|
|
40
|
+
}
|
|
41
|
+
interface Question {
|
|
42
|
+
id: string;
|
|
43
|
+
text: string;
|
|
44
|
+
type: QuestionType;
|
|
45
|
+
description?: string;
|
|
46
|
+
required?: boolean;
|
|
47
|
+
placeholder?: string;
|
|
48
|
+
options?: QuestionOption[];
|
|
49
|
+
showIf?: ShowIfRule;
|
|
50
|
+
validation?: QuestionValidation;
|
|
51
|
+
}
|
|
52
|
+
type DisplayMode = "step" | "all";
|
|
53
|
+
interface QuestionnaireConfig {
|
|
54
|
+
questions: Question[];
|
|
55
|
+
mode?: DisplayMode;
|
|
56
|
+
initialResponses?: Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Vue 3 composable for questify.
|
|
61
|
+
*
|
|
62
|
+
* All state values are `computed` refs — fully reactive.
|
|
63
|
+
* The Questionnaire instance is created once per composable call and cleaned
|
|
64
|
+
* up via `onUnmounted`. Do not call this outside of `setup()`.
|
|
65
|
+
*
|
|
66
|
+
* If the parent component passes different config on re-render, the composable
|
|
67
|
+
* does NOT reinitialise. Use a `key` attribute on the component to force remount.
|
|
68
|
+
*/
|
|
69
|
+
declare function useQuestionnaire(config: QuestionnaireConfig): {
|
|
70
|
+
question: vue.ComputedRef<{
|
|
71
|
+
id: string;
|
|
72
|
+
text: string;
|
|
73
|
+
type: QuestionType;
|
|
74
|
+
description?: string | undefined;
|
|
75
|
+
required?: boolean | undefined;
|
|
76
|
+
placeholder?: string | undefined;
|
|
77
|
+
options?: {
|
|
78
|
+
label: string;
|
|
79
|
+
value: string | number;
|
|
80
|
+
}[] | undefined;
|
|
81
|
+
showIf?: {
|
|
82
|
+
questionId: string;
|
|
83
|
+
value: unknown;
|
|
84
|
+
operator?: ConditionOperator | undefined;
|
|
85
|
+
} | {
|
|
86
|
+
and?: ({
|
|
87
|
+
questionId: string;
|
|
88
|
+
value: unknown;
|
|
89
|
+
operator?: ConditionOperator | undefined;
|
|
90
|
+
} | /*elided*/ any)[] | undefined;
|
|
91
|
+
or?: ({
|
|
92
|
+
questionId: string;
|
|
93
|
+
value: unknown;
|
|
94
|
+
operator?: ConditionOperator | undefined;
|
|
95
|
+
} | /*elided*/ any)[] | undefined;
|
|
96
|
+
} | undefined;
|
|
97
|
+
validation?: {
|
|
98
|
+
min?: number | undefined;
|
|
99
|
+
max?: number | undefined;
|
|
100
|
+
minLength?: number | undefined;
|
|
101
|
+
maxLength?: number | undefined;
|
|
102
|
+
regex?: string | undefined;
|
|
103
|
+
message?: string | undefined;
|
|
104
|
+
} | undefined;
|
|
105
|
+
} | null>;
|
|
106
|
+
visibleQuestions: vue.ComputedRef<{
|
|
107
|
+
id: string;
|
|
108
|
+
text: string;
|
|
109
|
+
type: QuestionType;
|
|
110
|
+
description?: string | undefined;
|
|
111
|
+
required?: boolean | undefined;
|
|
112
|
+
placeholder?: string | undefined;
|
|
113
|
+
options?: {
|
|
114
|
+
label: string;
|
|
115
|
+
value: string | number;
|
|
116
|
+
}[] | undefined;
|
|
117
|
+
showIf?: {
|
|
118
|
+
questionId: string;
|
|
119
|
+
value: unknown;
|
|
120
|
+
operator?: ConditionOperator | undefined;
|
|
121
|
+
} | {
|
|
122
|
+
and?: ({
|
|
123
|
+
questionId: string;
|
|
124
|
+
value: unknown;
|
|
125
|
+
operator?: ConditionOperator | undefined;
|
|
126
|
+
} | /*elided*/ any)[] | undefined;
|
|
127
|
+
or?: ({
|
|
128
|
+
questionId: string;
|
|
129
|
+
value: unknown;
|
|
130
|
+
operator?: ConditionOperator | undefined;
|
|
131
|
+
} | /*elided*/ any)[] | undefined;
|
|
132
|
+
} | undefined;
|
|
133
|
+
validation?: {
|
|
134
|
+
min?: number | undefined;
|
|
135
|
+
max?: number | undefined;
|
|
136
|
+
minLength?: number | undefined;
|
|
137
|
+
maxLength?: number | undefined;
|
|
138
|
+
regex?: string | undefined;
|
|
139
|
+
message?: string | undefined;
|
|
140
|
+
} | undefined;
|
|
141
|
+
}[]>;
|
|
142
|
+
questionIndex: vue.ComputedRef<number>;
|
|
143
|
+
totalQuestions: vue.ComputedRef<number>;
|
|
144
|
+
progress: vue.ComputedRef<number>;
|
|
145
|
+
responses: vue.ComputedRef<Record<string, unknown>>;
|
|
146
|
+
isComplete: vue.ComputedRef<boolean>;
|
|
147
|
+
errors: vue.ComputedRef<Record<string, string>>;
|
|
148
|
+
canGoBack: vue.ComputedRef<boolean>;
|
|
149
|
+
canGoNext: vue.ComputedRef<boolean>;
|
|
150
|
+
/** Answer the current step question (step mode). In all-mode, use `answerById`. */
|
|
151
|
+
answer: (value: unknown) => void;
|
|
152
|
+
/** Answer any visible question by id — correct API for mode:"all". */
|
|
153
|
+
answerById: (questionId: string, value: unknown) => void;
|
|
154
|
+
next: () => void;
|
|
155
|
+
back: () => void;
|
|
156
|
+
jumpTo: (index: number) => void;
|
|
157
|
+
/** Validate all visible required questions. Returns id→error map. */
|
|
158
|
+
validate: () => Record<string, string>;
|
|
159
|
+
reset: () => void;
|
|
160
|
+
/** Returns only responses for currently visible questions. Use for submission. */
|
|
161
|
+
getSubmittableResponses: () => Record<string, unknown>;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export { useQuestionnaire };
|