@terreno/ui 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/BooleanField.js +23 -23
  2. package/dist/BooleanField.js.map +1 -1
  3. package/dist/ConsentFormScreen.d.ts +14 -0
  4. package/dist/ConsentFormScreen.js +93 -0
  5. package/dist/ConsentFormScreen.js.map +1 -0
  6. package/dist/ConsentHistory.d.ts +8 -0
  7. package/dist/ConsentHistory.js +70 -0
  8. package/dist/ConsentHistory.js.map +1 -0
  9. package/dist/ConsentNavigator.d.ts +9 -0
  10. package/dist/ConsentNavigator.js +72 -0
  11. package/dist/ConsentNavigator.js.map +1 -0
  12. package/dist/DataTable.js +1 -1
  13. package/dist/DataTable.js.map +1 -1
  14. package/dist/DateTimeActionSheet.js +24 -8
  15. package/dist/DateTimeActionSheet.js.map +1 -1
  16. package/dist/DateTimeField.d.ts +22 -0
  17. package/dist/DateTimeField.js +187 -67
  18. package/dist/DateTimeField.js.map +1 -1
  19. package/dist/DraggableList.d.ts +66 -0
  20. package/dist/DraggableList.js +241 -0
  21. package/dist/DraggableList.js.map +1 -0
  22. package/dist/Link.js +1 -1
  23. package/dist/Link.js.map +1 -1
  24. package/dist/MarkdownEditor.d.ts +12 -0
  25. package/dist/MarkdownEditor.js +12 -0
  26. package/dist/MarkdownEditor.js.map +1 -0
  27. package/dist/MarkdownEditorField.d.ts +1 -0
  28. package/dist/MarkdownEditorField.js +16 -16
  29. package/dist/MarkdownEditorField.js.map +1 -1
  30. package/dist/Modal.js +11 -1
  31. package/dist/Modal.js.map +1 -1
  32. package/dist/PickerSelect.js +10 -0
  33. package/dist/PickerSelect.js.map +1 -1
  34. package/dist/Slider.js +2 -8
  35. package/dist/Slider.js.map +1 -1
  36. package/dist/TerrenoProvider.js +10 -1
  37. package/dist/TerrenoProvider.js.map +1 -1
  38. package/dist/UpgradeRequiredScreen.d.ts +8 -0
  39. package/dist/UpgradeRequiredScreen.js +10 -0
  40. package/dist/UpgradeRequiredScreen.js.map +1 -0
  41. package/dist/generateConsentHistoryPdf.d.ts +2 -0
  42. package/dist/generateConsentHistoryPdf.js +185 -0
  43. package/dist/generateConsentHistoryPdf.js.map +1 -0
  44. package/dist/index.d.ts +9 -0
  45. package/dist/index.js +9 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/useConsentForms.d.ts +29 -0
  48. package/dist/useConsentForms.js +50 -0
  49. package/dist/useConsentForms.js.map +1 -0
  50. package/dist/useConsentHistory.d.ts +31 -0
  51. package/dist/useConsentHistory.js +17 -0
  52. package/dist/useConsentHistory.js.map +1 -0
  53. package/dist/useSubmitConsent.d.ts +12 -0
  54. package/dist/useSubmitConsent.js +23 -0
  55. package/dist/useSubmitConsent.js.map +1 -0
  56. package/package.json +4 -2
  57. package/src/BooleanField.test.tsx +3 -5
  58. package/src/BooleanField.tsx +33 -31
  59. package/src/ConsentFormScreen.tsx +216 -0
  60. package/src/ConsentHistory.tsx +249 -0
  61. package/src/ConsentNavigator.test.tsx +111 -0
  62. package/src/ConsentNavigator.tsx +128 -0
  63. package/src/DataTable.tsx +1 -1
  64. package/src/DateTimeActionSheet.tsx +21 -8
  65. package/src/DateTimeField.tsx +416 -133
  66. package/src/DraggableList.tsx +424 -0
  67. package/src/Link.tsx +1 -1
  68. package/src/MarkdownEditor.tsx +66 -0
  69. package/src/MarkdownEditorField.tsx +32 -28
  70. package/src/Modal.tsx +19 -1
  71. package/src/PickerSelect.tsx +11 -0
  72. package/src/Slider.tsx +2 -1
  73. package/src/TerrenoProvider.tsx +10 -1
  74. package/src/TimezonePicker.test.tsx +9 -1
  75. package/src/UpgradeRequiredScreen.tsx +52 -0
  76. package/src/__snapshots__/BooleanField.test.tsx.snap +167 -203
  77. package/src/__snapshots__/DataTable.test.tsx.snap +0 -114
  78. package/src/__snapshots__/Field.test.tsx.snap +53 -69
  79. package/src/__snapshots__/Link.test.tsx.snap +14 -21
  80. package/src/__snapshots__/Slider.test.tsx.snap +0 -7
  81. package/src/__snapshots__/TimezonePicker.test.tsx.snap +0 -4710
  82. package/src/generateConsentHistoryPdf.ts +211 -0
  83. package/src/index.tsx +9 -1
  84. package/src/useConsentForms.ts +70 -0
  85. package/src/useConsentHistory.ts +40 -0
  86. package/src/useSubmitConsent.ts +35 -0
@@ -1,5 +1,5 @@
1
1
  import {type FC, useCallback, useEffect, useRef} from "react";
2
- import {Animated, TouchableWithoutFeedback, View} from "react-native";
2
+ import {Animated, Pressable, View} from "react-native";
3
3
 
4
4
  import type {BooleanFieldProps} from "./Common";
5
5
  import {FieldHelperText, FieldTitle} from "./fieldElements";
@@ -92,47 +92,49 @@ export const BooleanField: FC<BooleanFieldProps> = ({
92
92
  }}
93
93
  >
94
94
  {Boolean(title) && <FieldTitle text={title!} />}
95
- <TouchableWithoutFeedback aria-role="button" onPress={handleSwitch}>
96
- <View style={{alignItems: "center", flexDirection: "row", justifyContent: "center"}}>
95
+ <Pressable
96
+ aria-role="button"
97
+ onPress={handleSwitch}
98
+ style={{alignItems: "center", flexDirection: "row", justifyContent: "center"}}
99
+ >
100
+ <Animated.View
101
+ style={{
102
+ backgroundColor: disabled ? theme.surface.disabled : interpolatedColorAnimation,
103
+ borderColor: disabled ? theme.surface.disabled : theme.surface.secondaryDark,
104
+ borderRadius: TOUCHABLE_SIZE,
105
+ borderWidth: 1,
106
+ height: TOUCHABLE_SIZE,
107
+ marginHorizontal: variant === "title" ? undefined : OFFSET,
108
+ marginRight: variant === "title" ? OFFSET : undefined,
109
+ width: WIDTH,
110
+ }}
111
+ >
97
112
  <Animated.View
98
113
  style={{
99
- backgroundColor: disabled ? theme.surface.disabled : interpolatedColorAnimation,
100
- borderColor: disabled ? theme.surface.disabled : theme.surface.secondaryDark,
101
- borderRadius: TOUCHABLE_SIZE,
102
- borderWidth: 1,
103
- height: TOUCHABLE_SIZE,
104
- marginHorizontal: variant === "title" ? undefined : OFFSET,
105
- marginRight: variant === "title" ? OFFSET : undefined,
114
+ alignItems: "center",
115
+ flex: 1,
116
+ flexDirection: "row",
117
+ justifyContent: "center",
118
+ left: transformSwitch,
106
119
  width: WIDTH,
107
120
  }}
108
121
  >
109
122
  <Animated.View
110
123
  style={{
111
124
  alignItems: "center",
112
- flex: 1,
113
- flexDirection: "row",
125
+ backgroundColor: theme.surface.base,
126
+ borderColor: disabled ? theme.surface.disabled : theme.surface.secondaryDark,
127
+ borderRadius: 10,
128
+ borderWidth: 1,
129
+ height: TOUCHABLE_SIZE,
114
130
  justifyContent: "center",
115
- left: transformSwitch,
116
- width: WIDTH,
131
+ width: TOUCHABLE_SIZE,
117
132
  }}
118
- >
119
- <Animated.View
120
- style={{
121
- alignItems: "center",
122
- backgroundColor: theme.surface.base,
123
- borderColor: disabled ? theme.surface.disabled : theme.surface.secondaryDark,
124
- borderRadius: 10,
125
- borderWidth: 1,
126
- height: TOUCHABLE_SIZE,
127
- justifyContent: "center",
128
- width: TOUCHABLE_SIZE,
129
- }}
130
- />
131
- </Animated.View>
133
+ />
132
134
  </Animated.View>
133
- {variant === "title" && <Text size="md">{value ? "Yes" : "No"}</Text>}
134
- </View>
135
- </TouchableWithoutFeedback>
135
+ </Animated.View>
136
+ {variant === "title" && <Text size="md">{value ? "Yes" : "No"}</Text>}
137
+ </Pressable>
136
138
  </View>
137
139
  {disabled && disabledHelperText && <FieldHelperText text={disabledHelperText} />}
138
140
  {Boolean(helperText) && <FieldHelperText text={helperText as string} />}
@@ -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
+ });