@likerts/react-native 0.0.3
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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/examples/CheckoutFeedback.tsx +18 -0
- package/lib/Survey.d.ts +47 -0
- package/lib/Survey.js +102 -0
- package/lib/SurveyHost.d.ts +21 -0
- package/lib/SurveyHost.js +72 -0
- package/lib/advanced-questions.d.ts +20 -0
- package/lib/advanced-questions.js +19 -0
- package/lib/branching.d.ts +26 -0
- package/lib/branching.js +45 -0
- package/lib/choice-features.d.ts +9 -0
- package/lib/choice-features.js +59 -0
- package/lib/index.d.ts +118 -0
- package/lib/index.js +107 -0
- package/lib/offline.d.ts +69 -0
- package/lib/offline.js +117 -0
- package/package.json +76 -0
- package/src/Survey.tsx +53 -0
- package/src/SurveyHost.tsx +30 -0
- package/src/advanced-questions.ts +7 -0
- package/src/branching.ts +8 -0
- package/src/choice-features.ts +45 -0
- package/src/index.ts +54 -0
- package/src/offline.ts +35 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.3 — 2026-09-09
|
|
4
|
+
|
|
5
|
+
- Schema 5 adds ranking, single/multiple matrix and exact-total constant-sum questions with bounded answer schemas.
|
|
6
|
+
- Native controls use explicit rank movement, stacked matrix rows and an announced constant-sum remainder.
|
|
7
|
+
|
|
8
|
+
## 0.0.2 — 2026-09-09
|
|
9
|
+
|
|
10
|
+
- Schema 3 adds conditional visibility, structured Other text, exclusive None choices, stars and dropdown presentations. Existing schema 1/2 answers remain compatible.
|
|
11
|
+
- Schema 4 adds pages, forward routing, page validation, Back/Next navigation, progress and removal of skipped answers.
|
|
12
|
+
- Host-controlled triggers, submission and distribution remain unchanged. No package publication.
|
|
13
|
+
|
|
14
|
+
## 0.0.1 — 2026-09-08
|
|
15
|
+
|
|
16
|
+
- Collection-only client and six-type native renderer with NPS/yes-no, labels and selection bounds.
|
|
17
|
+
- Cancellable SurveyHost adapter preserves submission intent after ambiguous failures; host supplies its key generator.
|
|
18
|
+
- React Native source entry, compiled library/type declarations and customer-controlled checkout trigger example.
|
|
19
|
+
|
|
20
|
+
Local evaluation artifact for the recorded React Native 0.85–0.87 matrix. Publication and physical-device release checks remain separate.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Likerts contributors
|
|
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/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Likerts React Native SDK
|
|
2
|
+
|
|
3
|
+
## Install
|
|
4
|
+
|
|
5
|
+
Install a published version from npm:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @likerts/react-native
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
You can also use the installable tarballs from the [community release](https://github.com/crosstabs/likerts/releases/tag/community-v0.1.0).
|
|
12
|
+
|
|
13
|
+
## Install the current source
|
|
14
|
+
|
|
15
|
+
The free community edition supports schemas 1–5. Clone the repository and run these commands from its root:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm ci --prefix sdks/react-native
|
|
19
|
+
npm run build --prefix sdks/react-native
|
|
20
|
+
cd sdks/react-native
|
|
21
|
+
npm pack
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
From a host app on the supported React/React Native matrix, run `npm install /absolute/path/to/likerts/sdks/react-native/likerts-react-native-0.0.3.tgz` to install the tarball you just built. The package supplies a React Native source entry for Metro and compiled TypeScript declarations. It has no native module, autolinking step or pod installation of its own. React and React Native remain host peer dependencies. Frozen archives under `releases/` predate the free edition; build current source rather than installing those historical packages.
|
|
25
|
+
|
|
26
|
+
Import `LikertsClient` and `SurveyHost` from `@likerts/react-native`. `examples/CheckoutFeedback.tsx` shows host-controlled eligibility, a feedback button, dismissal and completion. Supply the host runtime's cryptographically strong key generator and a collection-only token; no management credential belongs in the bundle. Repository build/test commands below are for SDK contributors.
|
|
27
|
+
|
|
28
|
+
The package provides a public collection client, a native `Survey` renderer and an optional `SurveyHost` adapter. Administrative credentials do not belong in an iOS or Android bundle.
|
|
29
|
+
|
|
30
|
+
`Survey` is the lower-level component. The app owns loading, submission, errors and dismissal. It accepts translated operational messages, typed native style slots, initial answers, answer-change and validation callbacks, and a stable `testID` prefix. Controls expose question-qualified accessibility labels, selected/disabled/busy state and announced validation errors. Changing the collection identity resets answers and validation state.
|
|
31
|
+
|
|
32
|
+
`SurveyHost` is a small host-app adapter for teams that want the SDK to connect the client and renderer. It cancels collection loading and an active submission when its screen unmounts, keeps the same submission and idempotency key after an ambiguous failure, clears that pending intent when answers change, and invokes `onComplete` once after a valid receipt.
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
const client = new LikertsClient('https://feedback.example.com', publicCollectionToken);
|
|
36
|
+
|
|
37
|
+
<SurveyHost
|
|
38
|
+
client={client}
|
|
39
|
+
collectionId={collectionId}
|
|
40
|
+
createIdempotencyKey={() => randomUUIDFromYourNativeRuntime()}
|
|
41
|
+
metadata={{screen: 'receipt'}}
|
|
42
|
+
onComplete={() => navigation.goBack()}
|
|
43
|
+
onError={reportError}
|
|
44
|
+
surveyProps={{
|
|
45
|
+
messages: {submit: 'Send feedback', requiredSuffix: 'required'},
|
|
46
|
+
styles: {container: styles.survey, submit: styles.primaryButton},
|
|
47
|
+
testID: 'checkout-feedback',
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The host supplies the key generator because React Native runtime crypto support varies. Create a key for one intended submission and preserve it across ambiguous retries. The adapter does this automatically.
|
|
53
|
+
|
|
54
|
+
## Supported framework matrix
|
|
55
|
+
|
|
56
|
+
| React Native | React | Android/iOS renderer contract | Status |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| 0.85.x | 19.2.3 | TypeScript and Jest native-component integration | Passed on 0.85.0 |
|
|
59
|
+
| 0.86.x | 19.2.3 | TypeScript and Jest native-component integration | Passed on 0.86.3; locked development baseline |
|
|
60
|
+
| 0.87.x | 19.2.3 | TypeScript and Jest native-component integration | Passed on 0.87.0 |
|
|
61
|
+
|
|
62
|
+
Run `scripts/check-react-native-matrix.sh` from the repository root to reinstall and test all three exact baselines. The suite exercises both `Platform.OS` values and native accessibility/control props. These checks do not claim App Store/Play device certification; physical-device release rehearsal remains part of the cross-platform release gate. Separate [Android and iOS native-host runners](native-host/README.md) embed the real RN 0.86.3 renderer and exercise validation, accessibility state and exact advanced-question answers. They passed on an API-35 ARM64 emulator and iOS 26.4 simulator; they do not establish hosted networking, offline adapters or every OS/framework version.
|
|
63
|
+
|
|
64
|
+
React Native 0.85+ requires a supported Node release; this repository uses Node 22. The package uses public React Native component APIs and the New Architecture compatible surface only.
|
|
65
|
+
|
|
66
|
+
Schema 3 choice semantics are documented in [CHOICE-FEATURES.md](../../contracts/CHOICE-FEATURES.md): Other answers carry separate selected IDs and text; None is exclusive; stars retain numeric answers and dropdowns retain option IDs.
|
|
67
|
+
|
|
68
|
+
Schema 5 adds the bounded advanced families in [ADVANCED-QUESTIONS.md](../../contracts/ADVANCED-QUESTIONS.md). Ranking uses explicit accessible Move up/down controls. Matrix rows are stacked for narrow screens and emit row-to-column ID maps. Constant-sum inputs announce the remaining amount and emit every item ID only when integer allocations equal the configured total.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React, {useState} from 'react';
|
|
2
|
+
import {Button, View} from 'react-native';
|
|
3
|
+
import {LikertsClient, SurveyHost, type Receipt} from '@likerts/react-native';
|
|
4
|
+
|
|
5
|
+
export function CheckoutFeedback({client, collectionId, eligible, createIdempotencyKey, onComplete}: {
|
|
6
|
+
client: LikertsClient; collectionId: string; eligible: boolean;
|
|
7
|
+
createIdempotencyKey: () => string; onComplete: (receipt: Receipt) => void;
|
|
8
|
+
}) {
|
|
9
|
+
const [visible, setVisible] = useState(false);
|
|
10
|
+
if (!eligible) return null;
|
|
11
|
+
return <View>
|
|
12
|
+
<Button title="Give feedback" onPress={() => setVisible(true)} />
|
|
13
|
+
{visible && <SurveyHost client={client} collectionId={collectionId}
|
|
14
|
+
createIdempotencyKey={createIdempotencyKey} metadata={{placement: 'receipt'}}
|
|
15
|
+
onComplete={receipt => {setVisible(false); onComplete(receipt);}} />}
|
|
16
|
+
{visible && <Button title="Dismiss" onPress={() => setVisible(false)} />}
|
|
17
|
+
</View>;
|
|
18
|
+
}
|
package/lib/Survey.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StyleProp, TextStyle, ViewStyle } from 'react-native';
|
|
3
|
+
import type { Answer, Collection } from './index';
|
|
4
|
+
export interface SurveyMessages {
|
|
5
|
+
requiredSuffix: string;
|
|
6
|
+
requiredError: string;
|
|
7
|
+
selectionRangeError: string;
|
|
8
|
+
selectionLimitHint: string;
|
|
9
|
+
datePlaceholder: string;
|
|
10
|
+
back: string;
|
|
11
|
+
next: string;
|
|
12
|
+
progress: string;
|
|
13
|
+
submit: string;
|
|
14
|
+
submitting: string;
|
|
15
|
+
selectPlaceholder: string;
|
|
16
|
+
otherError: string;
|
|
17
|
+
moveUp: string;
|
|
18
|
+
moveDown: string;
|
|
19
|
+
remaining: string;
|
|
20
|
+
advancedError: string;
|
|
21
|
+
}
|
|
22
|
+
export interface SurveyStyles {
|
|
23
|
+
container?: StyleProp<ViewStyle>;
|
|
24
|
+
title?: StyleProp<TextStyle>;
|
|
25
|
+
question?: StyleProp<ViewStyle>;
|
|
26
|
+
label?: StyleProp<TextStyle>;
|
|
27
|
+
choice?: StyleProp<ViewStyle>;
|
|
28
|
+
choiceText?: StyleProp<TextStyle>;
|
|
29
|
+
input?: StyleProp<TextStyle>;
|
|
30
|
+
error?: StyleProp<TextStyle>;
|
|
31
|
+
submit?: StyleProp<ViewStyle>;
|
|
32
|
+
submitText?: StyleProp<TextStyle>;
|
|
33
|
+
}
|
|
34
|
+
export interface SurveyProps {
|
|
35
|
+
collection: Collection;
|
|
36
|
+
onSubmit: (answers: Record<string, Answer>) => void;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
submitting?: boolean;
|
|
39
|
+
initialAnswers?: Record<string, Answer>;
|
|
40
|
+
onAnswersChange?: (answers: Record<string, Answer>) => void;
|
|
41
|
+
onValidationError?: (message: string) => void;
|
|
42
|
+
messages?: Partial<SurveyMessages>;
|
|
43
|
+
styles?: SurveyStyles;
|
|
44
|
+
testID?: string;
|
|
45
|
+
}
|
|
46
|
+
/** Host owns network lifecycle, placement and dismissal; renderer checks selection bounds. */
|
|
47
|
+
export declare function Survey({ collection, onSubmit, disabled, submitting, initialAnswers, onAnswersChange, onValidationError, messages: overrides, styles, testID }: SurveyProps): React.JSX.Element;
|
package/lib/Survey.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { selectedChoices, otherTexts, toggleChoice, setOtherText, choiceError } from './choice-features';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { AccessibilityInfo, Pressable, Text, TextInput, View } from 'react-native';
|
|
5
|
+
import { visibleQuestionIds } from './index';
|
|
6
|
+
import { pageRoute, routedAnswers } from './branching';
|
|
7
|
+
import { advancedAnswerError, allocationRemaining, moveRanking, rankingOrder, setAllocation, setMatrixChoice } from './advanced-questions';
|
|
8
|
+
const defaults = { requiredSuffix: 'required', requiredError: '{question}: an answer is required.', selectionRangeError: '{question}: select between {min} and {max} options.', selectionLimitHint: 'Maximum selections reached', datePlaceholder: 'YYYY-MM-DD', back: 'Back', next: 'Next', progress: 'Page {current} of {total}', submit: 'Submit', submitting: 'Submitting…', selectPlaceholder: 'Select an answer', otherError: '{question}: enter valid text for the selected Other option.', moveUp: 'Move up', moveDown: 'Move down', remaining: '{remaining} remaining', advancedError: '{question}: complete the answer.' };
|
|
9
|
+
const format = (template, values) => Object.entries(values).reduce((result, [key, value]) => result.replace(`{${key}}`, String(value)), template);
|
|
10
|
+
/** Host owns network lifecycle, placement and dismissal; renderer checks selection bounds. */
|
|
11
|
+
export function Survey({ collection, onSubmit, disabled = false, submitting = false, initialAnswers = {}, onAnswersChange, onValidationError, messages: overrides, styles = {}, testID = 'likerts-survey' }) {
|
|
12
|
+
const messages = { ...defaults };
|
|
13
|
+
for (const key of Object.keys(defaults)) {
|
|
14
|
+
const value = overrides?.[key];
|
|
15
|
+
if (typeof value === 'string')
|
|
16
|
+
messages[key] = value;
|
|
17
|
+
}
|
|
18
|
+
const blocked = disabled || submitting;
|
|
19
|
+
const pages = collection.schema.pages ?? [{ id: 'survey', questionIds: collection.schema.questions.map(q => q.id) }];
|
|
20
|
+
const [answers, setAnswers] = useState(() => routedAnswers(collection.schema, { ...initialAnswers }));
|
|
21
|
+
const [error, setError] = useState('');
|
|
22
|
+
const [openDropdowns, setOpenDropdowns] = useState({});
|
|
23
|
+
const [currentPage, setCurrentPage] = useState(0);
|
|
24
|
+
const [history, setHistory] = useState([0]);
|
|
25
|
+
if (![1, 2, 3, 4, 5].includes(collection.schema.schemaVersion))
|
|
26
|
+
throw new Error('Unsupported survey schema version');
|
|
27
|
+
useEffect(() => { const reset = routedAnswers(collection.schema, { ...initialAnswers }); setAnswers(reset); setCurrentPage(0); setHistory([0]); setOpenDropdowns({}); setError(''); onAnswersChange?.(reset); }, [collection.id, collection.version]);
|
|
28
|
+
useEffect(() => { if (error)
|
|
29
|
+
AccessibilityInfo.announceForAccessibility(error); }, [error]);
|
|
30
|
+
const update = (id, value) => { setAnswers(previous => { const next = routedAnswers(collection.schema, { ...previous, [id]: typeof value === 'function' ? value(previous[id]) : value }); const route = pageRoute(collection.schema, next); if (!route.includes(currentPage)) {
|
|
31
|
+
let shared = route[0] ?? 0;
|
|
32
|
+
for (let i = 0; i < Math.min(history.length, route.length) && history[i] === route[i]; i++)
|
|
33
|
+
shared = route[i];
|
|
34
|
+
setCurrentPage(shared);
|
|
35
|
+
setHistory(route.slice(0, route.indexOf(shared) + 1));
|
|
36
|
+
} onAnswersChange?.(next); return next; }); setError(''); };
|
|
37
|
+
const fail = (message) => { setError(message); onValidationError?.(message); };
|
|
38
|
+
const visible = visibleQuestionIds(collection.schema.questions, answers), currentQuestions = new Set(pages[currentPage]?.questionIds ?? []), route = pageRoute(collection.schema, answers), routeIndex = route.indexOf(currentPage);
|
|
39
|
+
const advance = () => { const result = {}; for (const q of collection.schema.questions) {
|
|
40
|
+
if (!visible.has(q.id) || !currentQuestions.has(q.id))
|
|
41
|
+
continue;
|
|
42
|
+
const value = answers[q.id];
|
|
43
|
+
if (q.type === 'ranking' || q.type === 'matrix' || q.type === 'constant_sum') {
|
|
44
|
+
const code = advancedAnswerError(q, value);
|
|
45
|
+
if (code) {
|
|
46
|
+
fail(format(code === 'required' ? messages.requiredError : messages.advancedError, { question: q.label }));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (value !== undefined)
|
|
50
|
+
result[q.id] = value;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const isChoice = q.type === 'single_choice' || q.type === 'multiple_choice';
|
|
54
|
+
if (value === undefined || value === '' || (isChoice && selectedChoices(value).length === 0) || (Array.isArray(value) && value.length === 0)) {
|
|
55
|
+
if (q.required) {
|
|
56
|
+
fail(format(messages.requiredError, { question: q.label }));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (isChoice) {
|
|
62
|
+
const code = choiceError(q, value);
|
|
63
|
+
if (code) {
|
|
64
|
+
fail(format(code === 'other' ? messages.otherError : messages.selectionRangeError, { question: q.label, min: Math.max(q.required ? 1 : 0, q.minSelections ?? 0), max: q.maxSelections ?? q.options?.length ?? 0 }));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
result[q.id] = q.type === 'number' || q.type === 'scale' ? Number(value) : value;
|
|
69
|
+
} if (routeIndex + 1 < route.length) {
|
|
70
|
+
const next = route[routeIndex + 1];
|
|
71
|
+
setCurrentPage(next);
|
|
72
|
+
setHistory(route.slice(0, routeIndex + 2));
|
|
73
|
+
return;
|
|
74
|
+
} onSubmit(routedAnswers(collection.schema, { ...answers, ...result })); };
|
|
75
|
+
return _jsxs(View, { style: styles.container, testID: testID, accessibilityLabel: collection.schema.title, children: [_jsx(Text, { style: styles.title, accessibilityRole: "header", testID: `${testID}-title`, children: collection.schema.title }), collection.schema.pages ? _jsx(Text, { testID: `${testID}-progress`, children: format(messages.progress, { current: currentPage + 1, total: pages.length }) }) : null, collection.schema.questions.filter(q => visible.has(q.id) && currentQuestions.has(q.id)).map(q => {
|
|
76
|
+
const labelId = `${testID}-${q.id}-label`;
|
|
77
|
+
const questionLabel = `${q.label}${q.required ? `, ${messages.requiredSuffix}` : ''}`;
|
|
78
|
+
return _jsxs(View, { style: styles.question, testID: `${testID}-question-${q.id}`, children: [_jsxs(Text, { style: styles.label, nativeID: labelId, children: [q.label, q.required ? ` (${messages.requiredSuffix})` : ''] }), q.type === 'ranking' ? rankingOrder(q, answers[q.id]).map((id, index) => { const option = q.options?.find(value => value.id === id); return _jsxs(View, { children: [_jsxs(Text, { style: styles.choiceText, children: [index + 1, ". ", option.label] }), _jsx(Pressable, { testID: `${testID}-${q.id}-${id}-up`, accessibilityRole: "button", accessibilityLabel: `${messages.moveUp}: ${option.label}`, disabled: blocked || index === 0, onPress: () => update(q.id, current => moveRanking(q, current, id, -1)), children: _jsx(Text, { children: messages.moveUp }) }), _jsx(Pressable, { testID: `${testID}-${q.id}-${id}-down`, accessibilityRole: "button", accessibilityLabel: `${messages.moveDown}: ${option.label}`, disabled: blocked || index === rankingOrder(q, answers[q.id]).length - 1, onPress: () => update(q.id, current => moveRanking(q, current, id, 1)), children: _jsx(Text, { children: messages.moveDown }) })] }, id); })
|
|
79
|
+
: q.type === 'matrix' ? (q.rows ?? []).map(row => _jsxs(View, { children: [_jsx(Text, { style: styles.choiceText, children: row.label }), (q.columns ?? []).map(column => { const value = answers[q.id]?.[row.id], selected = Array.isArray(value) ? value.includes(column.id) : value === column.id; return _jsx(Pressable, { testID: `${testID}-${q.id}-${row.id}-${column.id}`, accessibilityRole: q.matrixMode === 'single' ? 'radio' : 'checkbox', accessibilityLabel: `${row.label}: ${column.label}`, accessibilityState: { checked: selected, disabled: blocked }, disabled: blocked, onPress: () => update(q.id, current => setMatrixChoice(q, current, row.id, column.id)), children: _jsxs(Text, { children: [selected ? '✓ ' : '', column.label] }) }, column.id); })] }, row.id))
|
|
80
|
+
: q.type === 'constant_sum' ? _jsxs(_Fragment, { children: [(q.items ?? []).map(item => _jsx(TextInput, { style: styles.input, testID: `${testID}-${q.id}-${item.id}`, accessibilityLabel: `${q.label}: ${item.label}`, keyboardType: "number-pad", editable: !blocked, value: String(answers[q.id]?.[item.id] ?? ''), onChangeText: value => update(q.id, current => setAllocation(current, item.id, Number(value))) }, item.id)), _jsx(Text, { accessibilityLiveRegion: "polite", testID: `${testID}-${q.id}-remaining`, children: format(messages.remaining, { remaining: allocationRemaining(q, answers[q.id]) }) })] })
|
|
81
|
+
: _jsxs(_Fragment, { children: [q.presentation === 'dropdown' ? _jsx(Pressable, { style: styles.choice, testID: `${testID}-${q.id}-dropdown`, accessibilityRole: "button", accessibilityLabel: `${q.label}: ${q.options?.find(o => o.id === selectedChoices(answers[q.id])[0])?.label ?? messages.selectPlaceholder}`, accessibilityState: { expanded: !!openDropdowns[q.id], disabled: blocked }, disabled: blocked, onPress: () => setOpenDropdowns(previous => ({ ...previous, [q.id]: !previous[q.id] })), children: _jsx(Text, { style: styles.choiceText, children: q.options?.find(o => o.id === selectedChoices(answers[q.id])[0])?.label ?? messages.selectPlaceholder }) }) : null, q.type === 'single_choice' || q.type === 'multiple_choice' ? (q.presentation === 'dropdown' && !openDropdowns[q.id] ? [] : q.options ?? []).map(option => {
|
|
82
|
+
const current = selectedChoices(answers[q.id]), selected = current.includes(option.id);
|
|
83
|
+
const exclusive = current.some(id => q.options?.some(o => o.id === id && o.exclusive));
|
|
84
|
+
const atLimit = q.type === 'multiple_choice' && !selected && !option.exclusive && !exclusive && q.maxSelections !== undefined && current.length >= q.maxSelections;
|
|
85
|
+
const optionBlocked = blocked || atLimit;
|
|
86
|
+
return _jsx(Pressable, { style: styles.choice, testID: `${testID}-${q.id}-${option.id}`, disabled: optionBlocked, accessibilityLabel: `${q.label}: ${option.label}`, accessibilityHint: atLimit ? messages.selectionLimitHint : undefined, accessibilityRole: q.type === 'single_choice' ? 'radio' : 'checkbox', accessibilityState: { checked: selected, disabled: optionBlocked }, onPress: () => { if (!optionBlocked) {
|
|
87
|
+
update(q.id, current => toggleChoice(q, current, option.id));
|
|
88
|
+
if (q.presentation === 'dropdown')
|
|
89
|
+
setOpenDropdowns(previous => ({ ...previous, [q.id]: false }));
|
|
90
|
+
} }, children: _jsxs(Text, { style: styles.choiceText, children: [selected ? '✓ ' : '', option.label] }) }, option.id);
|
|
91
|
+
})
|
|
92
|
+
: q.type === 'scale' && (q.labels !== undefined || q.preset === 'nps' || q.presentation === 'stars') ? Array.from({ length: (q.max ?? 0) - (q.min ?? 0) + 1 }, (_, i) => (q.min ?? 0) + i).map(value => { const label = q.labels?.[String(value)] ? `${value} — ${q.labels[String(value)]}` : String(value); return _jsx(Pressable, { style: styles.choice, testID: `${testID}-${q.id}-${value}`, disabled: blocked, accessibilityLabel: `${q.label}: ${label}`, accessibilityRole: "radio", accessibilityState: { checked: answers[q.id] === value, disabled: blocked }, onPress: () => { if (!blocked)
|
|
93
|
+
update(q.id, value); }, children: _jsx(Text, { style: styles.choiceText, children: q.presentation === 'stars' ? '★'.repeat(value) : label }) }, value); })
|
|
94
|
+
: _jsx(TextInput, { style: styles.input, testID: `${testID}-${q.id}`, accessibilityLabel: questionLabel, accessibilityLabelledBy: labelId, editable: !blocked, value: String(answers[q.id] ?? ''), multiline: q.type === 'text', maxLength: q.maxLength, placeholder: q.type === 'date' ? messages.datePlaceholder : undefined, keyboardType: q.type === 'number' || q.type === 'scale' ? 'numbers-and-punctuation' : 'default', onChangeText: value => update(q.id, value) })] }), (q.options ?? []).filter(o => o.other !== undefined && selectedChoices(answers[q.id]).includes(o.id)).map(option => _jsx(TextInput, { style: styles.input, testID: `${testID}-${q.id}-${option.id}-text`, accessibilityLabel: `${q.label}: ${option.label}`, editable: !blocked, value: otherTexts(answers[q.id])[option.id] ?? '', onChangeText: value => update(q.id, current => setOtherText(q, current, option.id, value)) }, option.id))] }, q.id);
|
|
95
|
+
}), error ? _jsx(Text, { style: styles.error, testID: `${testID}-error`, accessibilityRole: "alert", accessibilityLiveRegion: "assertive", children: error }) : null, collection.schema.pages && history.length > 1 ? _jsx(Pressable, { style: styles.submit, testID: `${testID}-back`, disabled: blocked, accessibilityRole: "button", onPress: () => { if (!blocked) {
|
|
96
|
+
const next = history.slice(0, -1);
|
|
97
|
+
setHistory(next);
|
|
98
|
+
setCurrentPage(next[next.length - 1]);
|
|
99
|
+
setError('');
|
|
100
|
+
} }, children: _jsx(Text, { style: styles.submitText, children: messages.back }) }) : null, _jsx(Pressable, { style: styles.submit, testID: `${testID}-${routeIndex + 1 < route.length ? 'next' : 'submit'}`, disabled: blocked, accessibilityLabel: submitting ? messages.submitting : routeIndex + 1 < route.length ? messages.next : messages.submit, accessibilityRole: "button", accessibilityState: { disabled: blocked, busy: submitting }, onPress: () => { if (!blocked)
|
|
101
|
+
advance(); }, children: _jsx(Text, { style: styles.submitText, children: submitting ? messages.submitting : routeIndex + 1 < route.length ? messages.next : messages.submit }) })] });
|
|
102
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { LikertsClient, Receipt } from './index';
|
|
3
|
+
import { SurveyProps } from './Survey';
|
|
4
|
+
export interface SurveyHostMessages {
|
|
5
|
+
loading: string;
|
|
6
|
+
loadError: string;
|
|
7
|
+
submitError: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SurveyHostProps {
|
|
10
|
+
client: LikertsClient;
|
|
11
|
+
collectionId: string;
|
|
12
|
+
createIdempotencyKey: () => string;
|
|
13
|
+
metadata?: Record<string, unknown>;
|
|
14
|
+
onComplete: (receipt: Receipt) => void;
|
|
15
|
+
onError?: (error: unknown) => void;
|
|
16
|
+
refreshOnMount?: boolean;
|
|
17
|
+
messages?: Partial<SurveyHostMessages>;
|
|
18
|
+
surveyProps?: Omit<SurveyProps, 'collection' | 'onSubmit' | 'disabled' | 'submitting'>;
|
|
19
|
+
}
|
|
20
|
+
/** Optional host adapter: owns cancellable loading/submission while Survey remains usable on its own. */
|
|
21
|
+
export declare function SurveyHost({ client, collectionId, createIdempotencyKey, metadata, onComplete, onError, refreshOnMount, messages: overrides, surveyProps }: SurveyHostProps): React.JSX.Element;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Text } from 'react-native';
|
|
4
|
+
import { Survey } from './Survey';
|
|
5
|
+
const defaults = { loading: 'Loading survey…', loadError: 'Could not load survey.', submitError: 'Could not confirm submission. Try again without changing the answers.' };
|
|
6
|
+
/** Optional host adapter: owns cancellable loading/submission while Survey remains usable on its own. */
|
|
7
|
+
export function SurveyHost({ client, collectionId, createIdempotencyKey, metadata = {}, onComplete, onError, refreshOnMount = false, messages: overrides, surveyProps = {} }) {
|
|
8
|
+
const messages = { ...defaults };
|
|
9
|
+
for (const key of Object.keys(defaults)) {
|
|
10
|
+
const value = overrides?.[key];
|
|
11
|
+
if (typeof value === 'string')
|
|
12
|
+
messages[key] = value;
|
|
13
|
+
}
|
|
14
|
+
const [collection, setCollection] = useState();
|
|
15
|
+
const [error, setError] = useState('');
|
|
16
|
+
const [submitting, setSubmitting] = useState(false);
|
|
17
|
+
const [completed, setCompleted] = useState(false);
|
|
18
|
+
const pending = useRef(undefined);
|
|
19
|
+
const generation = useRef(0);
|
|
20
|
+
const activeSubmit = useRef(undefined);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const current = ++generation.current;
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
activeSubmit.current?.abort();
|
|
25
|
+
setCollection(undefined);
|
|
26
|
+
setError('');
|
|
27
|
+
setCompleted(false);
|
|
28
|
+
pending.current = undefined;
|
|
29
|
+
client.collection(collectionId, { refresh: refreshOnMount, signal: controller.signal }).then(value => { if (generation.current === current)
|
|
30
|
+
setCollection(value); }).catch(cause => { if (!controller.signal.aborted && generation.current === current) {
|
|
31
|
+
setError(messages.loadError);
|
|
32
|
+
onError?.(cause);
|
|
33
|
+
} });
|
|
34
|
+
return () => { generation.current++; controller.abort(); activeSubmit.current?.abort(); };
|
|
35
|
+
}, [client, collectionId, refreshOnMount]);
|
|
36
|
+
const changed = (answers) => { if (pending.current?.serialized !== JSON.stringify(answers))
|
|
37
|
+
pending.current = undefined; surveyProps.onAnswersChange?.(answers); };
|
|
38
|
+
const submit = async (answers) => {
|
|
39
|
+
if (!collection || submitting || completed)
|
|
40
|
+
return;
|
|
41
|
+
const serialized = JSON.stringify(answers);
|
|
42
|
+
if (!pending.current)
|
|
43
|
+
pending.current = { serialized, submission: { idempotencyKey: createIdempotencyKey(), answers: JSON.parse(serialized), metadata: JSON.parse(JSON.stringify(metadata)) } };
|
|
44
|
+
const current = ++generation.current;
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
activeSubmit.current = controller;
|
|
47
|
+
setSubmitting(true);
|
|
48
|
+
setError('');
|
|
49
|
+
try {
|
|
50
|
+
const receipt = await client.submit(collection.id, pending.current.submission, { signal: controller.signal });
|
|
51
|
+
if (generation.current === current) {
|
|
52
|
+
setCompleted(true);
|
|
53
|
+
onComplete(receipt);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (cause) {
|
|
57
|
+
if (generation.current === current) {
|
|
58
|
+
setError(messages.submitError);
|
|
59
|
+
onError?.(cause);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
if (activeSubmit.current === controller)
|
|
64
|
+
activeSubmit.current = undefined;
|
|
65
|
+
if (generation.current === current)
|
|
66
|
+
setSubmitting(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
if (!collection)
|
|
70
|
+
return _jsx(Text, { testID: "likerts-host-status", accessibilityRole: error ? 'alert' : undefined, accessibilityLiveRegion: "polite", children: error || messages.loading });
|
|
71
|
+
return _jsxs(_Fragment, { children: [_jsx(Survey, { ...surveyProps, collection: collection, onSubmit: submit, onAnswersChange: changed, disabled: completed, submitting: submitting }), error ? _jsx(Text, { testID: "likerts-host-error", accessibilityRole: "alert", accessibilityLiveRegion: "assertive", children: error }) : null] });
|
|
72
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type AdvancedItem = {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
};
|
|
5
|
+
export type AdvancedQuestion = {
|
|
6
|
+
type: string;
|
|
7
|
+
required?: boolean;
|
|
8
|
+
options?: AdvancedItem[];
|
|
9
|
+
rows?: AdvancedItem[];
|
|
10
|
+
columns?: AdvancedItem[];
|
|
11
|
+
matrixMode?: 'single' | 'multiple';
|
|
12
|
+
items?: AdvancedItem[];
|
|
13
|
+
total?: number;
|
|
14
|
+
};
|
|
15
|
+
export declare const rankingOrder: (q: AdvancedQuestion, a: unknown) => string[];
|
|
16
|
+
export declare function moveRanking(q: AdvancedQuestion, a: unknown, id: string, offset: -1 | 1): string[];
|
|
17
|
+
export declare function setMatrixChoice(q: AdvancedQuestion, a: unknown, row: string, column: string): Record<string, string | string[]>;
|
|
18
|
+
export declare function setAllocation(a: unknown, item: string, value: number): Record<string, number>;
|
|
19
|
+
export declare function allocationRemaining(q: AdvancedQuestion, a: unknown): number;
|
|
20
|
+
export declare function advancedAnswerError(q: AdvancedQuestion, a: unknown): string | undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const rankingOrder = (q, a) => Array.isArray(a) ? a.filter((id) => typeof id === 'string') : (q.options ?? []).map(o => o.id);
|
|
2
|
+
export function moveRanking(q, a, id, offset) { const order = rankingOrder(q, a), from = order.indexOf(id); if (from < 0)
|
|
3
|
+
return order; const to = Math.max(0, Math.min(order.length - 1, from + offset)), next = [...order]; next.splice(from, 1); next.splice(to, 0, id); return next; }
|
|
4
|
+
export function setMatrixChoice(q, a, row, column) { const current = a && typeof a === 'object' && !Array.isArray(a) ? a : {}; if (q.matrixMode === 'single')
|
|
5
|
+
return { ...current, [row]: column }; const selected = Array.isArray(current[row]) ? current[row] : [], next = selected.includes(column) ? selected.filter(id => id !== column) : [...selected, column], result = { ...current }; if (next.length)
|
|
6
|
+
result[row] = next;
|
|
7
|
+
else
|
|
8
|
+
delete result[row]; return result; }
|
|
9
|
+
export function setAllocation(a, item, value) { return { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}), [item]: value }; }
|
|
10
|
+
export function allocationRemaining(q, a) { return (q.total ?? 0) - Object.values(a && typeof a === 'object' && !Array.isArray(a) ? a : {}).reduce((sum, value) => sum + (Number.isSafeInteger(value) ? value : 0), 0); }
|
|
11
|
+
export function advancedAnswerError(q, a) { if (a === undefined)
|
|
12
|
+
return q.required ? 'required' : undefined; if (q.type === 'ranking') {
|
|
13
|
+
const ids = (q.options ?? []).map(o => o.id), v = Array.isArray(a) ? a : [];
|
|
14
|
+
return v.length === ids.length && new Set(v).size === v.length && v.every(id => ids.includes(id)) ? undefined : 'ranking';
|
|
15
|
+
} if (!a || typeof a !== 'object' || Array.isArray(a))
|
|
16
|
+
return q.type; if (q.type === 'matrix') {
|
|
17
|
+
const rows = (q.rows ?? []).map(o => o.id), columns = (q.columns ?? []).map(o => o.id), entries = Object.entries(a);
|
|
18
|
+
return entries.length && (!q.required || entries.length === rows.length) && entries.every(([row, value]) => rows.includes(row) && (q.matrixMode === 'single' ? typeof value === 'string' && columns.includes(value) : Array.isArray(value) && value.length > 0 && new Set(value).size === value.length && value.every(id => typeof id === 'string' && columns.includes(id)))) ? undefined : 'matrix';
|
|
19
|
+
} const items = (q.items ?? []).map(o => o.id), entries = Object.entries(a); return entries.length === items.length && entries.every(([id, value]) => items.includes(id) && Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= (q.total ?? 0)) && allocationRemaining(q, a) === 0 ? undefined : 'constant_sum'; }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Answer, Question, SurveyPage } from './index';
|
|
2
|
+
export interface BranchingSchema {
|
|
3
|
+
questions: Question[];
|
|
4
|
+
pages?: SurveyPage[];
|
|
5
|
+
}
|
|
6
|
+
export interface SurveyProgress {
|
|
7
|
+
current: number;
|
|
8
|
+
total: number;
|
|
9
|
+
visited: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function pageRoute(schema: BranchingSchema, answers: Record<string, Answer>): number[];
|
|
12
|
+
export declare function routedAnswers(schema: BranchingSchema, answers: Record<string, Answer>): Record<string, Answer>;
|
|
13
|
+
export declare class SurveyFlow {
|
|
14
|
+
readonly schema: BranchingSchema;
|
|
15
|
+
private route;
|
|
16
|
+
private history;
|
|
17
|
+
private current;
|
|
18
|
+
private values;
|
|
19
|
+
constructor(schema: BranchingSchema, initial?: Record<string, Answer>);
|
|
20
|
+
get page(): SurveyPage;
|
|
21
|
+
get answers(): Record<string, Answer>;
|
|
22
|
+
get progress(): SurveyProgress;
|
|
23
|
+
setAnswer(id: string, value: Answer | undefined): void;
|
|
24
|
+
next(): boolean;
|
|
25
|
+
back(): boolean;
|
|
26
|
+
}
|
package/lib/branching.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { conditionMatches, visibleAnswers } from './index';
|
|
2
|
+
const pages = (schema) => schema.pages ?? [{ id: 'survey', questionIds: schema.questions.map(q => q.id) }];
|
|
3
|
+
export function pageRoute(schema, answers) { const all = pages(schema), ids = new Map(all.map((p, i) => [p.id, i])), visible = visibleAnswers(schema.questions, answers), available = new Set(), route = []; let current = 0; while (current < all.length) {
|
|
4
|
+
route.push(current);
|
|
5
|
+
all[current].questionIds.forEach(id => available.add(id));
|
|
6
|
+
const branch = all[current].branches?.find(b => conditionMatches(b.when, available.has(b.when.questionId) ? visible[b.when.questionId] : undefined));
|
|
7
|
+
current = branch ? ids.get(branch.goToPageId) ?? all.length : current + 1;
|
|
8
|
+
} return route; }
|
|
9
|
+
export function routedAnswers(schema, answers) { let result = visibleAnswers(schema.questions, answers); const all = pages(schema); for (let pass = 0; pass <= schema.questions.length; pass++) {
|
|
10
|
+
const reached = new Set(pageRoute(schema, result).flatMap(i => all[i].questionIds));
|
|
11
|
+
const filtered = visibleAnswers(schema.questions, Object.fromEntries(Object.entries(result).filter(([id]) => reached.has(id))));
|
|
12
|
+
if (Object.keys(filtered).length === Object.keys(result).length)
|
|
13
|
+
return filtered;
|
|
14
|
+
result = filtered;
|
|
15
|
+
} throw new Error('Survey route did not stabilize'); }
|
|
16
|
+
export class SurveyFlow {
|
|
17
|
+
schema;
|
|
18
|
+
route;
|
|
19
|
+
history;
|
|
20
|
+
current;
|
|
21
|
+
values;
|
|
22
|
+
constructor(schema, initial = {}) {
|
|
23
|
+
this.schema = schema;
|
|
24
|
+
this.values = routedAnswers(schema, { ...initial });
|
|
25
|
+
this.route = pageRoute(schema, this.values);
|
|
26
|
+
this.current = this.route[0] ?? 0;
|
|
27
|
+
this.history = [this.current];
|
|
28
|
+
}
|
|
29
|
+
get page() { return pages(this.schema)[this.current]; }
|
|
30
|
+
get answers() { return { ...this.values }; }
|
|
31
|
+
get progress() { return { current: this.current + 1, total: pages(this.schema).length, visited: this.history.length }; }
|
|
32
|
+
setAnswer(id, value) { const old = this.history; if (value === undefined)
|
|
33
|
+
delete this.values[id];
|
|
34
|
+
else
|
|
35
|
+
this.values[id] = value; this.values = routedAnswers(this.schema, this.values); this.route = pageRoute(this.schema, this.values); if (!this.route.includes(this.current)) {
|
|
36
|
+
let shared = this.route[0] ?? 0;
|
|
37
|
+
for (let i = 0; i < Math.min(old.length, this.route.length) && old[i] === this.route[i]; i++)
|
|
38
|
+
shared = this.route[i];
|
|
39
|
+
this.current = shared;
|
|
40
|
+
} this.history = this.route.slice(0, this.route.indexOf(this.current) + 1); }
|
|
41
|
+
next() { const at = this.route.indexOf(this.current); if (at < 0 || at + 1 >= this.route.length)
|
|
42
|
+
return false; this.current = this.route[at + 1]; this.history = this.route.slice(0, at + 2); return true; }
|
|
43
|
+
back() { if (this.history.length < 2)
|
|
44
|
+
return false; this.history.pop(); this.current = this.history[this.history.length - 1]; return true; }
|
|
45
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Answer, Question } from './index';
|
|
2
|
+
/** Selected option IDs have the same meaning across legacy and structured answers. */
|
|
3
|
+
export declare function selectedChoices(value: unknown): string[];
|
|
4
|
+
export declare function otherTexts(value: unknown): Record<string, string>;
|
|
5
|
+
export declare function choiceAnswer(q: Question, selected: string[], text?: Record<string, string>): Answer;
|
|
6
|
+
export declare function toggleChoice(q: Question, value: unknown, id: string): Answer;
|
|
7
|
+
export declare function setOtherText(q: Question, value: unknown, id: string, text: string): Answer;
|
|
8
|
+
/** Local feedback only; the server validates every accepted answer again. */
|
|
9
|
+
export declare function choiceError(q: Question, value: unknown): 'required' | 'range' | 'other' | 'invalid' | undefined;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Selected option IDs have the same meaning across legacy and structured answers. */
|
|
2
|
+
export function selectedChoices(value) {
|
|
3
|
+
if (typeof value === 'string')
|
|
4
|
+
return value ? [value] : [];
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return value.filter((v) => typeof v === 'string');
|
|
7
|
+
if (value && typeof value === 'object' && 'selected' in value && Array.isArray(value.selected))
|
|
8
|
+
return value.selected.filter((v) => typeof v === 'string');
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
export function otherTexts(value) {
|
|
12
|
+
if (!value || typeof value !== 'object' || !('otherText' in value) || !value.otherText || typeof value.otherText !== 'object' || Array.isArray(value.otherText))
|
|
13
|
+
return {};
|
|
14
|
+
return Object.fromEntries(Object.entries(value.otherText).filter((entry) => typeof entry[1] === 'string'));
|
|
15
|
+
}
|
|
16
|
+
export function choiceAnswer(q, selected, text = {}) {
|
|
17
|
+
if (q.options?.some(o => o.other !== undefined))
|
|
18
|
+
return { selected: [...selected], otherText: Object.fromEntries((q.options ?? []).filter(o => o.other !== undefined && selected.includes(o.id)).map(o => [o.id, text[o.id] ?? ''])) };
|
|
19
|
+
return q.type === 'single_choice' ? selected[0] ?? '' : [...selected];
|
|
20
|
+
}
|
|
21
|
+
export function toggleChoice(q, value, id) {
|
|
22
|
+
const option = q.options?.find(o => o.id === id);
|
|
23
|
+
if (!option)
|
|
24
|
+
throw new TypeError('Unknown choice');
|
|
25
|
+
const current = selectedChoices(value);
|
|
26
|
+
const selected = q.type === 'single_choice' ? [id] : current.includes(id) ? current.filter(v => v !== id) : option.exclusive ? [id] : [...current.filter(v => !q.options?.find(o => o.id === v)?.exclusive), id];
|
|
27
|
+
return choiceAnswer(q, selected, otherTexts(value));
|
|
28
|
+
}
|
|
29
|
+
export function setOtherText(q, value, id, text) {
|
|
30
|
+
return choiceAnswer(q, selectedChoices(value), { ...otherTexts(value), [id]: text });
|
|
31
|
+
}
|
|
32
|
+
/** Local feedback only; the server validates every accepted answer again. */
|
|
33
|
+
export function choiceError(q, value) {
|
|
34
|
+
if (value === undefined || value === '')
|
|
35
|
+
return q.required ? 'required' : undefined;
|
|
36
|
+
const hasOther = q.options?.some(o => o.other !== undefined);
|
|
37
|
+
if (hasOther) {
|
|
38
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== 2 || !('selected' in value) || !Array.isArray(value.selected) || !value.selected.every(v => typeof v === 'string') || !('otherText' in value) || !value.otherText || typeof value.otherText !== 'object' || Array.isArray(value.otherText))
|
|
39
|
+
return 'invalid';
|
|
40
|
+
}
|
|
41
|
+
else if (q.type === 'single_choice' ? typeof value !== 'string' : !Array.isArray(value) || !value.every(v => typeof v === 'string'))
|
|
42
|
+
return 'invalid';
|
|
43
|
+
const selected = selectedChoices(value);
|
|
44
|
+
if (new Set(selected).size !== selected.length || selected.some(id => !q.options?.some(o => o.id === id)))
|
|
45
|
+
return 'invalid';
|
|
46
|
+
const exclusive = selected.some(id => q.options?.some(o => o.id === id && o.exclusive));
|
|
47
|
+
if (exclusive && selected.length !== 1)
|
|
48
|
+
return 'invalid';
|
|
49
|
+
if (q.type === 'single_choice' ? selected.length !== 1 : selected.length > (q.maxSelections ?? q.options?.length ?? 0) || (!exclusive && selected.length < Math.max(q.required ? 1 : 0, q.minSelections ?? 0)))
|
|
50
|
+
return 'range';
|
|
51
|
+
if (hasOther) {
|
|
52
|
+
const text = otherTexts(value), expected = (q.options ?? []).filter(o => o.other !== undefined && selected.includes(o.id));
|
|
53
|
+
if (Object.keys(value.otherText).length !== expected.length)
|
|
54
|
+
return 'other';
|
|
55
|
+
if (expected.some(o => typeof text[o.id] !== 'string' || !text[o.id].trim() || Array.from(text[o.id]).length > o.other.maxLength))
|
|
56
|
+
return 'other';
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|