@terreno/ui 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BooleanField.js +23 -23
- package/dist/BooleanField.js.map +1 -1
- package/dist/ConsentFormScreen.d.ts +14 -0
- package/dist/ConsentFormScreen.js +93 -0
- package/dist/ConsentFormScreen.js.map +1 -0
- package/dist/ConsentHistory.d.ts +8 -0
- package/dist/ConsentHistory.js +70 -0
- package/dist/ConsentHistory.js.map +1 -0
- package/dist/ConsentNavigator.d.ts +9 -0
- package/dist/ConsentNavigator.js +72 -0
- package/dist/ConsentNavigator.js.map +1 -0
- package/dist/DataTable.js +1 -1
- package/dist/DataTable.js.map +1 -1
- package/dist/DateTimeActionSheet.js +22 -6
- package/dist/DateTimeActionSheet.js.map +1 -1
- package/dist/DateTimeField.d.ts +22 -0
- package/dist/DateTimeField.js +187 -67
- package/dist/DateTimeField.js.map +1 -1
- package/dist/DraggableList.d.ts +66 -0
- package/dist/DraggableList.js +241 -0
- package/dist/DraggableList.js.map +1 -0
- package/dist/Link.js +1 -1
- package/dist/Link.js.map +1 -1
- package/dist/MarkdownEditor.d.ts +12 -0
- package/dist/MarkdownEditor.js +12 -0
- package/dist/MarkdownEditor.js.map +1 -0
- package/dist/MarkdownEditorField.d.ts +1 -0
- package/dist/MarkdownEditorField.js +16 -16
- package/dist/MarkdownEditorField.js.map +1 -1
- package/dist/Modal.js +11 -1
- package/dist/Modal.js.map +1 -1
- package/dist/PickerSelect.js +10 -0
- package/dist/PickerSelect.js.map +1 -1
- package/dist/TerrenoProvider.js +10 -1
- package/dist/TerrenoProvider.js.map +1 -1
- package/dist/UpgradeRequiredScreen.d.ts +8 -0
- package/dist/UpgradeRequiredScreen.js +10 -0
- package/dist/UpgradeRequiredScreen.js.map +1 -0
- package/dist/generateConsentHistoryPdf.d.ts +2 -0
- package/dist/generateConsentHistoryPdf.js +185 -0
- package/dist/generateConsentHistoryPdf.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/useConsentForms.d.ts +29 -0
- package/dist/useConsentForms.js +50 -0
- package/dist/useConsentForms.js.map +1 -0
- package/dist/useConsentHistory.d.ts +31 -0
- package/dist/useConsentHistory.js +17 -0
- package/dist/useConsentHistory.js.map +1 -0
- package/dist/useSubmitConsent.d.ts +12 -0
- package/dist/useSubmitConsent.js +23 -0
- package/dist/useSubmitConsent.js.map +1 -0
- package/package.json +4 -2
- package/src/BooleanField.test.tsx +3 -5
- package/src/BooleanField.tsx +33 -31
- package/src/ConsentFormScreen.tsx +216 -0
- package/src/ConsentHistory.tsx +249 -0
- package/src/ConsentNavigator.test.tsx +111 -0
- package/src/ConsentNavigator.tsx +128 -0
- package/src/DataTable.tsx +1 -1
- package/src/DateTimeActionSheet.tsx +19 -6
- package/src/DateTimeField.tsx +416 -133
- package/src/DraggableList.tsx +424 -0
- package/src/Link.tsx +1 -1
- package/src/MarkdownEditor.tsx +66 -0
- package/src/MarkdownEditorField.tsx +32 -28
- package/src/Modal.tsx +19 -1
- package/src/PickerSelect.tsx +11 -0
- package/src/TerrenoProvider.tsx +10 -1
- package/src/TimezonePicker.test.tsx +9 -1
- package/src/UpgradeRequiredScreen.tsx +52 -0
- package/src/__snapshots__/BooleanField.test.tsx.snap +167 -203
- package/src/__snapshots__/DataTable.test.tsx.snap +0 -114
- package/src/__snapshots__/Field.test.tsx.snap +53 -69
- package/src/__snapshots__/Link.test.tsx.snap +14 -21
- package/src/__snapshots__/TimezonePicker.test.tsx.snap +0 -4710
- package/src/generateConsentHistoryPdf.ts +211 -0
- package/src/index.tsx +9 -1
- package/src/useConsentForms.ts +70 -0
- package/src/useConsentHistory.ts +40 -0
- package/src/useSubmitConsent.ts +35 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import React, {useState} from "react";
|
|
2
|
+
import {Pressable, ScrollView} from "react-native";
|
|
3
|
+
|
|
4
|
+
import {Box} from "./Box";
|
|
5
|
+
import {Button} from "./Button";
|
|
6
|
+
import {CheckBox} from "./CheckBox";
|
|
7
|
+
import {MarkdownView} from "./MarkdownView";
|
|
8
|
+
import {Modal} from "./Modal";
|
|
9
|
+
import {Page} from "./Page";
|
|
10
|
+
import {SignatureField} from "./SignatureField";
|
|
11
|
+
import {Text} from "./Text";
|
|
12
|
+
import type {ConsentFormPublic} from "./useConsentForms";
|
|
13
|
+
|
|
14
|
+
interface ConsentFormScreenProps {
|
|
15
|
+
form: ConsentFormPublic;
|
|
16
|
+
isSubmitting?: boolean;
|
|
17
|
+
locale: string;
|
|
18
|
+
onAgree: (data: {checkboxValues: Record<string, boolean>; signature?: string}) => void;
|
|
19
|
+
onDecline?: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const ConsentFormScreen: React.FC<ConsentFormScreenProps> = ({
|
|
23
|
+
form,
|
|
24
|
+
isSubmitting = false,
|
|
25
|
+
locale,
|
|
26
|
+
onAgree,
|
|
27
|
+
onDecline,
|
|
28
|
+
}) => {
|
|
29
|
+
const [checkboxValues, setCheckboxValues] = useState<Record<string, boolean>>({});
|
|
30
|
+
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(!form.requireScrollToBottom);
|
|
31
|
+
const [signatureValue, setSignatureValue] = useState<string | undefined>(undefined);
|
|
32
|
+
const [confirmModalVisible, setConfirmModalVisible] = useState(false);
|
|
33
|
+
const [confirmModalCheckboxIndex, setConfirmModalCheckboxIndex] = useState<number | null>(null);
|
|
34
|
+
const [scrollEnabled, setScrollEnabled] = useState(true);
|
|
35
|
+
const [contentHeight, setContentHeight] = useState(0);
|
|
36
|
+
const [layoutHeight, setLayoutHeight] = useState(0);
|
|
37
|
+
|
|
38
|
+
const content = form.content[locale] ?? form.content[form.defaultLocale] ?? "";
|
|
39
|
+
|
|
40
|
+
const allRequiredCheckboxesChecked = form.checkboxes.every((checkbox, index) => {
|
|
41
|
+
if (!checkbox.required) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
return checkboxValues[index.toString()] === true;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const signatureProvided = !form.captureSignature || Boolean(signatureValue);
|
|
48
|
+
|
|
49
|
+
const canAgree = hasScrolledToBottom && allRequiredCheckboxesChecked && signatureProvided;
|
|
50
|
+
|
|
51
|
+
// Auto-satisfy scroll requirement when content fits within the viewport
|
|
52
|
+
const handleContentSizeChange = (_w: number, h: number) => {
|
|
53
|
+
setContentHeight(h);
|
|
54
|
+
if (!hasScrolledToBottom && h > 0 && layoutHeight > 0 && h <= layoutHeight) {
|
|
55
|
+
setHasScrolledToBottom(true);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleLayout = (event: any) => {
|
|
60
|
+
const h = event.nativeEvent.layout.height;
|
|
61
|
+
setLayoutHeight(h);
|
|
62
|
+
if (!hasScrolledToBottom && contentHeight > 0 && h > 0 && contentHeight <= h) {
|
|
63
|
+
setHasScrolledToBottom(true);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleScroll = (event: any) => {
|
|
68
|
+
if (hasScrolledToBottom) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent;
|
|
72
|
+
const distanceFromBottom = contentSize.height - layoutMeasurement.height - contentOffset.y;
|
|
73
|
+
if (distanceFromBottom <= 20) {
|
|
74
|
+
setHasScrolledToBottom(true);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const handleCheckboxPress = (index: number) => {
|
|
79
|
+
const checkbox = form.checkboxes[index];
|
|
80
|
+
const key = index.toString();
|
|
81
|
+
const currentValue = checkboxValues[key] ?? false;
|
|
82
|
+
|
|
83
|
+
if (checkbox.confirmationPrompt && !currentValue) {
|
|
84
|
+
// Show confirmation modal before toggling on
|
|
85
|
+
setConfirmModalCheckboxIndex(index);
|
|
86
|
+
setConfirmModalVisible(true);
|
|
87
|
+
} else {
|
|
88
|
+
setCheckboxValues((prev) => ({...prev, [key]: !currentValue}));
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleConfirmModalConfirm = () => {
|
|
93
|
+
if (confirmModalCheckboxIndex !== null) {
|
|
94
|
+
const key = confirmModalCheckboxIndex.toString();
|
|
95
|
+
setCheckboxValues((prev) => ({...prev, [key]: true}));
|
|
96
|
+
}
|
|
97
|
+
setConfirmModalVisible(false);
|
|
98
|
+
setConfirmModalCheckboxIndex(null);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleConfirmModalDismiss = () => {
|
|
102
|
+
setConfirmModalVisible(false);
|
|
103
|
+
setConfirmModalCheckboxIndex(null);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handleAgree = () => {
|
|
107
|
+
onAgree({checkboxValues, signature: signatureValue});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const confirmingCheckbox =
|
|
111
|
+
confirmModalCheckboxIndex !== null ? form.checkboxes[confirmModalCheckboxIndex] : null;
|
|
112
|
+
|
|
113
|
+
const footer = (
|
|
114
|
+
<Box direction="column" gap={2} paddingY={2} testID="consent-form-footer" width="100%">
|
|
115
|
+
{Boolean(form.allowDecline && onDecline) && (
|
|
116
|
+
<Box width="100%">
|
|
117
|
+
<Button
|
|
118
|
+
fullWidth
|
|
119
|
+
onClick={onDecline!}
|
|
120
|
+
testID="consent-form-decline-button"
|
|
121
|
+
text={form.declineButtonText}
|
|
122
|
+
variant="muted"
|
|
123
|
+
/>
|
|
124
|
+
</Box>
|
|
125
|
+
)}
|
|
126
|
+
<Box width="100%">
|
|
127
|
+
<Button
|
|
128
|
+
disabled={!canAgree}
|
|
129
|
+
fullWidth
|
|
130
|
+
loading={isSubmitting}
|
|
131
|
+
onClick={handleAgree}
|
|
132
|
+
testID="consent-form-agree-button"
|
|
133
|
+
text={form.agreeButtonText}
|
|
134
|
+
/>
|
|
135
|
+
</Box>
|
|
136
|
+
</Box>
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<Page footer={footer} scroll={false} title={form.title}>
|
|
141
|
+
<ScrollView
|
|
142
|
+
onContentSizeChange={handleContentSizeChange}
|
|
143
|
+
onLayout={handleLayout}
|
|
144
|
+
onScroll={handleScroll}
|
|
145
|
+
scrollEnabled={scrollEnabled}
|
|
146
|
+
scrollEventThrottle={16}
|
|
147
|
+
style={{flex: 1}}
|
|
148
|
+
testID="consent-form-scroll-view"
|
|
149
|
+
>
|
|
150
|
+
<Box direction="column" gap={3} paddingY={2}>
|
|
151
|
+
<MarkdownView>{content}</MarkdownView>
|
|
152
|
+
|
|
153
|
+
{form.checkboxes.length > 0 && (
|
|
154
|
+
<Box direction="column" gap={2} testID="consent-form-checkboxes">
|
|
155
|
+
{form.checkboxes.map((checkbox, index) => {
|
|
156
|
+
const key = index.toString();
|
|
157
|
+
const isChecked = checkboxValues[key] ?? false;
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<Pressable
|
|
161
|
+
key={key}
|
|
162
|
+
onPress={() => handleCheckboxPress(index)}
|
|
163
|
+
testID={`consent-form-checkbox-${index}`}
|
|
164
|
+
>
|
|
165
|
+
<Box alignItems="center" direction="row" gap={2}>
|
|
166
|
+
<CheckBox selected={isChecked} size="md" />
|
|
167
|
+
<Box flex="grow">
|
|
168
|
+
<Text size="md">
|
|
169
|
+
{checkbox.label}
|
|
170
|
+
{checkbox.required && " *"}
|
|
171
|
+
</Text>
|
|
172
|
+
</Box>
|
|
173
|
+
</Box>
|
|
174
|
+
</Pressable>
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
</Box>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{Boolean(form.captureSignature) && (
|
|
181
|
+
<Box direction="column" gap={2} testID="consent-form-signature">
|
|
182
|
+
<SignatureField
|
|
183
|
+
onChange={(value) => setSignatureValue(value)}
|
|
184
|
+
onEnd={() => setScrollEnabled(true)}
|
|
185
|
+
onStart={() => setScrollEnabled(false)}
|
|
186
|
+
title="Signature"
|
|
187
|
+
value={signatureValue}
|
|
188
|
+
/>
|
|
189
|
+
</Box>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{Boolean(form.requireScrollToBottom && !hasScrolledToBottom) && (
|
|
193
|
+
<Box paddingY={2} testID="consent-form-scroll-hint">
|
|
194
|
+
<Text color="secondaryDark" size="sm">
|
|
195
|
+
Please scroll to the bottom to continue.
|
|
196
|
+
</Text>
|
|
197
|
+
</Box>
|
|
198
|
+
)}
|
|
199
|
+
</Box>
|
|
200
|
+
</ScrollView>
|
|
201
|
+
|
|
202
|
+
{confirmingCheckbox && (
|
|
203
|
+
<Modal
|
|
204
|
+
onDismiss={handleConfirmModalDismiss}
|
|
205
|
+
primaryButtonOnClick={handleConfirmModalConfirm}
|
|
206
|
+
primaryButtonText="Confirm"
|
|
207
|
+
secondaryButtonOnClick={handleConfirmModalDismiss}
|
|
208
|
+
secondaryButtonText="Cancel"
|
|
209
|
+
text={confirmingCheckbox.confirmationPrompt}
|
|
210
|
+
title="Please confirm"
|
|
211
|
+
visible={confirmModalVisible}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
</Page>
|
|
215
|
+
);
|
|
216
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import {DateTime} from "luxon";
|
|
2
|
+
import React, {useCallback, useState} from "react";
|
|
3
|
+
import {Image, Pressable} from "react-native";
|
|
4
|
+
|
|
5
|
+
import {Badge} from "./Badge";
|
|
6
|
+
import {Box} from "./Box";
|
|
7
|
+
import {Button} from "./Button";
|
|
8
|
+
import {Card} from "./Card";
|
|
9
|
+
import {generateConsentHistoryPdf} from "./generateConsentHistoryPdf";
|
|
10
|
+
import {Icon} from "./Icon";
|
|
11
|
+
import {IconButton} from "./IconButton";
|
|
12
|
+
import {MarkdownView} from "./MarkdownView";
|
|
13
|
+
import {Page} from "./Page";
|
|
14
|
+
import {Spinner} from "./Spinner";
|
|
15
|
+
import {Text} from "./Text";
|
|
16
|
+
import type {ConsentHistoryEntry} from "./useConsentHistory";
|
|
17
|
+
import {useConsentHistory} from "./useConsentHistory";
|
|
18
|
+
|
|
19
|
+
interface ConsentHistoryProps {
|
|
20
|
+
api: any;
|
|
21
|
+
baseUrl?: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const formatDate = (value: unknown): string => {
|
|
26
|
+
if (!value) {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
const dt = DateTime.fromISO(String(value));
|
|
30
|
+
if (!dt.isValid) {
|
|
31
|
+
return String(value);
|
|
32
|
+
}
|
|
33
|
+
return dt.toLocaleString(DateTime.DATETIME_MED);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const ConsentHistoryItem: React.FC<{entry: ConsentHistoryEntry}> = ({entry}) => {
|
|
37
|
+
const [expanded, setExpanded] = useState(false);
|
|
38
|
+
const [isDownloading, setIsDownloading] = useState(false);
|
|
39
|
+
|
|
40
|
+
const toggle = useCallback(() => {
|
|
41
|
+
setExpanded((prev) => !prev);
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
const handleDownload = useCallback(async () => {
|
|
45
|
+
setIsDownloading(true);
|
|
46
|
+
try {
|
|
47
|
+
await generateConsentHistoryPdf(entry);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error("Failed to generate PDF", err);
|
|
50
|
+
} finally {
|
|
51
|
+
setIsDownloading(false);
|
|
52
|
+
}
|
|
53
|
+
}, [entry]);
|
|
54
|
+
|
|
55
|
+
const formTitle = entry.form?.title ?? "Unknown Form";
|
|
56
|
+
const formType = entry.form?.type ?? "";
|
|
57
|
+
|
|
58
|
+
const checkboxEntries =
|
|
59
|
+
entry.checkboxValues && typeof entry.checkboxValues === "object"
|
|
60
|
+
? Object.entries(entry.checkboxValues)
|
|
61
|
+
: [];
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Card padding={0} testID={`consent-history-item-${entry._id}`}>
|
|
65
|
+
<Pressable onPress={toggle} testID={`consent-history-item-toggle-${entry._id}`}>
|
|
66
|
+
<Box direction="row" gap={3} padding={4}>
|
|
67
|
+
<Box flex="grow" gap={1}>
|
|
68
|
+
<Box alignItems="center" direction="row" gap={2}>
|
|
69
|
+
<Text bold size="md">
|
|
70
|
+
{formTitle}
|
|
71
|
+
</Text>
|
|
72
|
+
{formType ? <Badge status="neutral" value={formType} /> : null}
|
|
73
|
+
</Box>
|
|
74
|
+
<Box alignItems="center" direction="row" gap={2}>
|
|
75
|
+
<Badge
|
|
76
|
+
status={entry.agreed ? "success" : "error"}
|
|
77
|
+
value={entry.agreed ? "Agreed" : "Declined"}
|
|
78
|
+
/>
|
|
79
|
+
<Text color="secondaryDark" size="sm">
|
|
80
|
+
{formatDate(entry.agreedAt)}
|
|
81
|
+
</Text>
|
|
82
|
+
</Box>
|
|
83
|
+
</Box>
|
|
84
|
+
<Box alignItems="center" justifyContent="center">
|
|
85
|
+
<Icon
|
|
86
|
+
color="secondaryDark"
|
|
87
|
+
iconName={expanded ? "chevron-up" : "chevron-down"}
|
|
88
|
+
size="sm"
|
|
89
|
+
/>
|
|
90
|
+
</Box>
|
|
91
|
+
</Box>
|
|
92
|
+
</Pressable>
|
|
93
|
+
|
|
94
|
+
{expanded && (
|
|
95
|
+
<Box gap={3} padding={4} testID={`consent-history-item-details-${entry._id}`}>
|
|
96
|
+
<Box alignItems="center" direction="row" justifyContent="between">
|
|
97
|
+
<Box color="disabled" flex="grow" height={1} />
|
|
98
|
+
<Box marginLeft={2}>
|
|
99
|
+
<IconButton
|
|
100
|
+
accessibilityLabel="Download PDF"
|
|
101
|
+
iconName="download"
|
|
102
|
+
loading={isDownloading}
|
|
103
|
+
onClick={handleDownload}
|
|
104
|
+
testID={`consent-history-download-${entry._id}`}
|
|
105
|
+
tooltipText="Download PDF"
|
|
106
|
+
/>
|
|
107
|
+
</Box>
|
|
108
|
+
</Box>
|
|
109
|
+
|
|
110
|
+
{entry.form?.version !== undefined && (
|
|
111
|
+
<Box alignItems="center" direction="row" gap={2}>
|
|
112
|
+
<Text color="secondaryDark" size="sm">
|
|
113
|
+
Version:
|
|
114
|
+
</Text>
|
|
115
|
+
<Text size="sm">{entry.form.version}</Text>
|
|
116
|
+
</Box>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{entry.locale && (
|
|
120
|
+
<Box alignItems="center" direction="row" gap={2}>
|
|
121
|
+
<Text color="secondaryDark" size="sm">
|
|
122
|
+
Locale:
|
|
123
|
+
</Text>
|
|
124
|
+
<Text size="sm">{entry.locale}</Text>
|
|
125
|
+
</Box>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{entry.signedAt && (
|
|
129
|
+
<Box alignItems="center" direction="row" gap={2}>
|
|
130
|
+
<Text color="secondaryDark" size="sm">
|
|
131
|
+
Signed:
|
|
132
|
+
</Text>
|
|
133
|
+
<Text size="sm">{formatDate(entry.signedAt)}</Text>
|
|
134
|
+
</Box>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{entry.ipAddress && (
|
|
138
|
+
<Box alignItems="center" direction="row" gap={2}>
|
|
139
|
+
<Text color="secondaryDark" size="sm">
|
|
140
|
+
IP Address:
|
|
141
|
+
</Text>
|
|
142
|
+
<Text size="sm">{entry.ipAddress}</Text>
|
|
143
|
+
</Box>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{checkboxEntries.length > 0 && (
|
|
147
|
+
<Box gap={2}>
|
|
148
|
+
<Text bold color="secondaryDark" size="sm">
|
|
149
|
+
Checkboxes
|
|
150
|
+
</Text>
|
|
151
|
+
{checkboxEntries.map(([index, checked]) => {
|
|
152
|
+
const label = entry.form?.checkboxes?.[Number(index)]?.label;
|
|
153
|
+
return (
|
|
154
|
+
<Box alignItems="center" direction="row" gap={2} key={index}>
|
|
155
|
+
<Badge
|
|
156
|
+
status={checked ? "success" : "neutral"}
|
|
157
|
+
value={checked ? "Yes" : "No"}
|
|
158
|
+
/>
|
|
159
|
+
<Text size="sm">{label ?? `Checkbox ${index}`}</Text>
|
|
160
|
+
</Box>
|
|
161
|
+
);
|
|
162
|
+
})}
|
|
163
|
+
</Box>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{entry.signature && (
|
|
167
|
+
<Box gap={2}>
|
|
168
|
+
<Text bold color="secondaryDark" size="sm">
|
|
169
|
+
Signature
|
|
170
|
+
</Text>
|
|
171
|
+
<Box border="default" padding={2} rounding="sm">
|
|
172
|
+
<Image
|
|
173
|
+
accessibilityLabel="Your signature"
|
|
174
|
+
accessible
|
|
175
|
+
resizeMode="contain"
|
|
176
|
+
source={{uri: entry.signature}}
|
|
177
|
+
style={{height: 80, width: "100%"}}
|
|
178
|
+
testID={`consent-history-signature-${entry._id}`}
|
|
179
|
+
/>
|
|
180
|
+
</Box>
|
|
181
|
+
</Box>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{entry.contentSnapshot && (
|
|
185
|
+
<Box gap={2}>
|
|
186
|
+
<Text bold color="secondaryDark" size="sm">
|
|
187
|
+
Form Content
|
|
188
|
+
</Text>
|
|
189
|
+
<Box border="default" padding={3} rounding="sm">
|
|
190
|
+
<MarkdownView>{entry.contentSnapshot}</MarkdownView>
|
|
191
|
+
</Box>
|
|
192
|
+
</Box>
|
|
193
|
+
)}
|
|
194
|
+
</Box>
|
|
195
|
+
)}
|
|
196
|
+
</Card>
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const ConsentHistory: React.FC<ConsentHistoryProps> = ({
|
|
201
|
+
api,
|
|
202
|
+
baseUrl,
|
|
203
|
+
title = "My Consents",
|
|
204
|
+
}) => {
|
|
205
|
+
const {entries, isLoading, error, refetch} = useConsentHistory(api, baseUrl);
|
|
206
|
+
|
|
207
|
+
if (isLoading) {
|
|
208
|
+
return (
|
|
209
|
+
<Page maxWidth={800} title={title}>
|
|
210
|
+
<Box alignItems="center" justifyContent="center" padding={6}>
|
|
211
|
+
<Spinner />
|
|
212
|
+
</Box>
|
|
213
|
+
</Page>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (error) {
|
|
218
|
+
return (
|
|
219
|
+
<Page maxWidth={800} title={title}>
|
|
220
|
+
<Box alignItems="center" direction="column" gap={3} padding={6}>
|
|
221
|
+
<Text color="error" size="lg">
|
|
222
|
+
Failed to load consent history
|
|
223
|
+
</Text>
|
|
224
|
+
<Button onClick={refetch} text="Retry" />
|
|
225
|
+
</Box>
|
|
226
|
+
</Page>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (entries.length === 0) {
|
|
231
|
+
return (
|
|
232
|
+
<Page maxWidth={800} title={title}>
|
|
233
|
+
<Box alignItems="center" padding={6}>
|
|
234
|
+
<Text color="secondaryDark">No consent records found.</Text>
|
|
235
|
+
</Box>
|
|
236
|
+
</Page>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<Page maxWidth={800} scroll title={title}>
|
|
242
|
+
<Box gap={3} padding={4} testID="consent-history-list">
|
|
243
|
+
{entries.map((entry) => (
|
|
244
|
+
<ConsentHistoryItem entry={entry} key={entry._id} />
|
|
245
|
+
))}
|
|
246
|
+
</Box>
|
|
247
|
+
</Page>
|
|
248
|
+
);
|
|
249
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {describe, expect, it, mock} from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import {ConsentNavigator} from "./ConsentNavigator";
|
|
4
|
+
import {Text} from "./Text";
|
|
5
|
+
import {renderWithTheme} from "./test-utils";
|
|
6
|
+
import type {ConsentFormPublic} from "./useConsentForms";
|
|
7
|
+
|
|
8
|
+
const makeForm = (overrides: Partial<ConsentFormPublic> = {}): ConsentFormPublic => ({
|
|
9
|
+
active: true,
|
|
10
|
+
agreeButtonText: "I Agree",
|
|
11
|
+
allowDecline: false,
|
|
12
|
+
captureSignature: false,
|
|
13
|
+
checkboxes: [],
|
|
14
|
+
content: {en: "# Terms\n\nPlease read these terms."},
|
|
15
|
+
declineButtonText: "Decline",
|
|
16
|
+
defaultLocale: "en",
|
|
17
|
+
id: "form-1",
|
|
18
|
+
order: 1,
|
|
19
|
+
required: true,
|
|
20
|
+
requireScrollToBottom: false,
|
|
21
|
+
slug: "tos",
|
|
22
|
+
title: "Terms of Service",
|
|
23
|
+
type: "terms",
|
|
24
|
+
version: 1,
|
|
25
|
+
...overrides,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const createMockApi = (forms: ConsentFormPublic[]) => {
|
|
29
|
+
const mockSubmitMutation = mock(() => Promise.resolve({data: {agreed: true, id: "response-1"}}));
|
|
30
|
+
const mockUnwrap = mock(() => Promise.resolve({agreed: true, id: "response-1"}));
|
|
31
|
+
mockSubmitMutation.mockReturnValue({unwrap: mockUnwrap});
|
|
32
|
+
|
|
33
|
+
const innerApi = {
|
|
34
|
+
injectEndpoints: mock((_config: any) => ({
|
|
35
|
+
useGetPendingConsentsQuery: mock(() => ({
|
|
36
|
+
data: {data: forms},
|
|
37
|
+
error: undefined,
|
|
38
|
+
isLoading: false,
|
|
39
|
+
refetch: mock(() => Promise.resolve()),
|
|
40
|
+
})),
|
|
41
|
+
useSubmitConsentResponseMutation: mock(() => [
|
|
42
|
+
mockSubmitMutation,
|
|
43
|
+
{error: undefined, isLoading: false},
|
|
44
|
+
]),
|
|
45
|
+
})),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
enhanceEndpoints: mock((_config: any) => innerApi),
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const createLoadingMockApi = () => {
|
|
54
|
+
const innerApi = {
|
|
55
|
+
injectEndpoints: mock((_config: any) => ({
|
|
56
|
+
useGetPendingConsentsQuery: mock(() => ({
|
|
57
|
+
data: undefined,
|
|
58
|
+
error: undefined,
|
|
59
|
+
isLoading: true,
|
|
60
|
+
refetch: mock(() => Promise.resolve()),
|
|
61
|
+
})),
|
|
62
|
+
useSubmitConsentResponseMutation: mock(() => [
|
|
63
|
+
mock(() => ({unwrap: mock(() => Promise.resolve({}))})),
|
|
64
|
+
{error: undefined, isLoading: false},
|
|
65
|
+
]),
|
|
66
|
+
})),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
enhanceEndpoints: mock((_config: any) => innerApi),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe("ConsentNavigator", () => {
|
|
75
|
+
it("renders children when there are no pending consent forms", async () => {
|
|
76
|
+
const api = createMockApi([]);
|
|
77
|
+
const {getByText} = renderWithTheme(
|
|
78
|
+
<ConsentNavigator api={api}>
|
|
79
|
+
<Text>App Content</Text>
|
|
80
|
+
</ConsentNavigator>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(getByText("App Content")).toBeTruthy();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("shows ConsentFormScreen when there are pending forms", async () => {
|
|
87
|
+
const form = makeForm();
|
|
88
|
+
const api = createMockApi([form]);
|
|
89
|
+
|
|
90
|
+
const {getByTestId} = renderWithTheme(
|
|
91
|
+
<ConsentNavigator api={api}>
|
|
92
|
+
<Text>App Content</Text>
|
|
93
|
+
</ConsentNavigator>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// The agree button is rendered inside ConsentFormScreen
|
|
97
|
+
expect(getByTestId("consent-form-agree-button")).toBeTruthy();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("shows loading spinner while fetching consent forms", async () => {
|
|
101
|
+
const api = createLoadingMockApi();
|
|
102
|
+
|
|
103
|
+
const {getByTestId} = renderWithTheme(
|
|
104
|
+
<ConsentNavigator api={api}>
|
|
105
|
+
<Text>App Content</Text>
|
|
106
|
+
</ConsentNavigator>
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(getByTestId("consent-navigator-loading")).toBeTruthy();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React, {useState} from "react";
|
|
2
|
+
|
|
3
|
+
import {Box} from "./Box";
|
|
4
|
+
import {Button} from "./Button";
|
|
5
|
+
import {ConsentFormScreen} from "./ConsentFormScreen";
|
|
6
|
+
import {Spinner} from "./Spinner";
|
|
7
|
+
import {Text} from "./Text";
|
|
8
|
+
import {detectLocale, useConsentForms} from "./useConsentForms";
|
|
9
|
+
import type {SubmitConsentBody} from "./useSubmitConsent";
|
|
10
|
+
import {useSubmitConsent} from "./useSubmitConsent";
|
|
11
|
+
|
|
12
|
+
interface ConsentNavigatorProps {
|
|
13
|
+
api: any;
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
onError?: (error: any) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const ConsentNavigator: React.FC<ConsentNavigatorProps> = ({
|
|
20
|
+
api,
|
|
21
|
+
baseUrl,
|
|
22
|
+
children,
|
|
23
|
+
onError,
|
|
24
|
+
}) => {
|
|
25
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
26
|
+
const {forms, isLoading, error, refetch} = useConsentForms(api, baseUrl);
|
|
27
|
+
const {submit, isSubmitting} = useSubmitConsent(api, baseUrl);
|
|
28
|
+
const locale = detectLocale();
|
|
29
|
+
|
|
30
|
+
if (isLoading) {
|
|
31
|
+
console.debug("[ConsentNavigator] Loading pending consents...");
|
|
32
|
+
return (
|
|
33
|
+
<Box
|
|
34
|
+
alignItems="center"
|
|
35
|
+
flex="grow"
|
|
36
|
+
justifyContent="center"
|
|
37
|
+
testID="consent-navigator-loading"
|
|
38
|
+
>
|
|
39
|
+
<Spinner />
|
|
40
|
+
</Box>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (error) {
|
|
45
|
+
const status = (error as any)?.status ?? (error as any)?.originalStatus;
|
|
46
|
+
console.warn("[ConsentNavigator] Error fetching pending consents:", {error, status});
|
|
47
|
+
// On auth errors, pass through to let the app handle re-authentication
|
|
48
|
+
if (status === 401 || status === 403) {
|
|
49
|
+
return <>{children}</>;
|
|
50
|
+
}
|
|
51
|
+
onError?.(error);
|
|
52
|
+
return (
|
|
53
|
+
<Box
|
|
54
|
+
alignItems="center"
|
|
55
|
+
direction="column"
|
|
56
|
+
flex="grow"
|
|
57
|
+
gap={3}
|
|
58
|
+
justifyContent="center"
|
|
59
|
+
padding={6}
|
|
60
|
+
testID="consent-navigator-error"
|
|
61
|
+
>
|
|
62
|
+
<Text align="center" color="error" size="lg">
|
|
63
|
+
Failed to load consent forms
|
|
64
|
+
</Text>
|
|
65
|
+
<Button onClick={refetch} text="Retry" />
|
|
66
|
+
</Box>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (forms.length === 0 || currentIndex >= forms.length) {
|
|
71
|
+
console.debug("[ConsentNavigator] No pending consents, showing app");
|
|
72
|
+
return <>{children}</>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.info(
|
|
76
|
+
`[ConsentNavigator] Showing consent form ${currentIndex + 1}/${forms.length}: ${forms[currentIndex]?.title}`
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const currentForm = forms[currentIndex];
|
|
80
|
+
|
|
81
|
+
const handleAgree = async (data: {
|
|
82
|
+
checkboxValues: Record<string, boolean>;
|
|
83
|
+
signature?: string;
|
|
84
|
+
}) => {
|
|
85
|
+
const body: SubmitConsentBody = {
|
|
86
|
+
agreed: true,
|
|
87
|
+
checkboxValues: data.checkboxValues,
|
|
88
|
+
consentFormId: currentForm.id,
|
|
89
|
+
locale,
|
|
90
|
+
signature: data.signature,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await submit(body);
|
|
95
|
+
// Always refetch and reset so we pick up the updated pending list.
|
|
96
|
+
// Advancing currentIndex is racy because invalidatesTags shrinks
|
|
97
|
+
// the forms array in the background.
|
|
98
|
+
setCurrentIndex(0);
|
|
99
|
+
await refetch();
|
|
100
|
+
} catch (err) {
|
|
101
|
+
onError?.(err);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleDecline = async () => {
|
|
106
|
+
try {
|
|
107
|
+
await submit({
|
|
108
|
+
agreed: false,
|
|
109
|
+
consentFormId: currentForm.id,
|
|
110
|
+
locale,
|
|
111
|
+
});
|
|
112
|
+
setCurrentIndex(0);
|
|
113
|
+
await refetch();
|
|
114
|
+
} catch (err) {
|
|
115
|
+
onError?.(err);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<ConsentFormScreen
|
|
121
|
+
form={currentForm}
|
|
122
|
+
isSubmitting={isSubmitting}
|
|
123
|
+
locale={locale}
|
|
124
|
+
onAgree={handleAgree}
|
|
125
|
+
onDecline={currentForm.required ? undefined : handleDecline}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
};
|