@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/lib/index.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export interface StructuredChoiceAnswer {
|
|
2
|
+
selected: string[];
|
|
3
|
+
otherText: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
export type Answer = string | number | string[] | StructuredChoiceAnswer | Record<string, string | string[]> | Record<string, number>;
|
|
6
|
+
export type QuestionType = 'single_choice' | 'multiple_choice' | 'scale' | 'text' | 'number' | 'date' | 'ranking' | 'matrix' | 'constant_sum';
|
|
7
|
+
export type VisibilityOperator = 'equals' | 'not_equals' | 'includes' | 'not_includes' | 'answered' | 'not_answered';
|
|
8
|
+
export interface VisibilityCondition {
|
|
9
|
+
questionId: string;
|
|
10
|
+
operator: VisibilityOperator;
|
|
11
|
+
value?: string | number;
|
|
12
|
+
}
|
|
13
|
+
export interface PageBranch {
|
|
14
|
+
when: VisibilityCondition;
|
|
15
|
+
goToPageId: string;
|
|
16
|
+
}
|
|
17
|
+
export interface SurveyPage {
|
|
18
|
+
id: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
questionIds: string[];
|
|
21
|
+
branches?: PageBranch[];
|
|
22
|
+
}
|
|
23
|
+
export interface Choice {
|
|
24
|
+
id: string;
|
|
25
|
+
label: string;
|
|
26
|
+
other?: {
|
|
27
|
+
maxLength: number;
|
|
28
|
+
};
|
|
29
|
+
exclusive?: true;
|
|
30
|
+
}
|
|
31
|
+
export interface PromptItem {
|
|
32
|
+
id: string;
|
|
33
|
+
label: string;
|
|
34
|
+
}
|
|
35
|
+
export interface Question {
|
|
36
|
+
id: string;
|
|
37
|
+
type: QuestionType;
|
|
38
|
+
label: string;
|
|
39
|
+
required?: boolean;
|
|
40
|
+
options?: Choice[];
|
|
41
|
+
min?: number;
|
|
42
|
+
max?: number;
|
|
43
|
+
maxLength?: number;
|
|
44
|
+
preset?: 'nps' | 'yes_no';
|
|
45
|
+
labels?: Record<string, string>;
|
|
46
|
+
minSelections?: number;
|
|
47
|
+
maxSelections?: number;
|
|
48
|
+
visibleWhen?: VisibilityCondition;
|
|
49
|
+
presentation?: 'stars' | 'dropdown';
|
|
50
|
+
rows?: PromptItem[];
|
|
51
|
+
columns?: Choice[];
|
|
52
|
+
matrixMode?: 'single' | 'multiple';
|
|
53
|
+
items?: PromptItem[];
|
|
54
|
+
total?: number;
|
|
55
|
+
}
|
|
56
|
+
export interface Collection {
|
|
57
|
+
id: string;
|
|
58
|
+
surveyId: string;
|
|
59
|
+
version: number;
|
|
60
|
+
placement: string;
|
|
61
|
+
schema: {
|
|
62
|
+
schemaVersion: 1 | 2 | 3 | 4 | 5;
|
|
63
|
+
title: string;
|
|
64
|
+
questions: Question[];
|
|
65
|
+
pages?: SurveyPage[];
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export interface Submission {
|
|
69
|
+
idempotencyKey: string;
|
|
70
|
+
answers: Record<string, Answer>;
|
|
71
|
+
metadata: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
export interface Receipt {
|
|
74
|
+
responseId: string;
|
|
75
|
+
collectionId: string;
|
|
76
|
+
accepted: true;
|
|
77
|
+
}
|
|
78
|
+
export interface RequestOptions {
|
|
79
|
+
signal?: AbortSignal;
|
|
80
|
+
timeoutMs?: number;
|
|
81
|
+
}
|
|
82
|
+
export interface CollectionRequestOptions extends RequestOptions {
|
|
83
|
+
refresh?: boolean;
|
|
84
|
+
}
|
|
85
|
+
export declare const LIKERTS_SDK_CAPABILITY: Readonly<{
|
|
86
|
+
target: "react_native";
|
|
87
|
+
sdkVersion: "0.0.3";
|
|
88
|
+
schemaVersions: readonly [1, 2, 3, 4, 5];
|
|
89
|
+
}>;
|
|
90
|
+
export declare class LikertsError extends Error {
|
|
91
|
+
status: number;
|
|
92
|
+
response: string;
|
|
93
|
+
constructor(status: number, response: string);
|
|
94
|
+
}
|
|
95
|
+
/** Only a public collection credential belongs here. No administrative credentials. */
|
|
96
|
+
export declare class LikertsClient {
|
|
97
|
+
private token;
|
|
98
|
+
private defaultTimeoutMs;
|
|
99
|
+
private cacheMaxAgeMs;
|
|
100
|
+
private baseURL;
|
|
101
|
+
private transport;
|
|
102
|
+
private collectionCache;
|
|
103
|
+
constructor(baseURL: string, token: string, transport?: typeof fetch | undefined, defaultTimeoutMs?: number, cacheMaxAgeMs?: number);
|
|
104
|
+
private request;
|
|
105
|
+
collection(id: string, options?: CollectionRequestOptions): Promise<Collection>;
|
|
106
|
+
clearCollectionCache(id?: string): void;
|
|
107
|
+
/** Retrying requires the same submission object, including its idempotencyKey. */
|
|
108
|
+
submit(id: string, submission: Submission, options?: RequestOptions): Promise<Receipt>;
|
|
109
|
+
}
|
|
110
|
+
export declare function conditionMatches(condition: VisibilityCondition, answer: Answer | undefined): boolean;
|
|
111
|
+
export declare function visibleQuestionIds(questions: Question[], answers: Record<string, Answer>): Set<string>;
|
|
112
|
+
export declare function visibleAnswers(questions: Question[], answers: Record<string, Answer>): Record<string, Answer>;
|
|
113
|
+
export { SurveyFlow, pageRoute, routedAnswers } from './branching';
|
|
114
|
+
export type { BranchingSchema, SurveyProgress } from './branching';
|
|
115
|
+
export { Survey } from './Survey';
|
|
116
|
+
export type { SurveyMessages, SurveyProps, SurveyStyles } from './Survey';
|
|
117
|
+
export { SurveyHost } from './SurveyHost';
|
|
118
|
+
export type { SurveyHostMessages, SurveyHostProps } from './SurveyHost';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const LIKERTS_SDK_CAPABILITY = Object.freeze({ target: 'react_native', sdkVersion: '0.0.3', schemaVersions: [1, 2, 3, 4, 5] });
|
|
2
|
+
export class LikertsError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
response;
|
|
5
|
+
constructor(status, response) {
|
|
6
|
+
super(`Likerts request failed (${status})`);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.response = response;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function safeBaseURL(value) {
|
|
12
|
+
const url = new URL(value);
|
|
13
|
+
const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]';
|
|
14
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash)
|
|
15
|
+
throw new TypeError('Likerts base URL must use HTTPS (HTTP is limited to loopback development)');
|
|
16
|
+
return value.replace(/\/$/, '');
|
|
17
|
+
}
|
|
18
|
+
/** Only a public collection credential belongs here. No administrative credentials. */
|
|
19
|
+
export class LikertsClient {
|
|
20
|
+
token;
|
|
21
|
+
defaultTimeoutMs;
|
|
22
|
+
cacheMaxAgeMs;
|
|
23
|
+
baseURL;
|
|
24
|
+
transport;
|
|
25
|
+
collectionCache = new Map();
|
|
26
|
+
constructor(baseURL, token, transport = undefined, defaultTimeoutMs = 15000, cacheMaxAgeMs = 300000) {
|
|
27
|
+
this.token = token;
|
|
28
|
+
this.defaultTimeoutMs = defaultTimeoutMs;
|
|
29
|
+
this.cacheMaxAgeMs = cacheMaxAgeMs;
|
|
30
|
+
this.baseURL = safeBaseURL(baseURL);
|
|
31
|
+
this.transport = transport ?? ((input, init) => globalThis.fetch(input, init));
|
|
32
|
+
if (!Number.isFinite(defaultTimeoutMs) || defaultTimeoutMs <= 0)
|
|
33
|
+
throw new TypeError('timeoutMs must be positive');
|
|
34
|
+
if (!Number.isFinite(cacheMaxAgeMs) || cacheMaxAgeMs < 0)
|
|
35
|
+
throw new TypeError('cacheMaxAgeMs must not be negative');
|
|
36
|
+
}
|
|
37
|
+
async request(path, body, options = {}) {
|
|
38
|
+
const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs;
|
|
39
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
40
|
+
throw new TypeError('timeoutMs must be positive');
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
43
|
+
if (options.signal?.aborted)
|
|
44
|
+
abort();
|
|
45
|
+
else
|
|
46
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
47
|
+
const timer = setTimeout(() => controller.abort(new Error('Likerts request timed out')), timeoutMs);
|
|
48
|
+
try {
|
|
49
|
+
const response = await this.transport(`${this.baseURL}${path}`, { method: body ? 'POST' : 'GET', redirect: 'error', signal: controller.signal, headers: { Authorization: `Bearer ${this.token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
|
50
|
+
if (!response.ok)
|
|
51
|
+
throw new LikertsError(response.status, await response.text());
|
|
52
|
+
return await response.json();
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
options.signal?.removeEventListener('abort', abort);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async collection(id, options = {}) {
|
|
60
|
+
const cached = this.collectionCache.get(id);
|
|
61
|
+
if (!options.refresh && cached && Date.now() - cached.storedAt < this.cacheMaxAgeMs)
|
|
62
|
+
return cached.value;
|
|
63
|
+
try {
|
|
64
|
+
const c = await this.request(`/v1/collections/${encodeURIComponent(id)}`, undefined, options);
|
|
65
|
+
if (![1, 2, 3, 4, 5].includes(c.schema.schemaVersion))
|
|
66
|
+
throw new Error('Unsupported survey schema version');
|
|
67
|
+
if (cached && (cached.value.id !== c.id || cached.value.surveyId !== c.surveyId || cached.value.version !== c.version || cached.value.schema.schemaVersion !== c.schema.schemaVersion)) {
|
|
68
|
+
this.collectionCache.delete(id);
|
|
69
|
+
throw new Error('Collection binding changed');
|
|
70
|
+
}
|
|
71
|
+
this.collectionCache.set(id, { value: c, storedAt: Date.now() });
|
|
72
|
+
return c;
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
this.collectionCache.delete(id);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
clearCollectionCache(id) { if (id)
|
|
80
|
+
this.collectionCache.delete(id);
|
|
81
|
+
else
|
|
82
|
+
this.collectionCache.clear(); }
|
|
83
|
+
/** Retrying requires the same submission object, including its idempotencyKey. */
|
|
84
|
+
async submit(id, submission, options) { const receipt = await this.request(`/v1/collections/${encodeURIComponent(id)}/responses`, submission, options); if (receipt.accepted !== true)
|
|
85
|
+
throw new Error('Invalid Likerts receipt'); return receipt; }
|
|
86
|
+
}
|
|
87
|
+
export function conditionMatches(condition, answer) { const selected = answer && typeof answer === 'object' && !Array.isArray(answer) && 'selected' in answer && Array.isArray(answer.selected) ? answer.selected : undefined; const objectAnswer = answer && typeof answer === 'object' && !Array.isArray(answer); const hasAnswer = answer !== undefined && (typeof answer !== 'string' || answer.trim() !== '') && (!Array.isArray(answer) || answer.length > 0) && (!objectAnswer || (selected ? selected.length > 0 : Object.keys(answer).length > 0)); if (condition.operator === 'answered')
|
|
88
|
+
return hasAnswer; if (condition.operator === 'not_answered')
|
|
89
|
+
return !hasAnswer; if (answer === undefined || answer === '')
|
|
90
|
+
return false; const scalar = selected?.length === 1 ? selected[0] : answer; if (condition.operator === 'equals')
|
|
91
|
+
return scalar === condition.value; if (condition.operator === 'not_equals')
|
|
92
|
+
return scalar !== condition.value; const values = selected ?? (Array.isArray(answer) ? answer : undefined); if (!values)
|
|
93
|
+
return false; if (condition.operator === 'includes')
|
|
94
|
+
return values.includes(String(condition.value)); if (condition.operator === 'not_includes')
|
|
95
|
+
return !values.includes(String(condition.value)); throw new Error('Unsupported visibility operator'); }
|
|
96
|
+
export function visibleQuestionIds(questions, answers) { const byId = new Map(questions.map(q => [q.id, q])); const memo = new Map(); const active = new Set(); const visible = (q) => { const prior = memo.get(q.id); if (prior !== undefined)
|
|
97
|
+
return prior; if (active.has(q.id))
|
|
98
|
+
throw new Error('Conditional visibility cycle'); active.add(q.id); let result = true; if (q.visibleWhen) {
|
|
99
|
+
const source = byId.get(q.visibleWhen.questionId);
|
|
100
|
+
if (!source)
|
|
101
|
+
throw new Error('Conditional visibility references an unknown question');
|
|
102
|
+
result = conditionMatches(q.visibleWhen, visible(source) ? answers[source.id] : undefined);
|
|
103
|
+
} active.delete(q.id); memo.set(q.id, result); return result; }; return new Set(questions.filter(visible).map(q => q.id)); }
|
|
104
|
+
export function visibleAnswers(questions, answers) { const visible = visibleQuestionIds(questions, answers); return Object.fromEntries(Object.entries(answers).filter(([id]) => visible.has(id))); }
|
|
105
|
+
export { SurveyFlow, pageRoute, routedAnswers } from './branching';
|
|
106
|
+
export { Survey } from './Survey';
|
|
107
|
+
export { SurveyHost } from './SurveyHost';
|
package/lib/offline.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Receipt, Submission } from './index';
|
|
2
|
+
export type OfflineReason = 'invalid' | 'conflict' | 'unauthorized' | 'revoked' | 'deleted' | 'expired';
|
|
3
|
+
export type OfflineState = 'pending' | 'blocked' | 'expired_local';
|
|
4
|
+
export interface OfflineLimits {
|
|
5
|
+
maxRecords: number;
|
|
6
|
+
maxBytes: number;
|
|
7
|
+
maxAgeSeconds: number;
|
|
8
|
+
}
|
|
9
|
+
export interface OfflineStatus {
|
|
10
|
+
pending: number;
|
|
11
|
+
blockedByReason: Partial<Record<OfflineReason, number>>;
|
|
12
|
+
expiredLocal: number;
|
|
13
|
+
quarantined: number;
|
|
14
|
+
bytes: number;
|
|
15
|
+
}
|
|
16
|
+
export interface OfflineOutcome {
|
|
17
|
+
recordId: string;
|
|
18
|
+
outcome: 'accepted' | 'retry' | 'blocked' | 'expired_local' | 'quarantined' | 'credential_unavailable';
|
|
19
|
+
reason?: OfflineReason;
|
|
20
|
+
retryAfterSeconds?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface FlushReport {
|
|
23
|
+
attempted: number;
|
|
24
|
+
accepted: number;
|
|
25
|
+
pending: number;
|
|
26
|
+
blocked: number;
|
|
27
|
+
expiredLocal: number;
|
|
28
|
+
quarantined: number;
|
|
29
|
+
cancelled: boolean;
|
|
30
|
+
outcomes: OfflineOutcome[];
|
|
31
|
+
}
|
|
32
|
+
export interface OpaqueQueueStore {
|
|
33
|
+
load(): Promise<Uint8Array[]>;
|
|
34
|
+
replace(records: readonly Uint8Array[]): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export interface QueueCipher {
|
|
37
|
+
seal(cleartext: Uint8Array): Promise<Uint8Array>;
|
|
38
|
+
open(ciphertext: Uint8Array): Promise<Uint8Array>;
|
|
39
|
+
}
|
|
40
|
+
export interface OfflineSendResult {
|
|
41
|
+
status: number;
|
|
42
|
+
receipt?: Receipt;
|
|
43
|
+
retryAfterSeconds?: number;
|
|
44
|
+
}
|
|
45
|
+
export type OfflineSender = (collectionId: string, credential: string, submission: Submission, signal?: AbortSignal) => Promise<OfflineSendResult>;
|
|
46
|
+
export interface NativeOfflineAdapter extends OpaqueQueueStore, QueueCipher {
|
|
47
|
+
readonly secureKeyProfile: 'keychain' | 'android_keystore';
|
|
48
|
+
readonly encryptedStorage: true;
|
|
49
|
+
createRecordId(): string;
|
|
50
|
+
}
|
|
51
|
+
export declare function openNativeOfflineQueue(adapter: NativeOfflineAdapter, sender: OfflineSender, configuration?: Partial<OfflineLimits>): OfflineQueue;
|
|
52
|
+
export declare class OfflineQueue {
|
|
53
|
+
private store;
|
|
54
|
+
private cipher;
|
|
55
|
+
private sender;
|
|
56
|
+
private now;
|
|
57
|
+
private randomId;
|
|
58
|
+
private readonly configuration;
|
|
59
|
+
private flushing;
|
|
60
|
+
constructor(store: OpaqueQueueStore, cipher: QueueCipher, sender: OfflineSender, configuration?: Partial<OfflineLimits>, now?: () => number, randomId?: () => string);
|
|
61
|
+
private read;
|
|
62
|
+
private write;
|
|
63
|
+
enqueue(collectionId: string, submission: Submission): Promise<string>;
|
|
64
|
+
snapshot(): Promise<OfflineStatus>;
|
|
65
|
+
delete(recordId: string): Promise<void>;
|
|
66
|
+
deleteCollection(collectionId: string): Promise<number>;
|
|
67
|
+
purgeQuarantined(): Promise<number>;
|
|
68
|
+
flush(resolveCredential: (collectionId: string) => Promise<string | undefined>, signal?: AbortSignal): Promise<FlushReport>;
|
|
69
|
+
}
|
package/lib/offline.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const DEFAULTS = { maxRecords: 1000, maxBytes: 10 * 1024 * 1024, maxAgeSeconds: 7 * 86400 }, HARD = { maxRecords: 10000, maxBytes: 100 * 1024 * 1024, maxAgeSeconds: 30 * 86400 }, MAX_RECORD = 65536;
|
|
2
|
+
const encoder = new TextEncoder(), decoder = new TextDecoder();
|
|
3
|
+
const canonical = (value) => JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, JSON.parse(canonical(v))])) : Array.isArray(value) ? value.map(v => JSON.parse(canonical(v))) : value);
|
|
4
|
+
const reason = (status) => ({ 400: 'invalid', 409: 'conflict', 401: 'unauthorized', 403: 'revoked', 404: 'deleted', 410: 'expired' }[status] ?? (status >= 400 && status < 500 && ![408, 425, 429].includes(status) ? 'invalid' : undefined));
|
|
5
|
+
const retryable = (status) => [408, 425, 429].includes(status) || status >= 500;
|
|
6
|
+
const limits = (value) => { const result = { ...DEFAULTS, ...value }; if (!Number.isSafeInteger(result.maxRecords) || result.maxRecords < 1 || result.maxRecords > HARD.maxRecords || !Number.isSafeInteger(result.maxBytes) || result.maxBytes < 1 || result.maxBytes > HARD.maxBytes || !Number.isSafeInteger(result.maxAgeSeconds) || result.maxAgeSeconds < 1 || result.maxAgeSeconds > HARD.maxAgeSeconds)
|
|
7
|
+
throw new RangeError('Offline queue limits exceed hard bounds'); return result; };
|
|
8
|
+
export function openNativeOfflineQueue(adapter, sender, configuration = {}) { if (adapter.encryptedStorage !== true || !['keychain', 'android_keystore'].includes(adapter.secureKeyProfile))
|
|
9
|
+
throw new Error('A native encrypted storage and secure key adapter is required'); return new OfflineQueue(adapter, adapter, sender, configuration, undefined, () => adapter.createRecordId()); }
|
|
10
|
+
export class OfflineQueue {
|
|
11
|
+
store;
|
|
12
|
+
cipher;
|
|
13
|
+
sender;
|
|
14
|
+
now;
|
|
15
|
+
randomId;
|
|
16
|
+
configuration;
|
|
17
|
+
flushing = false;
|
|
18
|
+
constructor(store, cipher, sender, configuration = {}, now = () => Date.now(), randomId = () => { throw new Error('A native secure random record ID adapter is required'); }) {
|
|
19
|
+
this.store = store;
|
|
20
|
+
this.cipher = cipher;
|
|
21
|
+
this.sender = sender;
|
|
22
|
+
this.now = now;
|
|
23
|
+
this.randomId = randomId;
|
|
24
|
+
this.configuration = limits(configuration);
|
|
25
|
+
}
|
|
26
|
+
async read() { const good = [], bad = []; for (const ciphertext of await this.store.load()) {
|
|
27
|
+
try {
|
|
28
|
+
good.push({ record: JSON.parse(decoder.decode(await this.cipher.open(ciphertext))), ciphertext });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
bad.push(ciphertext);
|
|
32
|
+
}
|
|
33
|
+
} return { good, bad }; }
|
|
34
|
+
async write(records, bad) { await this.store.replace([...bad, ...await Promise.all(records.map(r => this.cipher.seal(encoder.encode(canonical(r)))))]); }
|
|
35
|
+
async enqueue(collectionId, submission) { if (!collectionId || !submission.idempotencyKey)
|
|
36
|
+
throw new TypeError('collectionId and idempotencyKey are required'); const submissionText = canonical(submission), byteSize = encoder.encode(submissionText).byteLength; if (byteSize > MAX_RECORD)
|
|
37
|
+
throw new RangeError('Offline record exceeds 64 KiB'); const { good, bad } = await this.read(), records = good.map(v => v.record), prior = records.find(r => JSON.parse(r.submissionText).idempotencyKey === submission.idempotencyKey); if (prior) {
|
|
38
|
+
if (prior.collectionId !== collectionId || prior.submissionText !== submissionText)
|
|
39
|
+
throw new Error('Offline idempotency conflict');
|
|
40
|
+
return prior.id;
|
|
41
|
+
} if (records.length >= this.configuration.maxRecords || records.reduce((sum, r) => sum + r.byteSize, 0) + byteSize > this.configuration.maxBytes)
|
|
42
|
+
throw new RangeError('Offline queue capacity exceeded'); const record = { id: this.randomId(), collectionId, submissionText, createdAt: this.now(), byteSize, attemptCount: 0, state: 'pending' }; await this.write([...records, record], bad); return record.id; }
|
|
43
|
+
async snapshot() { const { good, bad } = await this.read(), status = { pending: 0, blockedByReason: {}, expiredLocal: 0, quarantined: bad.length, bytes: 0 }; for (const { record: r } of good) {
|
|
44
|
+
status.bytes += r.byteSize;
|
|
45
|
+
if (r.state === 'pending')
|
|
46
|
+
status.pending++;
|
|
47
|
+
else if (r.state === 'expired_local')
|
|
48
|
+
status.expiredLocal++;
|
|
49
|
+
else if (r.reason)
|
|
50
|
+
status.blockedByReason[r.reason] = (status.blockedByReason[r.reason] ?? 0) + 1;
|
|
51
|
+
} return status; }
|
|
52
|
+
async delete(recordId) { const { good, bad } = await this.read(); await this.write(good.map(v => v.record).filter(r => r.id !== recordId), bad); }
|
|
53
|
+
async deleteCollection(collectionId) { const { good, bad } = await this.read(), kept = good.map(v => v.record).filter(r => r.collectionId !== collectionId); const count = good.length - kept.length; await this.write(kept, bad); return count; }
|
|
54
|
+
async purgeQuarantined() { const { good, bad } = await this.read(); await this.write(good.map(v => v.record), []); return bad.length; }
|
|
55
|
+
async flush(resolveCredential, signal) { if (this.flushing)
|
|
56
|
+
throw new Error('Offline flush already active'); this.flushing = true; const report = { attempted: 0, accepted: 0, pending: 0, blocked: 0, expiredLocal: 0, quarantined: 0, cancelled: false, outcomes: [] }; try {
|
|
57
|
+
const initial = await this.read(), initialRecords = initial.good.map(v => v.record);
|
|
58
|
+
let expiredChanged = false;
|
|
59
|
+
for (const record of initialRecords) {
|
|
60
|
+
if (record.state !== 'expired_local' && this.now() - record.createdAt > this.configuration.maxAgeSeconds * 1000) {
|
|
61
|
+
record.state = 'expired_local';
|
|
62
|
+
delete record.reason;
|
|
63
|
+
expiredChanged = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (expiredChanged)
|
|
67
|
+
await this.write(initialRecords, initial.bad);
|
|
68
|
+
const ids = initialRecords.filter(r => r.state === 'pending').sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map(r => r.id);
|
|
69
|
+
for (const id of ids) {
|
|
70
|
+
if (signal?.aborted) {
|
|
71
|
+
report.cancelled = true;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
const current = await this.read(), records = current.good.map(v => v.record), record = records.find(r => r.id === id);
|
|
75
|
+
if (!record || record.state !== 'pending')
|
|
76
|
+
continue;
|
|
77
|
+
const credential = await resolveCredential(record.collectionId);
|
|
78
|
+
if (!credential) {
|
|
79
|
+
report.pending++;
|
|
80
|
+
report.outcomes.push({ recordId: id, outcome: 'credential_unavailable' });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
report.attempted++;
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
result = await this.sender(record.collectionId, credential, JSON.parse(record.submissionText), signal);
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
const fresh = await this.read(), updated = fresh.good.map(v => v.record), target = updated.find(r => r.id === id);
|
|
90
|
+
if (!target)
|
|
91
|
+
continue;
|
|
92
|
+
const receipt = result?.receipt;
|
|
93
|
+
if (result && result.status >= 200 && result.status < 300 && receipt?.accepted === true && receipt.collectionId === target.collectionId && receipt.responseId.length > 0) {
|
|
94
|
+
await this.write(updated.filter(r => r.id !== id), fresh.bad);
|
|
95
|
+
report.accepted++;
|
|
96
|
+
report.outcomes.push({ recordId: id, outcome: 'accepted' });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
target.attemptCount++;
|
|
100
|
+
const terminal = result && reason(result.status);
|
|
101
|
+
if (terminal) {
|
|
102
|
+
target.state = 'blocked';
|
|
103
|
+
target.reason = terminal;
|
|
104
|
+
report.blocked++;
|
|
105
|
+
report.outcomes.push({ recordId: id, outcome: 'blocked', reason: terminal });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
report.pending++;
|
|
109
|
+
report.outcomes.push({ recordId: id, outcome: 'retry', ...(result?.retryAfterSeconds !== undefined && retryable(result.status) ? { retryAfterSeconds: Math.max(0, Math.min(86400, Math.floor(result.retryAfterSeconds))) } : {}) });
|
|
110
|
+
}
|
|
111
|
+
await this.write(updated, fresh.bad);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
this.flushing = false;
|
|
116
|
+
} const final = await this.snapshot(); report.pending = final.pending; report.quarantined = final.quarantined; report.expiredLocal = final.expiredLocal; report.blocked = Object.values(final.blockedByReason).reduce((a, b) => a + (b ?? 0), 0); return report; }
|
|
117
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@likerts/react-native",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public",
|
|
7
|
+
"registry": "https://registry.npmjs.org/"
|
|
8
|
+
},
|
|
9
|
+
"main": "lib/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"check": "tsc --noEmit",
|
|
12
|
+
"test": "jest --runInBand",
|
|
13
|
+
"build": "tsc -p tsconfig.build.json",
|
|
14
|
+
"prepack": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"react": "^19.2.3",
|
|
18
|
+
"react-native": ">=0.85 <0.88"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@babel/core": "^7.28.0",
|
|
22
|
+
"@react-native/babel-preset": "0.86.3",
|
|
23
|
+
"@react-native/jest-preset": "0.86.3",
|
|
24
|
+
"@types/jest": "^29.5.14",
|
|
25
|
+
"@types/react": "^19.2.0",
|
|
26
|
+
"@types/react-test-renderer": "^19.1.0",
|
|
27
|
+
"babel-jest": "^29.7.0",
|
|
28
|
+
"jest": "^29.7.0",
|
|
29
|
+
"react": "19.2.3",
|
|
30
|
+
"react-native": "0.86.3",
|
|
31
|
+
"react-test-renderer": "19.2.3",
|
|
32
|
+
"typescript": "^5.9.3"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"files": [
|
|
36
|
+
"lib",
|
|
37
|
+
"src",
|
|
38
|
+
"examples",
|
|
39
|
+
"README.md",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"types": "lib/index.d.ts",
|
|
44
|
+
"react-native": "src/index.ts",
|
|
45
|
+
"exports": {
|
|
46
|
+
".": {
|
|
47
|
+
"types": "./lib/index.d.ts",
|
|
48
|
+
"react-native": "./src/index.ts",
|
|
49
|
+
"default": "./lib/index.js"
|
|
50
|
+
},
|
|
51
|
+
"./offline": {
|
|
52
|
+
"types": "./lib/offline.d.ts",
|
|
53
|
+
"react-native": "./src/offline.ts",
|
|
54
|
+
"default": "./lib/offline.js"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"description": "In-product survey collection for React Native with Likerts.",
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "git+https://github.com/crosstabs/likerts.git",
|
|
61
|
+
"directory": "sdks/react-native"
|
|
62
|
+
},
|
|
63
|
+
"homepage": "https://likerts.com/docs",
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/crosstabs/likerts/issues"
|
|
66
|
+
},
|
|
67
|
+
"engines": {
|
|
68
|
+
"node": ">=22"
|
|
69
|
+
},
|
|
70
|
+
"keywords": [
|
|
71
|
+
"likerts",
|
|
72
|
+
"surveys",
|
|
73
|
+
"feedback",
|
|
74
|
+
"react-native"
|
|
75
|
+
]
|
|
76
|
+
}
|
package/src/Survey.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {selectedChoices, otherTexts, toggleChoice, setOtherText, choiceError} from './choice-features';
|
|
2
|
+
import React, {useEffect, useState} from 'react';
|
|
3
|
+
import {AccessibilityInfo, Pressable, StyleProp, Text, TextInput, TextStyle, View, ViewStyle} from 'react-native';
|
|
4
|
+
import {visibleAnswers,visibleQuestionIds} from './index';
|
|
5
|
+
import {pageRoute,routedAnswers} from './branching';
|
|
6
|
+
import {advancedAnswerError,allocationRemaining,moveRanking,rankingOrder,setAllocation,setMatrixChoice} from './advanced-questions';
|
|
7
|
+
import type {Answer, Collection} from './index';
|
|
8
|
+
|
|
9
|
+
export interface SurveyMessages {requiredSuffix:string;requiredError:string;selectionRangeError:string;selectionLimitHint:string;datePlaceholder:string;back:string;next:string;progress:string;submit:string;submitting:string;selectPlaceholder:string;otherError:string;moveUp:string;moveDown:string;remaining:string;advancedError:string}
|
|
10
|
+
export interface SurveyStyles {container?:StyleProp<ViewStyle>;title?:StyleProp<TextStyle>;question?:StyleProp<ViewStyle>;label?:StyleProp<TextStyle>;choice?:StyleProp<ViewStyle>;choiceText?:StyleProp<TextStyle>;input?:StyleProp<TextStyle>;error?:StyleProp<TextStyle>;submit?:StyleProp<ViewStyle>;submitText?:StyleProp<TextStyle>}
|
|
11
|
+
export interface SurveyProps {collection:Collection;onSubmit:(answers:Record<string,Answer>)=>void;disabled?:boolean;submitting?:boolean;initialAnswers?:Record<string,Answer>;onAnswersChange?:(answers:Record<string,Answer>)=>void;onValidationError?:(message:string)=>void;messages?:Partial<SurveyMessages>;styles?:SurveyStyles;testID?:string}
|
|
12
|
+
|
|
13
|
+
const defaults:SurveyMessages={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.'};
|
|
14
|
+
const format=(template:string,values:Record<string,string|number>)=>Object.entries(values).reduce((result,[key,value])=>result.replace(`{${key}}`,String(value)),template);
|
|
15
|
+
|
|
16
|
+
/** Host owns network lifecycle, placement and dismissal; renderer checks selection bounds. */
|
|
17
|
+
export function Survey({collection,onSubmit,disabled=false,submitting=false,initialAnswers={},onAnswersChange,onValidationError,messages:overrides,styles={},testID='likerts-survey'}:SurveyProps) {
|
|
18
|
+
const messages={...defaults};for(const key of Object.keys(defaults) as (keyof SurveyMessages)[]){const value=overrides?.[key];if(typeof value==='string')messages[key]=value}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<Record<string,Answer>>(()=>routedAnswers(collection.schema,{...initialAnswers}));const [error,setError]=useState('');const [openDropdowns,setOpenDropdowns]=useState<Record<string,boolean>>({});const [currentPage,setCurrentPage]=useState(0);const [history,setHistory]=useState([0]);
|
|
21
|
+
if (![1,2,3,4,5].includes(collection.schema.schemaVersion)) throw new Error('Unsupported survey schema version');
|
|
22
|
+
useEffect(()=>{const reset=routedAnswers(collection.schema,{...initialAnswers});setAnswers(reset);setCurrentPage(0);setHistory([0]);setOpenDropdowns({});setError('');onAnswersChange?.(reset);},[collection.id,collection.version]);
|
|
23
|
+
useEffect(()=>{if(error)AccessibilityInfo.announceForAccessibility(error);},[error]);
|
|
24
|
+
const update=(id:string,value:Answer|((current:Answer|undefined)=>Answer))=>{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)){let shared=route[0]??0;for(let i=0;i<Math.min(history.length,route.length)&&history[i]===route[i];i++)shared=route[i];setCurrentPage(shared);setHistory(route.slice(0,route.indexOf(shared)+1));}onAnswersChange?.(next);return next});setError('')};
|
|
25
|
+
const fail=(message:string)=>{setError(message);onValidationError?.(message)};
|
|
26
|
+
const visible=visibleQuestionIds(collection.schema.questions,answers),currentQuestions=new Set(pages[currentPage]?.questionIds??[]),route=pageRoute(collection.schema,answers),routeIndex=route.indexOf(currentPage);
|
|
27
|
+
const advance=()=>{const result:Record<string,Answer>={};for(const q of collection.schema.questions){if(!visible.has(q.id)||!currentQuestions.has(q.id))continue;const value=answers[q.id];if(q.type==='ranking'||q.type==='matrix'||q.type==='constant_sum'){const code=advancedAnswerError(q,value);if(code){fail(format(code==='required'?messages.requiredError:messages.advancedError,{question:q.label}));return}if(value!==undefined)result[q.id]=value;continue}const isChoice=q.type==='single_choice'||q.type==='multiple_choice';if(value===undefined||value===''||(isChoice&&selectedChoices(value).length===0)||(Array.isArray(value)&&value.length===0)){if(q.required){fail(format(messages.requiredError,{question:q.label}));return}continue}if(isChoice){const code=choiceError(q,value);if(code){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}));return}}result[q.id]=q.type==='number'||q.type==='scale'?Number(value):value}if(routeIndex+1<route.length){const next=route[routeIndex+1];setCurrentPage(next);setHistory(route.slice(0,routeIndex+2));return}onSubmit(routedAnswers(collection.schema,{...answers,...result}))};
|
|
28
|
+
return <View style={styles.container} testID={testID} accessibilityLabel={collection.schema.title}>
|
|
29
|
+
<Text style={styles.title} accessibilityRole="header" testID={`${testID}-title`}>{collection.schema.title}</Text>
|
|
30
|
+
{collection.schema.pages?<Text testID={`${testID}-progress`}>{format(messages.progress,{current:currentPage+1,total:pages.length})}</Text>:null}
|
|
31
|
+
{collection.schema.questions.filter(q=>visible.has(q.id)&¤tQuestions.has(q.id)).map(q=>{
|
|
32
|
+
const labelId=`${testID}-${q.id}-label`;const questionLabel=`${q.label}${q.required?`, ${messages.requiredSuffix}`:''}`;
|
|
33
|
+
return <View key={q.id} style={styles.question} testID={`${testID}-question-${q.id}`}>
|
|
34
|
+
<Text style={styles.label} nativeID={labelId}>{q.label}{q.required?` (${messages.requiredSuffix})`:''}</Text>
|
|
35
|
+
{q.type==='ranking'?rankingOrder(q,answers[q.id]).map((id,index)=>{const option=q.options?.find(value=>value.id===id)!;return <View key={id}><Text style={styles.choiceText}>{index+1}. {option.label}</Text><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))}><Text>{messages.moveUp}</Text></Pressable><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))}><Text>{messages.moveDown}</Text></Pressable></View>})
|
|
36
|
+
:q.type==='matrix'?(q.rows??[]).map(row=><View key={row.id}><Text style={styles.choiceText}>{row.label}</Text>{(q.columns??[]).map(column=>{const value=(answers[q.id] as Record<string,string|string[]>|undefined)?.[row.id],selected=Array.isArray(value)?value.includes(column.id):value===column.id;return <Pressable key={column.id} 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))}><Text>{selected?'✓ ':''}{column.label}</Text></Pressable>})}</View>)
|
|
37
|
+
:q.type==='constant_sum'?<>{(q.items??[]).map(item=><TextInput key={item.id} style={styles.input} testID={`${testID}-${q.id}-${item.id}`} accessibilityLabel={`${q.label}: ${item.label}`} keyboardType="number-pad" editable={!blocked} value={String((answers[q.id] as Record<string,number>|undefined)?.[item.id]??'')} onChangeText={value=>update(q.id,current=>setAllocation(current,item.id,Number(value)))}/>)}<Text accessibilityLiveRegion="polite" testID={`${testID}-${q.id}-remaining`}>{format(messages.remaining,{remaining:allocationRemaining(q,answers[q.id])})}</Text></>
|
|
38
|
+
:<>{q.presentation==='dropdown'?<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]}))}><Text style={styles.choiceText}>{q.options?.find(o=>o.id===selectedChoices(answers[q.id])[0])?.label??messages.selectPlaceholder}</Text></Pressable>:null}
|
|
39
|
+
{q.type==='single_choice'||q.type==='multiple_choice'?(q.presentation==='dropdown'&&!openDropdowns[q.id]?[]:q.options??[]).map(option=>{
|
|
40
|
+
const current=selectedChoices(answers[q.id]),selected=current.includes(option.id);const exclusive=current.some(id=>q.options?.some(o=>o.id===id&&o.exclusive));
|
|
41
|
+
const atLimit=q.type==='multiple_choice'&&!selected&&!option.exclusive&&!exclusive&&q.maxSelections!==undefined&¤t.length>=q.maxSelections;const optionBlocked=blocked||atLimit;
|
|
42
|
+
return <Pressable key={option.id} 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){update(q.id,current=>toggleChoice(q,current,option.id));if(q.presentation==='dropdown')setOpenDropdowns(previous=>({...previous,[q.id]:false}));}}}><Text style={styles.choiceText}>{selected?'✓ ':''}{option.label}</Text></Pressable>;
|
|
43
|
+
})
|
|
44
|
+
: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 <Pressable key={value} 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)update(q.id,value)}}><Text style={styles.choiceText}>{q.presentation==='stars'?'★'.repeat(value):label}</Text></Pressable>})
|
|
45
|
+
:<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)}/>}</>}
|
|
46
|
+
{(q.options??[]).filter(o=>o.other!==undefined&&selectedChoices(answers[q.id]).includes(o.id)).map(option=><TextInput key={option.id} 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))}/>)}
|
|
47
|
+
|
|
48
|
+
</View>})}
|
|
49
|
+
{error?<Text style={styles.error} testID={`${testID}-error`} accessibilityRole="alert" accessibilityLiveRegion="assertive">{error}</Text>:null}
|
|
50
|
+
{collection.schema.pages&&history.length>1?<Pressable style={styles.submit} testID={`${testID}-back`} disabled={blocked} accessibilityRole="button" onPress={()=>{if(!blocked){const next=history.slice(0,-1);setHistory(next);setCurrentPage(next[next.length-1]);setError('')}}}><Text style={styles.submitText}>{messages.back}</Text></Pressable>:null}
|
|
51
|
+
<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)advance()}}><Text style={styles.submitText}>{submitting?messages.submitting:routeIndex+1<route.length?messages.next:messages.submit}</Text></Pressable>
|
|
52
|
+
</View>;
|
|
53
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React, {useEffect, useRef, useState} from 'react';
|
|
2
|
+
import {Text} from 'react-native';
|
|
3
|
+
import type {Answer, Collection, LikertsClient, Receipt, Submission} from './index';
|
|
4
|
+
import {Survey, SurveyProps} from './Survey';
|
|
5
|
+
|
|
6
|
+
export interface SurveyHostMessages {loading:string;loadError:string;submitError:string}
|
|
7
|
+
export interface SurveyHostProps {
|
|
8
|
+
client:LikertsClient;collectionId:string;createIdempotencyKey:()=>string;metadata?:Record<string,unknown>;
|
|
9
|
+
onComplete:(receipt:Receipt)=>void;onError?:(error:unknown)=>void;refreshOnMount?:boolean;
|
|
10
|
+
messages?:Partial<SurveyHostMessages>;surveyProps?:Omit<SurveyProps,'collection'|'onSubmit'|'disabled'|'submitting'>;
|
|
11
|
+
}
|
|
12
|
+
const defaults:SurveyHostMessages={loading:'Loading survey…',loadError:'Could not load survey.',submitError:'Could not confirm submission. Try again without changing the answers.'};
|
|
13
|
+
|
|
14
|
+
/** Optional host adapter: owns cancellable loading/submission while Survey remains usable on its own. */
|
|
15
|
+
export function SurveyHost({client,collectionId,createIdempotencyKey,metadata={},onComplete,onError,refreshOnMount=false,messages:overrides,surveyProps={}}:SurveyHostProps){
|
|
16
|
+
const messages={...defaults};for(const key of Object.keys(defaults) as (keyof SurveyHostMessages)[]){const value=overrides?.[key];if(typeof value==='string')messages[key]=value}const [collection,setCollection]=useState<Collection>();const [error,setError]=useState('');const [submitting,setSubmitting]=useState(false);const [completed,setCompleted]=useState(false);
|
|
17
|
+
const pending=useRef<{serialized:string;submission:Submission}|undefined>(undefined);const generation=useRef(0);const activeSubmit=useRef<AbortController|undefined>(undefined);
|
|
18
|
+
useEffect(()=>{const current=++generation.current;const controller=new AbortController();activeSubmit.current?.abort();setCollection(undefined);setError('');setCompleted(false);pending.current=undefined;
|
|
19
|
+
client.collection(collectionId,{refresh:refreshOnMount,signal:controller.signal}).then(value=>{if(generation.current===current)setCollection(value)}).catch(cause=>{if(!controller.signal.aborted&&generation.current===current){setError(messages.loadError);onError?.(cause)}});
|
|
20
|
+
return()=>{generation.current++;controller.abort();activeSubmit.current?.abort()};
|
|
21
|
+
},[client,collectionId,refreshOnMount]);
|
|
22
|
+
const changed=(answers:Record<string,Answer>)=>{if(pending.current?.serialized!==JSON.stringify(answers))pending.current=undefined;surveyProps.onAnswersChange?.(answers)};
|
|
23
|
+
const submit=async(answers:Record<string,Answer>)=>{if(!collection||submitting||completed)return;const serialized=JSON.stringify(answers);if(!pending.current)pending.current={serialized,submission:{idempotencyKey:createIdempotencyKey(),answers:JSON.parse(serialized),metadata:JSON.parse(JSON.stringify(metadata))}};const current=++generation.current;const controller=new AbortController();activeSubmit.current=controller;setSubmitting(true);setError('');
|
|
24
|
+
try{const receipt=await client.submit(collection.id,pending.current.submission,{signal:controller.signal});if(generation.current===current){setCompleted(true);onComplete(receipt)}}
|
|
25
|
+
catch(cause){if(generation.current===current){setError(messages.submitError);onError?.(cause)}}
|
|
26
|
+
finally{if(activeSubmit.current===controller)activeSubmit.current=undefined;if(generation.current===current)setSubmitting(false)}
|
|
27
|
+
};
|
|
28
|
+
if(!collection)return <Text testID="likerts-host-status" accessibilityRole={error?'alert':undefined} accessibilityLiveRegion="polite">{error||messages.loading}</Text>;
|
|
29
|
+
return <><Survey {...surveyProps} collection={collection} onSubmit={submit} onAnswersChange={changed} disabled={completed} submitting={submitting}/>{error?<Text testID="likerts-host-error" accessibilityRole="alert" accessibilityLiveRegion="assertive">{error}</Text>:null}</>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type AdvancedItem={id:string;label:string};export type AdvancedQuestion={type:string;required?:boolean;options?:AdvancedItem[];rows?:AdvancedItem[];columns?:AdvancedItem[];matrixMode?:'single'|'multiple';items?:AdvancedItem[];total?:number};
|
|
2
|
+
export const rankingOrder=(q:AdvancedQuestion,a:unknown):string[]=>Array.isArray(a)?a.filter((id):id is string=>typeof id==='string'):(q.options??[]).map(o=>o.id);
|
|
3
|
+
export function moveRanking(q:AdvancedQuestion,a:unknown,id:string,offset:-1|1){const order=rankingOrder(q,a),from=order.indexOf(id);if(from<0)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:AdvancedQuestion,a:unknown,row:string,column:string):Record<string,string|string[]>{const current=a&&typeof a==='object'&&!Array.isArray(a)?a as Record<string,string|string[]>:{};if(q.matrixMode==='single')return{...current,[row]:column};const selected=Array.isArray(current[row])?current[row] as string[]:[],next=selected.includes(column)?selected.filter(id=>id!==column):[...selected,column],result={...current};if(next.length)result[row]=next;else delete result[row];return result}
|
|
5
|
+
export function setAllocation(a:unknown,item:string,value:number):Record<string,number>{return{...(a&&typeof a==='object'&&!Array.isArray(a)?a as Record<string,number>:{}),[item]:value}}
|
|
6
|
+
export function allocationRemaining(q:AdvancedQuestion,a:unknown){return(q.total??0)-Object.values(a&&typeof a==='object'&&!Array.isArray(a)?a as Record<string,number>:{}).reduce((sum,value)=>sum+(Number.isSafeInteger(value)?value:0),0)}
|
|
7
|
+
export function advancedAnswerError(q:AdvancedQuestion,a:unknown):string|undefined{if(a===undefined)return q.required?'required':undefined;if(q.type==='ranking'){const ids=(q.options??[]).map(o=>o.id),v=Array.isArray(a)?a:[];return v.length===ids.length&&new Set(v).size===v.length&&v.every(id=>ids.includes(id))?undefined:'ranking'}if(!a||typeof a!=='object'||Array.isArray(a))return q.type;if(q.type==='matrix'){const rows=(q.rows??[]).map(o=>o.id),columns=(q.columns??[]).map(o=>o.id),entries=Object.entries(a);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'}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'}
|
package/src/branching.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import {conditionMatches,visibleAnswers} from './index';
|
|
2
|
+
import type {Answer,Question,SurveyPage} from './index';
|
|
3
|
+
export interface BranchingSchema{questions:Question[];pages?:SurveyPage[]}
|
|
4
|
+
export interface SurveyProgress{current:number;total:number;visited:number}
|
|
5
|
+
const pages=(schema:BranchingSchema):SurveyPage[]=>schema.pages??[{id:'survey',questionIds:schema.questions.map(q=>q.id)}];
|
|
6
|
+
export function pageRoute(schema:BranchingSchema,answers:Record<string,Answer>):number[]{const all=pages(schema),ids=new Map(all.map((p,i)=>[p.id,i])),visible=visibleAnswers(schema.questions,answers),available=new Set<string>(),route:number[]=[];let current=0;while(current<all.length){route.push(current);all[current].questionIds.forEach(id=>available.add(id));const branch=all[current].branches?.find(b=>conditionMatches(b.when,available.has(b.when.questionId)?visible[b.when.questionId]:undefined));current=branch?ids.get(branch.goToPageId)??all.length:current+1;}return route}
|
|
7
|
+
export function routedAnswers(schema:BranchingSchema,answers:Record<string,Answer>):Record<string,Answer>{let result=visibleAnswers(schema.questions,answers);const all=pages(schema);for(let pass=0;pass<=schema.questions.length;pass++){const reached=new Set(pageRoute(schema,result).flatMap(i=>all[i].questionIds));const filtered=visibleAnswers(schema.questions,Object.fromEntries(Object.entries(result).filter(([id])=>reached.has(id))));if(Object.keys(filtered).length===Object.keys(result).length)return filtered;result=filtered}throw new Error('Survey route did not stabilize')}
|
|
8
|
+
export class SurveyFlow{private route:number[];private history:number[];private current:number;private values:Record<string,Answer>;constructor(readonly schema:BranchingSchema,initial:Record<string,Answer>={}){this.values=routedAnswers(schema,{...initial});this.route=pageRoute(schema,this.values);this.current=this.route[0]??0;this.history=[this.current]}get page():SurveyPage{return pages(this.schema)[this.current]}get answers():Record<string,Answer>{return {...this.values}}get progress():SurveyProgress{return{current:this.current+1,total:pages(this.schema).length,visited:this.history.length}}setAnswer(id:string,value:Answer|undefined):void{const old=this.history;if(value===undefined)delete this.values[id];else this.values[id]=value;this.values=routedAnswers(this.schema,this.values);this.route=pageRoute(this.schema,this.values);if(!this.route.includes(this.current)){let shared=this.route[0]??0;for(let i=0;i<Math.min(old.length,this.route.length)&&old[i]===this.route[i];i++)shared=this.route[i];this.current=shared}this.history=this.route.slice(0,this.route.indexOf(this.current)+1)}next():boolean{const at=this.route.indexOf(this.current);if(at<0||at+1>=this.route.length)return false;this.current=this.route[at+1];this.history=this.route.slice(0,at+2);return true}back():boolean{if(this.history.length<2)return false;this.history.pop();this.current=this.history[this.history.length-1];return true}}
|