@terreno/ui 0.11.1 → 0.11.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/dist/Common.d.ts +12 -12
- package/dist/Common.js.map +1 -1
- package/dist/PickerSelect.js +72 -17
- package/dist/PickerSelect.js.map +1 -1
- package/dist/SelectBadge.js +39 -4
- package/dist/SelectBadge.js.map +1 -1
- package/dist/WebDropdownMenu.d.ts +60 -0
- package/dist/WebDropdownMenu.js +63 -0
- package/dist/WebDropdownMenu.js.map +1 -0
- package/package.json +1 -1
- package/src/Common.ts +34 -13
- package/src/Modal.test.tsx +46 -1
- package/src/PickerSelect.tsx +109 -24
- package/src/SelectBadge.tsx +63 -5
- package/src/SelectBadge.web.test.tsx +75 -0
- package/src/WebDropdownMenu.test.tsx +180 -0
- package/src/WebDropdownMenu.tsx +183 -0
- package/src/__snapshots__/SelectBadge.test.tsx.snap +27 -0
- package/src/useConsentForms.test.ts +41 -19
- package/src/useSubmitConsent.test.ts +16 -5
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {describe, expect, it, mock} from "bun:test";
|
|
2
|
+
import {act, fireEvent, renderHook} from "@testing-library/react-native";
|
|
3
|
+
|
|
4
|
+
import {renderWithTheme} from "./test-utils";
|
|
5
|
+
import {useWebDropdownAnchor, WebDropdownMenu} from "./WebDropdownMenu";
|
|
6
|
+
|
|
7
|
+
describe("WebDropdownMenu", () => {
|
|
8
|
+
const anchor = {height: 40, width: 200, x: 16, y: 32};
|
|
9
|
+
const options = [
|
|
10
|
+
{label: "Option A", value: "a"},
|
|
11
|
+
{label: "Option B", value: "b"},
|
|
12
|
+
{label: "Option C", value: "c"},
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
it("marks the underlying Modal hidden when not visible", () => {
|
|
16
|
+
const {getByTestId} = renderWithTheme(
|
|
17
|
+
<WebDropdownMenu
|
|
18
|
+
anchor={anchor}
|
|
19
|
+
onClose={() => {}}
|
|
20
|
+
onSelect={() => {}}
|
|
21
|
+
options={options}
|
|
22
|
+
visible={false}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
expect(getByTestId("web_dropdown_modal").props.visible).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("marks the underlying Modal visible when open", () => {
|
|
29
|
+
const {getByTestId} = renderWithTheme(
|
|
30
|
+
<WebDropdownMenu
|
|
31
|
+
anchor={anchor}
|
|
32
|
+
onClose={() => {}}
|
|
33
|
+
onSelect={() => {}}
|
|
34
|
+
options={options}
|
|
35
|
+
visible
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
expect(getByTestId("web_dropdown_modal").props.visible).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders every option when visible", () => {
|
|
42
|
+
const {getByText} = renderWithTheme(
|
|
43
|
+
<WebDropdownMenu
|
|
44
|
+
anchor={anchor}
|
|
45
|
+
onClose={() => {}}
|
|
46
|
+
onSelect={() => {}}
|
|
47
|
+
options={options}
|
|
48
|
+
selectedValue="b"
|
|
49
|
+
visible
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
expect(getByText("Option A")).toBeTruthy();
|
|
53
|
+
expect(getByText("Option B")).toBeTruthy();
|
|
54
|
+
expect(getByText("Option C")).toBeTruthy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("invokes onSelect with value and index when an option is pressed", () => {
|
|
58
|
+
const onSelect = mock(() => {});
|
|
59
|
+
const {getByTestId} = renderWithTheme(
|
|
60
|
+
<WebDropdownMenu
|
|
61
|
+
anchor={anchor}
|
|
62
|
+
onClose={() => {}}
|
|
63
|
+
onSelect={onSelect}
|
|
64
|
+
options={options}
|
|
65
|
+
visible
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
fireEvent.press(getByTestId("web_dropdown_option_b"));
|
|
69
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(onSelect.mock.calls[0]).toEqual(["b", 1]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("invokes onClose when the backdrop is pressed", () => {
|
|
74
|
+
const onClose = mock(() => {});
|
|
75
|
+
const {getByTestId} = renderWithTheme(
|
|
76
|
+
<WebDropdownMenu
|
|
77
|
+
anchor={anchor}
|
|
78
|
+
onClose={onClose}
|
|
79
|
+
onSelect={() => {}}
|
|
80
|
+
options={options}
|
|
81
|
+
visible
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
fireEvent.press(getByTestId("web_dropdown_backdrop"));
|
|
85
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("anchors the menu below the trigger using the provided anchor", () => {
|
|
89
|
+
const {getByTestId} = renderWithTheme(
|
|
90
|
+
<WebDropdownMenu
|
|
91
|
+
anchor={anchor}
|
|
92
|
+
onClose={() => {}}
|
|
93
|
+
onSelect={() => {}}
|
|
94
|
+
options={options}
|
|
95
|
+
visible
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
const menu = getByTestId("web_dropdown_menu");
|
|
99
|
+
const style = Array.isArray(menu.props.style)
|
|
100
|
+
? Object.assign({}, ...menu.props.style)
|
|
101
|
+
: menu.props.style;
|
|
102
|
+
expect(style.left).toBe(anchor.x);
|
|
103
|
+
expect(style.top).toBe(anchor.y + anchor.height + 4);
|
|
104
|
+
expect(style.width).toBe(anchor.width);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("uses the custom testID prefix when provided", () => {
|
|
108
|
+
const {getByTestId, queryByTestId} = renderWithTheme(
|
|
109
|
+
<WebDropdownMenu
|
|
110
|
+
anchor={anchor}
|
|
111
|
+
onClose={() => {}}
|
|
112
|
+
onSelect={() => {}}
|
|
113
|
+
options={options}
|
|
114
|
+
testIDPrefix="badge_menu"
|
|
115
|
+
visible
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
expect(getByTestId("badge_menu_menu")).toBeTruthy();
|
|
119
|
+
expect(getByTestId("badge_menu_backdrop")).toBeTruthy();
|
|
120
|
+
expect(getByTestId("badge_menu_option_a")).toBeTruthy();
|
|
121
|
+
expect(queryByTestId("web_dropdown_menu")).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("highlights the option matched by selectedIndex regardless of duplicated values", () => {
|
|
125
|
+
const dupOptions = [
|
|
126
|
+
{label: "Placeholder", value: ""},
|
|
127
|
+
{label: "Blank option", value: ""},
|
|
128
|
+
{label: "Real", value: "real"},
|
|
129
|
+
];
|
|
130
|
+
const {getByText} = renderWithTheme(
|
|
131
|
+
<WebDropdownMenu
|
|
132
|
+
anchor={anchor}
|
|
133
|
+
onClose={() => {}}
|
|
134
|
+
onSelect={() => {}}
|
|
135
|
+
options={dupOptions}
|
|
136
|
+
selectedIndex={1}
|
|
137
|
+
visible
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
expect(getByText("Blank option").props.style.fontWeight).toBe("600");
|
|
141
|
+
expect(getByText("Placeholder").props.style.fontWeight).toBe("400");
|
|
142
|
+
expect(getByText("Real").props.style.fontWeight).toBe("400");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("useWebDropdownAnchor", () => {
|
|
147
|
+
it("exposes a default zero-sized anchor before measuring", () => {
|
|
148
|
+
const {result} = renderHook(() => useWebDropdownAnchor());
|
|
149
|
+
expect(result.current.anchor).toEqual({height: 0, width: 0, x: 0, y: 0});
|
|
150
|
+
expect(result.current.triggerRef.current).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("invokes the callback synchronously with the existing anchor when the ref is empty", () => {
|
|
154
|
+
const {result} = renderHook(() => useWebDropdownAnchor());
|
|
155
|
+
const onMeasured = mock(() => {});
|
|
156
|
+
act(() => {
|
|
157
|
+
result.current.measure(onMeasured);
|
|
158
|
+
});
|
|
159
|
+
expect(onMeasured).toHaveBeenCalledTimes(1);
|
|
160
|
+
expect(onMeasured.mock.calls[0][0]).toEqual({height: 0, width: 0, x: 0, y: 0});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("measures the trigger and updates anchor state when the ref has measureInWindow", () => {
|
|
164
|
+
const {result} = renderHook(() => useWebDropdownAnchor());
|
|
165
|
+
// Simulate a mounted native View by assigning a measureInWindow shim to the
|
|
166
|
+
// ref. The hook does not care whether the node is a real View instance.
|
|
167
|
+
const measureInWindow = mock((cb: (x: number, y: number, w: number, h: number) => void) => {
|
|
168
|
+
cb(10, 20, 100, 40);
|
|
169
|
+
});
|
|
170
|
+
(result.current.triggerRef as {current: unknown}).current = {measureInWindow};
|
|
171
|
+
const onMeasured = mock(() => {});
|
|
172
|
+
act(() => {
|
|
173
|
+
result.current.measure(onMeasured);
|
|
174
|
+
});
|
|
175
|
+
expect(measureInWindow).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(onMeasured).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(onMeasured.mock.calls[0][0]).toEqual({height: 40, width: 100, x: 10, y: 20});
|
|
178
|
+
expect(result.current.anchor).toEqual({height: 40, width: 100, x: 10, y: 20});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {type ReactElement, useRef, useState} from "react";
|
|
2
|
+
import {
|
|
3
|
+
type DimensionValue,
|
|
4
|
+
Modal,
|
|
5
|
+
Pressable,
|
|
6
|
+
ScrollView,
|
|
7
|
+
Text,
|
|
8
|
+
type TextStyle,
|
|
9
|
+
View,
|
|
10
|
+
} from "react-native";
|
|
11
|
+
|
|
12
|
+
import {useTheme} from "./Theme";
|
|
13
|
+
|
|
14
|
+
export interface WebDropdownMenuOption {
|
|
15
|
+
key?: string | number;
|
|
16
|
+
label: string;
|
|
17
|
+
value: string;
|
|
18
|
+
color?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WebDropdownAnchor {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WebDropdownMenuProps {
|
|
29
|
+
/** Controls visibility of the popup. */
|
|
30
|
+
visible: boolean;
|
|
31
|
+
/** Position of the trigger element so the menu can be anchored beneath it. */
|
|
32
|
+
anchor: WebDropdownAnchor;
|
|
33
|
+
/** Options to render in the list. */
|
|
34
|
+
options: WebDropdownMenuOption[];
|
|
35
|
+
/** Currently selected value (used to highlight the matching option). */
|
|
36
|
+
selectedValue?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Optional index of the currently selected option. When provided, takes
|
|
39
|
+
* precedence over `selectedValue` — useful when option values aren't
|
|
40
|
+
* unique (e.g. a placeholder with an empty value sharing the same string
|
|
41
|
+
* representation as another option).
|
|
42
|
+
*/
|
|
43
|
+
selectedIndex?: number;
|
|
44
|
+
/** Called when an option is chosen. */
|
|
45
|
+
onSelect: (value: string, index: number) => void;
|
|
46
|
+
/** Called when the backdrop is pressed or Escape is hit. */
|
|
47
|
+
onClose: () => void;
|
|
48
|
+
/** Optional fixed width for the menu. Defaults to the trigger width. */
|
|
49
|
+
width?: DimensionValue;
|
|
50
|
+
/** Optional minimum width for the menu. */
|
|
51
|
+
minWidth?: DimensionValue;
|
|
52
|
+
/** Additional style applied to each option's label. */
|
|
53
|
+
optionTextStyle?: TextStyle;
|
|
54
|
+
/** Prefix for the testIDs on the menu / backdrop / option nodes. */
|
|
55
|
+
testIDPrefix?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface PressableWebState {
|
|
59
|
+
pressed: boolean;
|
|
60
|
+
hovered?: boolean;
|
|
61
|
+
focused?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Shared web-only popup used by `RNPickerSelect` and `SelectBadge` so every
|
|
66
|
+
* browser renders the same styled dropdown instead of falling back to the
|
|
67
|
+
* platform-native `<select>` UI. Must be anchored to a trigger element via
|
|
68
|
+
* `useWebDropdownAnchor` (or an equivalent measurement).
|
|
69
|
+
*/
|
|
70
|
+
export const WebDropdownMenu = ({
|
|
71
|
+
visible,
|
|
72
|
+
anchor,
|
|
73
|
+
options,
|
|
74
|
+
selectedValue,
|
|
75
|
+
selectedIndex,
|
|
76
|
+
onSelect,
|
|
77
|
+
onClose,
|
|
78
|
+
width,
|
|
79
|
+
minWidth,
|
|
80
|
+
optionTextStyle,
|
|
81
|
+
testIDPrefix = "web_dropdown",
|
|
82
|
+
}: WebDropdownMenuProps): ReactElement => {
|
|
83
|
+
const {theme} = useTheme();
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Modal
|
|
87
|
+
animationType="none"
|
|
88
|
+
onRequestClose={onClose}
|
|
89
|
+
testID={`${testIDPrefix}_modal`}
|
|
90
|
+
transparent
|
|
91
|
+
visible={visible}
|
|
92
|
+
>
|
|
93
|
+
<Pressable
|
|
94
|
+
aria-role="button"
|
|
95
|
+
onPress={onClose}
|
|
96
|
+
style={{flex: 1}}
|
|
97
|
+
testID={`${testIDPrefix}_backdrop`}
|
|
98
|
+
/>
|
|
99
|
+
<View
|
|
100
|
+
style={{
|
|
101
|
+
backgroundColor: theme.surface.base,
|
|
102
|
+
borderColor: theme.border.dark,
|
|
103
|
+
borderRadius: 4,
|
|
104
|
+
borderWidth: 1,
|
|
105
|
+
left: anchor.x,
|
|
106
|
+
maxHeight: 300,
|
|
107
|
+
minWidth,
|
|
108
|
+
overflow: "hidden",
|
|
109
|
+
position: "absolute",
|
|
110
|
+
shadowColor: "#000",
|
|
111
|
+
shadowOffset: {height: 2, width: 0},
|
|
112
|
+
shadowOpacity: 0.15,
|
|
113
|
+
shadowRadius: 8,
|
|
114
|
+
top: anchor.y + anchor.height + 4,
|
|
115
|
+
width: width ?? anchor.width,
|
|
116
|
+
}}
|
|
117
|
+
testID={`${testIDPrefix}_menu`}
|
|
118
|
+
>
|
|
119
|
+
<ScrollView>
|
|
120
|
+
{options.map((item, idx) => {
|
|
121
|
+
const isSelected =
|
|
122
|
+
selectedIndex !== undefined ? idx === selectedIndex : item.value === selectedValue;
|
|
123
|
+
return (
|
|
124
|
+
<Pressable
|
|
125
|
+
aria-role="button"
|
|
126
|
+
key={item.key ?? idx}
|
|
127
|
+
onPress={() => onSelect(item.value, idx)}
|
|
128
|
+
style={(state: PressableWebState) => ({
|
|
129
|
+
backgroundColor:
|
|
130
|
+
isSelected || state.hovered || state.pressed
|
|
131
|
+
? theme.surface.neutralLight
|
|
132
|
+
: theme.surface.base,
|
|
133
|
+
paddingHorizontal: 12,
|
|
134
|
+
paddingVertical: 10,
|
|
135
|
+
})}
|
|
136
|
+
testID={`${testIDPrefix}_option_${item.value}`}
|
|
137
|
+
>
|
|
138
|
+
<Text
|
|
139
|
+
style={{
|
|
140
|
+
color: item.color ?? theme.text.primary,
|
|
141
|
+
fontWeight: isSelected ? "600" : "400",
|
|
142
|
+
...optionTextStyle,
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{item.label}
|
|
146
|
+
</Text>
|
|
147
|
+
</Pressable>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</ScrollView>
|
|
151
|
+
</View>
|
|
152
|
+
</Modal>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Hook that wires up a `View` ref + anchor state for use with
|
|
158
|
+
* `WebDropdownMenu`. Measure the trigger via `measure()` before opening so
|
|
159
|
+
* the menu lines up beneath it across browsers.
|
|
160
|
+
*/
|
|
161
|
+
export const useWebDropdownAnchor = (): {
|
|
162
|
+
triggerRef: React.RefObject<View | null>;
|
|
163
|
+
anchor: WebDropdownAnchor;
|
|
164
|
+
measure: (onMeasured: (anchor: WebDropdownAnchor) => void) => void;
|
|
165
|
+
} => {
|
|
166
|
+
const triggerRef = useRef<View>(null);
|
|
167
|
+
const [anchor, setAnchor] = useState<WebDropdownAnchor>({height: 0, width: 0, x: 0, y: 0});
|
|
168
|
+
|
|
169
|
+
const measure = (onMeasured: (next: WebDropdownAnchor) => void): void => {
|
|
170
|
+
const node = triggerRef.current;
|
|
171
|
+
if (node && typeof node.measureInWindow === "function") {
|
|
172
|
+
node.measureInWindow((x, y, w, h) => {
|
|
173
|
+
const next = {height: h, width: w, x, y};
|
|
174
|
+
setAnchor(next);
|
|
175
|
+
onMeasured(next);
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
onMeasured(anchor);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return {anchor, measure, triggerRef};
|
|
183
|
+
};
|
|
@@ -254,6 +254,9 @@ exports[`SelectBadge renders correctly with default props 1`] = `
|
|
|
254
254
|
},
|
|
255
255
|
],
|
|
256
256
|
"props": {
|
|
257
|
+
"ref": {
|
|
258
|
+
"current": null,
|
|
259
|
+
},
|
|
257
260
|
"style": {
|
|
258
261
|
"alignItems": "flex-start",
|
|
259
262
|
"opacity": 1,
|
|
@@ -518,6 +521,9 @@ exports[`SelectBadge renders with info status (default) 1`] = `
|
|
|
518
521
|
},
|
|
519
522
|
],
|
|
520
523
|
"props": {
|
|
524
|
+
"ref": {
|
|
525
|
+
"current": null,
|
|
526
|
+
},
|
|
521
527
|
"style": {
|
|
522
528
|
"alignItems": "flex-start",
|
|
523
529
|
"opacity": 1,
|
|
@@ -782,6 +788,9 @@ exports[`SelectBadge renders with success status 1`] = `
|
|
|
782
788
|
},
|
|
783
789
|
],
|
|
784
790
|
"props": {
|
|
791
|
+
"ref": {
|
|
792
|
+
"current": null,
|
|
793
|
+
},
|
|
785
794
|
"style": {
|
|
786
795
|
"alignItems": "flex-start",
|
|
787
796
|
"opacity": 1,
|
|
@@ -1046,6 +1055,9 @@ exports[`SelectBadge renders with warning status 1`] = `
|
|
|
1046
1055
|
},
|
|
1047
1056
|
],
|
|
1048
1057
|
"props": {
|
|
1058
|
+
"ref": {
|
|
1059
|
+
"current": null,
|
|
1060
|
+
},
|
|
1049
1061
|
"style": {
|
|
1050
1062
|
"alignItems": "flex-start",
|
|
1051
1063
|
"opacity": 1,
|
|
@@ -1310,6 +1322,9 @@ exports[`SelectBadge renders with error status 1`] = `
|
|
|
1310
1322
|
},
|
|
1311
1323
|
],
|
|
1312
1324
|
"props": {
|
|
1325
|
+
"ref": {
|
|
1326
|
+
"current": null,
|
|
1327
|
+
},
|
|
1313
1328
|
"style": {
|
|
1314
1329
|
"alignItems": "flex-start",
|
|
1315
1330
|
"opacity": 1,
|
|
@@ -1574,6 +1589,9 @@ exports[`SelectBadge renders with neutral status 1`] = `
|
|
|
1574
1589
|
},
|
|
1575
1590
|
],
|
|
1576
1591
|
"props": {
|
|
1592
|
+
"ref": {
|
|
1593
|
+
"current": null,
|
|
1594
|
+
},
|
|
1577
1595
|
"style": {
|
|
1578
1596
|
"alignItems": "flex-start",
|
|
1579
1597
|
"opacity": 1,
|
|
@@ -1838,6 +1856,9 @@ exports[`SelectBadge renders with secondary style 1`] = `
|
|
|
1838
1856
|
},
|
|
1839
1857
|
],
|
|
1840
1858
|
"props": {
|
|
1859
|
+
"ref": {
|
|
1860
|
+
"current": null,
|
|
1861
|
+
},
|
|
1841
1862
|
"style": {
|
|
1842
1863
|
"alignItems": "flex-start",
|
|
1843
1864
|
"opacity": 1,
|
|
@@ -2102,6 +2123,9 @@ exports[`SelectBadge renders with custom colors 1`] = `
|
|
|
2102
2123
|
},
|
|
2103
2124
|
],
|
|
2104
2125
|
"props": {
|
|
2126
|
+
"ref": {
|
|
2127
|
+
"current": null,
|
|
2128
|
+
},
|
|
2105
2129
|
"style": {
|
|
2106
2130
|
"alignItems": "flex-start",
|
|
2107
2131
|
"opacity": 1,
|
|
@@ -2366,6 +2390,9 @@ exports[`SelectBadge renders disabled state 1`] = `
|
|
|
2366
2390
|
},
|
|
2367
2391
|
],
|
|
2368
2392
|
"props": {
|
|
2393
|
+
"ref": {
|
|
2394
|
+
"current": null,
|
|
2395
|
+
},
|
|
2369
2396
|
"style": {
|
|
2370
2397
|
"alignItems": "flex-start",
|
|
2371
2398
|
"opacity": 0.5,
|
|
@@ -1,32 +1,53 @@
|
|
|
1
1
|
import {describe, expect, it, mock} from "bun:test";
|
|
2
2
|
import {renderHook} from "@testing-library/react-native";
|
|
3
3
|
|
|
4
|
+
import type {ConsentFormPublic} from "./useConsentForms";
|
|
4
5
|
import {detectLocale, useConsentForms} from "./useConsentForms";
|
|
5
6
|
|
|
7
|
+
type ConsentFormsApi = Parameters<typeof useConsentForms>[0];
|
|
8
|
+
|
|
9
|
+
interface GlobalThisWithNavigator {
|
|
10
|
+
navigator: {language: string} | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface MockQueryDef {
|
|
14
|
+
onQueryStarted?: (
|
|
15
|
+
_arg: unknown,
|
|
16
|
+
helpers: {queryFulfilled: Promise<{data: ConsentFormPublic[]}> | Promise<never>}
|
|
17
|
+
) => Promise<void>;
|
|
18
|
+
query: () => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface MockInjectOpts {
|
|
22
|
+
endpoints: (build: {query: (def: MockQueryDef) => string}) => Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
6
25
|
mock.module("expo-localization", () => ({
|
|
7
26
|
getLocales: () => [{languageTag: "es-ES"}],
|
|
8
27
|
}));
|
|
9
28
|
|
|
10
29
|
describe("detectLocale", () => {
|
|
11
30
|
it("returns locale from expo-localization in native test env", () => {
|
|
12
|
-
const
|
|
13
|
-
|
|
31
|
+
const g = globalThis as unknown as GlobalThisWithNavigator;
|
|
32
|
+
const originalNavigator = g.navigator;
|
|
33
|
+
g.navigator = undefined;
|
|
14
34
|
const locale = detectLocale();
|
|
15
|
-
|
|
35
|
+
g.navigator = originalNavigator;
|
|
16
36
|
expect(typeof locale).toBe("string");
|
|
17
37
|
expect(locale.length).toBeGreaterThan(0);
|
|
18
38
|
});
|
|
19
39
|
|
|
20
40
|
it("falls back to en when expo-localization throws and no navigator", () => {
|
|
21
|
-
const
|
|
22
|
-
|
|
41
|
+
const g = globalThis as unknown as GlobalThisWithNavigator;
|
|
42
|
+
const originalNavigator = g.navigator;
|
|
43
|
+
g.navigator = undefined;
|
|
23
44
|
mock.module("expo-localization", () => ({
|
|
24
45
|
getLocales: () => {
|
|
25
46
|
throw new Error("not available");
|
|
26
47
|
},
|
|
27
48
|
}));
|
|
28
49
|
const locale = detectLocale();
|
|
29
|
-
|
|
50
|
+
g.navigator = originalNavigator;
|
|
30
51
|
// Reset mock
|
|
31
52
|
mock.module("expo-localization", () => ({
|
|
32
53
|
getLocales: () => [{languageTag: "es-ES"}],
|
|
@@ -35,10 +56,11 @@ describe("detectLocale", () => {
|
|
|
35
56
|
});
|
|
36
57
|
|
|
37
58
|
it("returns navigator.language when available", () => {
|
|
38
|
-
const
|
|
39
|
-
|
|
59
|
+
const g = globalThis as unknown as GlobalThisWithNavigator;
|
|
60
|
+
const originalNavigator = g.navigator;
|
|
61
|
+
g.navigator = {language: "fr-FR"};
|
|
40
62
|
const locale = detectLocale();
|
|
41
|
-
|
|
63
|
+
g.navigator = originalNavigator;
|
|
42
64
|
expect(locale).toBe("fr-FR");
|
|
43
65
|
});
|
|
44
66
|
});
|
|
@@ -54,10 +76,10 @@ describe("useConsentForms", () => {
|
|
|
54
76
|
}));
|
|
55
77
|
const api = {
|
|
56
78
|
enhanceEndpoints: mock(() => ({
|
|
57
|
-
injectEndpoints: mock((opts:
|
|
79
|
+
injectEndpoints: mock((opts: MockInjectOpts) => {
|
|
58
80
|
// Call endpoint builder to exercise provides/onQueryStarted
|
|
59
81
|
const build = {
|
|
60
|
-
query: mock((def:
|
|
82
|
+
query: mock((def: MockQueryDef) => {
|
|
61
83
|
// Call onQueryStarted with a successful result
|
|
62
84
|
if (def.onQueryStarted) {
|
|
63
85
|
void def.onQueryStarted(undefined, {
|
|
@@ -78,21 +100,21 @@ describe("useConsentForms", () => {
|
|
|
78
100
|
};
|
|
79
101
|
|
|
80
102
|
it("returns an array of forms when response is an array", () => {
|
|
81
|
-
const {api} = buildApi({data: [{id: "1", title: "Form 1"} as
|
|
82
|
-
const {result} = renderHook(() => useConsentForms(api as
|
|
103
|
+
const {api} = buildApi({data: [{id: "1", title: "Form 1"} as unknown as ConsentFormPublic]});
|
|
104
|
+
const {result} = renderHook(() => useConsentForms(api as unknown as ConsentFormsApi));
|
|
83
105
|
expect(result.current.forms).toBeDefined();
|
|
84
106
|
expect(Array.isArray(result.current.forms)).toBe(true);
|
|
85
107
|
});
|
|
86
108
|
|
|
87
109
|
it("unwraps .data property when response is object shape", () => {
|
|
88
|
-
const {api} = buildApi({data: {data: [{id: "2"} as
|
|
89
|
-
const {result} = renderHook(() => useConsentForms(api as
|
|
110
|
+
const {api} = buildApi({data: {data: [{id: "2"} as unknown as ConsentFormPublic]}});
|
|
111
|
+
const {result} = renderHook(() => useConsentForms(api as unknown as ConsentFormsApi, "/api"));
|
|
90
112
|
expect(Array.isArray(result.current.forms)).toBe(true);
|
|
91
113
|
});
|
|
92
114
|
|
|
93
115
|
it("returns empty array when no data is present", () => {
|
|
94
116
|
const {api} = buildApi({data: undefined, isLoading: true});
|
|
95
|
-
const {result} = renderHook(() => useConsentForms(api as
|
|
117
|
+
const {result} = renderHook(() => useConsentForms(api as unknown as ConsentFormsApi));
|
|
96
118
|
expect(result.current.forms).toEqual([]);
|
|
97
119
|
expect(result.current.isLoading).toBe(true);
|
|
98
120
|
});
|
|
@@ -101,9 +123,9 @@ describe("useConsentForms", () => {
|
|
|
101
123
|
const refetch = mock(() => {});
|
|
102
124
|
const api = {
|
|
103
125
|
enhanceEndpoints: mock(() => ({
|
|
104
|
-
injectEndpoints: mock((opts:
|
|
126
|
+
injectEndpoints: mock((opts: MockInjectOpts) => {
|
|
105
127
|
const build = {
|
|
106
|
-
query: mock((def:
|
|
128
|
+
query: mock((def: MockQueryDef) => {
|
|
107
129
|
if (def.onQueryStarted) {
|
|
108
130
|
void def.onQueryStarted(undefined, {
|
|
109
131
|
queryFulfilled: Promise.reject(new Error("failed")),
|
|
@@ -124,7 +146,7 @@ describe("useConsentForms", () => {
|
|
|
124
146
|
}),
|
|
125
147
|
})),
|
|
126
148
|
};
|
|
127
|
-
const {result} = renderHook(() => useConsentForms(api as
|
|
149
|
+
const {result} = renderHook(() => useConsentForms(api as unknown as ConsentFormsApi));
|
|
128
150
|
expect(result.current.error).toBe("error");
|
|
129
151
|
});
|
|
130
152
|
});
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import {describe, expect, it, mock} from "bun:test";
|
|
2
2
|
import {renderHook} from "@testing-library/react-native";
|
|
3
3
|
|
|
4
|
+
import type {SubmitConsentBody} from "./useSubmitConsent";
|
|
4
5
|
import {useSubmitConsent} from "./useSubmitConsent";
|
|
5
6
|
|
|
7
|
+
type SubmitConsentApi = Parameters<typeof useSubmitConsent>[0];
|
|
8
|
+
|
|
9
|
+
interface MockMutationDef {
|
|
10
|
+
query: (body: SubmitConsentBody) => {method: string; url: string};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface MockInjectOpts {
|
|
14
|
+
endpoints: (build: {mutation: (def: MockMutationDef) => string}) => Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
6
17
|
describe("useSubmitConsent", () => {
|
|
7
18
|
const buildApi = () => {
|
|
8
19
|
const unwrap = mock(async () => ({success: true}));
|
|
@@ -13,9 +24,9 @@ describe("useSubmitConsent", () => {
|
|
|
13
24
|
]);
|
|
14
25
|
const api = {
|
|
15
26
|
enhanceEndpoints: mock(() => ({
|
|
16
|
-
injectEndpoints: mock((opts:
|
|
27
|
+
injectEndpoints: mock((opts: MockInjectOpts) => {
|
|
17
28
|
const build = {
|
|
18
|
-
mutation: mock((def:
|
|
29
|
+
mutation: mock((def: MockMutationDef) => {
|
|
19
30
|
// Exercise the query builder
|
|
20
31
|
const result = def.query({
|
|
21
32
|
agreed: true,
|
|
@@ -37,7 +48,7 @@ describe("useSubmitConsent", () => {
|
|
|
37
48
|
|
|
38
49
|
it("returns submit function and state", () => {
|
|
39
50
|
const {api} = buildApi();
|
|
40
|
-
const {result} = renderHook(() => useSubmitConsent(api as
|
|
51
|
+
const {result} = renderHook(() => useSubmitConsent(api as unknown as SubmitConsentApi));
|
|
41
52
|
expect(typeof result.current.submit).toBe("function");
|
|
42
53
|
expect(result.current.isSubmitting).toBe(false);
|
|
43
54
|
expect(result.current.error).toBeUndefined();
|
|
@@ -45,7 +56,7 @@ describe("useSubmitConsent", () => {
|
|
|
45
56
|
|
|
46
57
|
it("calls submit mutation when submit is invoked", async () => {
|
|
47
58
|
const {api, submitMutation, unwrap} = buildApi();
|
|
48
|
-
const {result} = renderHook(() => useSubmitConsent(api as
|
|
59
|
+
const {result} = renderHook(() => useSubmitConsent(api as unknown as SubmitConsentApi, "/api"));
|
|
49
60
|
const response = await result.current.submit({
|
|
50
61
|
agreed: true,
|
|
51
62
|
consentFormId: "f1",
|
|
@@ -58,7 +69,7 @@ describe("useSubmitConsent", () => {
|
|
|
58
69
|
|
|
59
70
|
it("uses empty baseUrl when none provided", () => {
|
|
60
71
|
const {api} = buildApi();
|
|
61
|
-
const {result} = renderHook(() => useSubmitConsent(api as
|
|
72
|
+
const {result} = renderHook(() => useSubmitConsent(api as unknown as SubmitConsentApi));
|
|
62
73
|
expect(result.current.submit).toBeDefined();
|
|
63
74
|
});
|
|
64
75
|
});
|