@terreno/ui 0.13.0 → 0.13.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.
Files changed (54) hide show
  1. package/dist/ConsentFormScreen.js +8 -7
  2. package/dist/ConsentFormScreen.js.map +1 -1
  3. package/dist/PickerSelect.d.ts +22 -10
  4. package/dist/PickerSelect.js +11 -9
  5. package/dist/PickerSelect.js.map +1 -1
  6. package/dist/SelectBadge.js +11 -1
  7. package/dist/SelectBadge.js.map +1 -1
  8. package/dist/SelectField.js +2 -2
  9. package/dist/SelectField.js.map +1 -1
  10. package/dist/SidebarNavigation.native.js.map +1 -1
  11. package/dist/Signature.js +4 -0
  12. package/dist/Signature.js.map +1 -1
  13. package/dist/Signature.native.js +4 -0
  14. package/dist/Signature.native.js.map +1 -1
  15. package/dist/Theme.d.ts +1 -1
  16. package/dist/Theme.js.map +1 -1
  17. package/dist/useConsentForms.d.ts +4 -3
  18. package/dist/useConsentForms.js +26 -4
  19. package/dist/useConsentForms.js.map +1 -1
  20. package/dist/useConsentHistory.d.ts +4 -3
  21. package/dist/useConsentHistory.js +26 -4
  22. package/dist/useConsentHistory.js.map +1 -1
  23. package/dist/useSubmitConsent.d.ts +7 -6
  24. package/dist/useSubmitConsent.js +25 -3
  25. package/dist/useSubmitConsent.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/ConsentFormScreen.test.tsx +47 -0
  28. package/src/ConsentFormScreen.tsx +11 -0
  29. package/src/HeightField.test.tsx +68 -0
  30. package/src/Modal.tsx +2 -2
  31. package/src/PickerSelect.tsx +48 -34
  32. package/src/SelectBadge.tsx +7 -3
  33. package/src/SelectField.tsx +2 -2
  34. package/src/SidebarNavigation.native.tsx +6 -2
  35. package/src/Signature.native.tsx +4 -0
  36. package/src/Signature.test.tsx +10 -0
  37. package/src/Signature.tsx +4 -0
  38. package/src/Theme.tsx +17 -14
  39. package/src/Tooltip.test.tsx +41 -22
  40. package/src/__snapshots__/AddressField.test.tsx.snap +0 -1
  41. package/src/__snapshots__/CustomSelectField.test.tsx.snap +0 -7
  42. package/src/__snapshots__/Field.test.tsx.snap +0 -6
  43. package/src/__snapshots__/PickerSelect.test.tsx.snap +0 -7
  44. package/src/__snapshots__/SelectField.test.tsx.snap +0 -6
  45. package/src/__snapshots__/TerrenoProvider.test.tsx.snap +4 -18
  46. package/src/__snapshots__/TimezonePicker.test.tsx.snap +0 -6
  47. package/src/bunSetup.ts +22 -0
  48. package/src/table/__snapshots__/TableBadge.test.tsx.snap +0 -1
  49. package/src/useConsentForms.test.ts +25 -0
  50. package/src/useConsentForms.ts +32 -7
  51. package/src/useConsentHistory.test.ts +99 -0
  52. package/src/useConsentHistory.ts +31 -6
  53. package/src/useSubmitConsent.test.ts +24 -0
  54. package/src/useSubmitConsent.ts +35 -10
package/src/Theme.tsx CHANGED
@@ -172,18 +172,21 @@ const computeTheme = (
172
172
  return acc;
173
173
  }
174
174
  const value = themeConfig[key as keyof TerrenoThemeConfig] ?? {};
175
- acc[key as keyof TerrenoTheme] = Object.keys(value).reduce((accKey, valueKey) => {
176
- const primitiveKey = value[valueKey as keyof typeof value] as keyof ThemePrimitives;
177
- if (key === "font") {
178
- accKey[valueKey] = primitiveKey;
179
- } else {
180
- if (primitives[primitiveKey] === undefined) {
181
- console.error(`Primitive ${primitiveKey} not found in theme.`);
175
+ (acc as unknown as Record<string, unknown>)[key] = Object.keys(value).reduce(
176
+ (accKey, valueKey) => {
177
+ const primitiveKey = value[valueKey as keyof typeof value] as keyof ThemePrimitives;
178
+ if (key === "font") {
179
+ accKey[valueKey] = primitiveKey;
180
+ } else {
181
+ if (primitives[primitiveKey] === undefined) {
182
+ console.error(`Primitive ${primitiveKey} not found in theme.`);
183
+ }
184
+ accKey[valueKey] = primitives[primitiveKey];
182
185
  }
183
- accKey[valueKey as keyof typeof accKey] = primitives[primitiveKey];
184
- }
185
- return accKey;
186
- }, {} as any);
186
+ return accKey;
187
+ },
188
+ {} as Record<string, string | number>
189
+ );
187
190
  return acc;
188
191
  }, {} as TerrenoTheme);
189
192
  return {...theme, primitives};
@@ -199,7 +202,7 @@ export const ThemeContext = createContext({
199
202
  });
200
203
 
201
204
  interface ThemeProviderProps {
202
- children: any;
205
+ children: React.ReactNode;
203
206
  }
204
207
 
205
208
  export const ThemeProvider = ({children}: ThemeProviderProps) => {
@@ -225,12 +228,12 @@ export const ThemeProvider = ({children}: ThemeProviderProps) => {
225
228
  const prevSubTheme = prev[key as keyof TerrenoThemeConfig];
226
229
 
227
230
  if (newSubTheme && typeof newSubTheme === "object") {
228
- (mergedTheme as any)[key as keyof TerrenoThemeConfig] = {
231
+ (mergedTheme as Record<string, unknown>)[key] = {
229
232
  ...prevSubTheme,
230
233
  ...newSubTheme,
231
234
  };
232
235
  } else {
233
- mergedTheme[key as keyof TerrenoThemeConfig] = newSubTheme as any;
236
+ (mergedTheme as Record<string, unknown>)[key] = newSubTheme;
234
237
  }
235
238
  }
236
239
  }
@@ -6,6 +6,20 @@ import {Text} from "./Text";
6
6
  import {Tooltip} from "./Tooltip";
7
7
  import {renderWithTheme} from "./test-utils";
8
8
 
9
+ // Minimal shape of the tree returned by toJSON() that we rely on here.
10
+ interface TestNode {
11
+ type: string;
12
+ props: {
13
+ onPointerEnter?: () => void;
14
+ onPointerLeave?: () => void;
15
+ onTouchStart?: (event?: {nativeEvent: object}) => void;
16
+ onLayout?: (event: {
17
+ nativeEvent: {layout: {height: number; width: number; x: number; y: number}};
18
+ }) => void;
19
+ };
20
+ children: null | Array<TestNode | string>;
21
+ }
22
+
9
23
  // Mock react-native-portalize so Portal renders inline in tests
10
24
  mock.module("react-native-portalize", () => ({
11
25
  Host: ({children}: {children: React.ReactNode}) => <View testID="portal-host">{children}</View>,
@@ -13,10 +27,10 @@ mock.module("react-native-portalize", () => ({
13
27
  }));
14
28
 
15
29
  beforeAll(() => {
16
- (global as any).requestAnimationFrame = (callback: FrameRequestCallback) => {
30
+ globalThis.requestAnimationFrame = (callback: FrameRequestCallback) => {
17
31
  return setTimeout(() => callback(Date.now()), 0) as unknown as number;
18
32
  };
19
- (global as any).cancelAnimationFrame = (id: number) => {
33
+ globalThis.cancelAnimationFrame = (id: number) => {
20
34
  clearTimeout(id);
21
35
  };
22
36
  });
@@ -111,14 +125,14 @@ describe("Tooltip", () => {
111
125
  </Tooltip>
112
126
  );
113
127
 
114
- const wrapper = toJSON() as any;
128
+ const wrapper = toJSON();
115
129
  expect(wrapper).toBeTruthy();
116
130
  expect(queryByTestId("tooltip-container")).toBeNull();
117
131
 
118
- const tree = toJSON();
132
+ const tree = toJSON() as TestNode | null;
119
133
  await act(async () => {
120
134
  // Trigger pointer enter on the wrapper
121
- const root = (tree as any).children?.[0];
135
+ const root = tree?.children?.[0] as TestNode | undefined;
122
136
  if (root?.props?.onPointerEnter) {
123
137
  root.props.onPointerEnter();
124
138
  }
@@ -138,8 +152,8 @@ describe("Tooltip", () => {
138
152
  </Tooltip>
139
153
  );
140
154
 
141
- const tree = toJSON() as any;
142
- const root = tree.children?.[0];
155
+ const tree = toJSON() as TestNode;
156
+ const root = tree.children?.[0] as TestNode;
143
157
 
144
158
  await act(async () => {
145
159
  root.props.onTouchStart?.({nativeEvent: {}});
@@ -151,8 +165,10 @@ describe("Tooltip", () => {
151
165
  expect(queryByTestId("tooltip-container")).toBeTruthy();
152
166
 
153
167
  // Second touch should hide
154
- const treeAfterShow = toJSON() as any;
155
- const updatedRoot = treeAfterShow.children?.[treeAfterShow.children.length - 1];
168
+ const treeAfterShow = toJSON() as TestNode;
169
+ const updatedRoot = (treeAfterShow.children as Array<TestNode | string>)[
170
+ (treeAfterShow.children as Array<TestNode | string>).length - 1
171
+ ] as TestNode;
156
172
  await act(async () => {
157
173
  updatedRoot.props.onTouchStart?.({nativeEvent: {}});
158
174
  });
@@ -167,8 +183,8 @@ describe("Tooltip", () => {
167
183
  </Tooltip>
168
184
  );
169
185
 
170
- const tree = toJSON() as any;
171
- const root = tree.children?.[0];
186
+ const tree = toJSON() as TestNode;
187
+ const root = tree.children?.[0] as TestNode;
172
188
 
173
189
  await act(async () => {
174
190
  root.props.onPointerEnter?.();
@@ -179,8 +195,10 @@ describe("Tooltip", () => {
179
195
 
180
196
  expect(queryByTestId("tooltip-container")).toBeTruthy();
181
197
 
182
- const treeAfter = toJSON() as any;
183
- const wrapper = treeAfter.children?.[treeAfter.children.length - 1];
198
+ const treeAfter = toJSON() as TestNode;
199
+ const wrapper = (treeAfter.children as Array<TestNode | string>)[
200
+ (treeAfter.children as Array<TestNode | string>).length - 1
201
+ ] as TestNode;
184
202
  await act(async () => {
185
203
  wrapper.props.onPointerLeave?.();
186
204
  });
@@ -206,8 +224,8 @@ describe("Tooltip", () => {
206
224
  </Tooltip>
207
225
  );
208
226
 
209
- const tree = toJSON() as any;
210
- const root = tree.children?.[0];
227
+ const tree = toJSON() as TestNode;
228
+ const root = tree.children?.[0] as TestNode;
211
229
 
212
230
  await act(async () => {
213
231
  root.props.onPointerEnter?.();
@@ -227,8 +245,8 @@ describe("Tooltip", () => {
227
245
  </Tooltip>
228
246
  );
229
247
 
230
- const tree = toJSON() as any;
231
- const root = tree.children?.[0];
248
+ const tree = toJSON() as TestNode;
249
+ const root = tree.children?.[0] as TestNode;
232
250
 
233
251
  // Show the tooltip
234
252
  await act(async () => {
@@ -241,11 +259,12 @@ describe("Tooltip", () => {
241
259
 
242
260
  // Find any views with onLayout to simulate layout event
243
261
  const {View: ViewComp} = await import("react-native");
244
- const allViews = UNSAFE_getAllByType(ViewComp as any);
262
+ const allViews = UNSAFE_getAllByType(ViewComp);
245
263
  for (const v of allViews) {
246
- if ((v.props as any).onLayout) {
264
+ const props = v.props as TestNode["props"];
265
+ if (props.onLayout) {
247
266
  await act(async () => {
248
- (v.props as any).onLayout({
267
+ props.onLayout?.({
249
268
  nativeEvent: {
250
269
  layout: {height: 100, width: 200, x: 0, y: 0},
251
270
  },
@@ -280,8 +299,8 @@ describe("Tooltip", () => {
280
299
  </Tooltip>
281
300
  );
282
301
 
283
- const tree = toJSON() as any;
284
- const root = tree.children?.[0];
302
+ const tree = toJSON() as TestNode;
303
+ const root = tree.children?.[0] as TestNode;
285
304
  await act(async () => {
286
305
  root.props.onPointerEnter?.();
287
306
  });
@@ -421,7 +421,6 @@ exports[`AddressField renders correctly with default props 1`] = `
421
421
  },
422
422
  ],
423
423
  "props": {
424
- "activeOpacity": 1,
425
424
  "onPress": [Function],
426
425
  "style": {
427
426
  "alignItems": "center",
@@ -57,7 +57,6 @@ exports[`CustomSelectField renders correctly with default props 1`] = `
57
57
  },
58
58
  ],
59
59
  "props": {
60
- "activeOpacity": 1,
61
60
  "onPress": [Function],
62
61
  "style": {
63
62
  "alignItems": "center",
@@ -385,7 +384,6 @@ exports[`CustomSelectField renders with title 1`] = `
385
384
  },
386
385
  ],
387
386
  "props": {
388
- "activeOpacity": 1,
389
387
  "onPress": [Function],
390
388
  "style": {
391
389
  "alignItems": "center",
@@ -698,7 +696,6 @@ exports[`CustomSelectField renders with placeholder 1`] = `
698
696
  },
699
697
  ],
700
698
  "props": {
701
- "activeOpacity": 1,
702
699
  "onPress": [Function],
703
700
  "style": {
704
701
  "alignItems": "center",
@@ -1011,7 +1008,6 @@ exports[`CustomSelectField renders with selected value 1`] = `
1011
1008
  },
1012
1009
  ],
1013
1010
  "props": {
1014
- "activeOpacity": 1,
1015
1011
  "onPress": [Function],
1016
1012
  "style": {
1017
1013
  "alignItems": "center",
@@ -1324,7 +1320,6 @@ exports[`CustomSelectField renders with custom value (not in options) 1`] = `
1324
1320
  },
1325
1321
  ],
1326
1322
  "props": {
1327
- "activeOpacity": 1,
1328
1323
  "onPress": [Function],
1329
1324
  "style": {
1330
1325
  "alignItems": "center",
@@ -1741,7 +1736,6 @@ exports[`CustomSelectField renders disabled state 1`] = `
1741
1736
  },
1742
1737
  ],
1743
1738
  "props": {
1744
- "activeOpacity": 1,
1745
1739
  "onPress": [Function],
1746
1740
  "style": {
1747
1741
  "alignItems": "center",
@@ -2056,7 +2050,6 @@ exports[`CustomSelectField includes custom option in dropdown 1`] = `
2056
2050
  },
2057
2051
  ],
2058
2052
  "props": {
2059
- "activeOpacity": 1,
2060
2053
  "onPress": [Function],
2061
2054
  "style": {
2062
2055
  "alignItems": "center",
@@ -1224,7 +1224,6 @@ exports[`Field renders time field 1`] = `
1224
1224
  },
1225
1225
  ],
1226
1226
  "props": {
1227
- "activeOpacity": 1,
1228
1227
  "onPress": [Function],
1229
1228
  "style": {
1230
1229
  "alignItems": "center",
@@ -1494,7 +1493,6 @@ exports[`Field renders time field 1`] = `
1494
1493
  },
1495
1494
  ],
1496
1495
  "props": {
1497
- "activeOpacity": 1,
1498
1496
  "onPress": [Function],
1499
1497
  "style": {
1500
1498
  "alignItems": "center",
@@ -2219,7 +2217,6 @@ exports[`Field renders datetime field 1`] = `
2219
2217
  },
2220
2218
  ],
2221
2219
  "props": {
2222
- "activeOpacity": 1,
2223
2220
  "onPress": [Function],
2224
2221
  "style": {
2225
2222
  "alignItems": "center",
@@ -2489,7 +2486,6 @@ exports[`Field renders datetime field 1`] = `
2489
2486
  },
2490
2487
  ],
2491
2488
  "props": {
2492
- "activeOpacity": 1,
2493
2489
  "onPress": [Function],
2494
2490
  "style": {
2495
2491
  "alignItems": "center",
@@ -2860,7 +2856,6 @@ exports[`Field renders select field 1`] = `
2860
2856
  },
2861
2857
  ],
2862
2858
  "props": {
2863
- "activeOpacity": 1,
2864
2859
  "onPress": [Function],
2865
2860
  "style": {
2866
2861
  "alignItems": "center",
@@ -3737,7 +3732,6 @@ exports[`Field renders address field 1`] = `
3737
3732
  },
3738
3733
  ],
3739
3734
  "props": {
3740
- "activeOpacity": 1,
3741
3735
  "onPress": [Function],
3742
3736
  "style": {
3743
3737
  "alignItems": "center",
@@ -48,7 +48,6 @@ exports[`PickerSelect renders correctly with default props 1`] = `
48
48
  },
49
49
  ],
50
50
  "props": {
51
- "activeOpacity": 1,
52
51
  "onPress": [Function],
53
52
  "style": {
54
53
  "alignItems": "center",
@@ -313,7 +312,6 @@ exports[`PickerSelect renders with selected value 1`] = `
313
312
  },
314
313
  ],
315
314
  "props": {
316
- "activeOpacity": 1,
317
315
  "onPress": [Function],
318
316
  "style": {
319
317
  "alignItems": "center",
@@ -578,7 +576,6 @@ exports[`PickerSelect renders disabled state 1`] = `
578
576
  },
579
577
  ],
580
578
  "props": {
581
- "activeOpacity": 1,
582
579
  "onPress": [Function],
583
580
  "style": {
584
581
  "alignItems": "center",
@@ -845,7 +842,6 @@ exports[`PickerSelect renders without placeholder when placeholder is empty obje
845
842
  },
846
843
  ],
847
844
  "props": {
848
- "activeOpacity": 1,
849
845
  "onPress": [Function],
850
846
  "style": {
851
847
  "alignItems": "center",
@@ -1100,7 +1096,6 @@ exports[`PickerSelect matches items by itemKey 1`] = `
1100
1096
  },
1101
1097
  ],
1102
1098
  "props": {
1103
- "activeOpacity": 1,
1104
1099
  "onPress": [Function],
1105
1100
  "style": {
1106
1101
  "alignItems": "center",
@@ -1355,7 +1350,6 @@ exports[`PickerSelect renders custom InputAccessoryView 1`] = `
1355
1350
  },
1356
1351
  ],
1357
1352
  "props": {
1358
- "activeOpacity": 1,
1359
1353
  "onPress": [Function],
1360
1354
  "style": {
1361
1355
  "alignItems": "center",
@@ -1541,7 +1535,6 @@ exports[`PickerSelect passes textInputProps to TextInput 1`] = `
1541
1535
  },
1542
1536
  ],
1543
1537
  "props": {
1544
- "activeOpacity": 1,
1545
1538
  "onPress": [Function],
1546
1539
  "style": {
1547
1540
  "alignItems": "center",
@@ -51,7 +51,6 @@ exports[`SelectField renders correctly with default props 1`] = `
51
51
  },
52
52
  ],
53
53
  "props": {
54
- "activeOpacity": 1,
55
54
  "onPress": [Function],
56
55
  "style": {
57
56
  "alignItems": "center",
@@ -341,7 +340,6 @@ exports[`SelectField renders with title 1`] = `
341
340
  },
342
341
  ],
343
342
  "props": {
344
- "activeOpacity": 1,
345
343
  "onPress": [Function],
346
344
  "style": {
347
345
  "alignItems": "center",
@@ -616,7 +614,6 @@ exports[`SelectField renders with selected value 1`] = `
616
614
  },
617
615
  ],
618
616
  "props": {
619
- "activeOpacity": 1,
620
617
  "onPress": [Function],
621
618
  "style": {
622
619
  "alignItems": "center",
@@ -891,7 +888,6 @@ exports[`SelectField renders with custom placeholder 1`] = `
891
888
  },
892
889
  ],
893
890
  "props": {
894
- "activeOpacity": 1,
895
891
  "onPress": [Function],
896
892
  "style": {
897
893
  "alignItems": "center",
@@ -1166,7 +1162,6 @@ exports[`SelectField renders disabled state 1`] = `
1166
1162
  },
1167
1163
  ],
1168
1164
  "props": {
1169
- "activeOpacity": 1,
1170
1165
  "onPress": [Function],
1171
1166
  "style": {
1172
1167
  "alignItems": "center",
@@ -1443,7 +1438,6 @@ exports[`SelectField renders with requireValue (no clear option) 1`] = `
1443
1438
  },
1444
1439
  ],
1445
1440
  "props": {
1446
- "activeOpacity": 1,
1447
1441
  "onPress": [Function],
1448
1442
  "style": {
1449
1443
  "alignItems": "center",
@@ -27,15 +27,8 @@ exports[`TerrenoProvider renders correctly with default props 1`] = `
27
27
  },
28
28
  ],
29
29
  "props": {
30
- "collapsable": false,
31
- "pointerEvents": "box-none",
32
- "style": [
33
- {
34
- "flex": 1,
35
- },
36
- undefined,
37
- ],
38
- "testID": undefined,
30
+ "style": undefined,
31
+ "testID": "portal-host",
39
32
  },
40
33
  "type": "View",
41
34
  },
@@ -123,15 +116,8 @@ exports[`TerrenoProvider renders with openAPISpecUrl 1`] = `
123
116
  },
124
117
  ],
125
118
  "props": {
126
- "collapsable": false,
127
- "pointerEvents": "box-none",
128
- "style": [
129
- {
130
- "flex": 1,
131
- },
132
- undefined,
133
- ],
134
- "testID": undefined,
119
+ "style": undefined,
120
+ "testID": "portal-host",
135
121
  },
136
122
  "type": "View",
137
123
  },
@@ -66,7 +66,6 @@ exports[`TimezonePicker renders correctly with default props 1`] = `
66
66
  },
67
67
  ],
68
68
  "props": {
69
- "activeOpacity": 1,
70
69
  "onPress": [Function],
71
70
  "style": {
72
71
  "alignItems": "center",
@@ -381,7 +380,6 @@ exports[`TimezonePicker hides title when hideTitle is true 1`] = `
381
380
  },
382
381
  ],
383
382
  "props": {
384
- "activeOpacity": 1,
385
383
  "onPress": [Function],
386
384
  "style": {
387
385
  "alignItems": "center",
@@ -711,7 +709,6 @@ exports[`TimezonePicker renders with selected timezone 1`] = `
711
709
  },
712
710
  ],
713
711
  "props": {
714
- "activeOpacity": 1,
715
712
  "onPress": [Function],
716
713
  "style": {
717
714
  "alignItems": "center",
@@ -1041,7 +1038,6 @@ exports[`TimezonePicker renders USA timezones by default 1`] = `
1041
1038
  },
1042
1039
  ],
1043
1040
  "props": {
1044
- "activeOpacity": 1,
1045
1041
  "onPress": [Function],
1046
1042
  "style": {
1047
1043
  "alignItems": "center",
@@ -1371,7 +1367,6 @@ exports[`TimezonePicker renders with short timezone labels 1`] = `
1371
1367
  },
1372
1368
  ],
1373
1369
  "props": {
1374
- "activeOpacity": 1,
1375
1370
  "onPress": [Function],
1376
1371
  "style": {
1377
1372
  "alignItems": "center",
@@ -1701,7 +1696,6 @@ exports[`TimezonePicker calls onChange when timezone is selected 1`] = `
1701
1696
  },
1702
1697
  ],
1703
1698
  "props": {
1704
- "activeOpacity": 1,
1705
1699
  "onPress": [Function],
1706
1700
  "style": {
1707
1701
  "alignItems": "center",
package/src/bunSetup.ts CHANGED
@@ -530,6 +530,28 @@ mock.module("react-native-signature-canvas", () => ({
530
530
  Signature: mock(() => null),
531
531
  }));
532
532
 
533
+ // Mock react-signature-canvas (web). The real module references `window` at
534
+ // import time, which doesn't exist under bun test.
535
+ mock.module("react-signature-canvas", () => {
536
+ const SignatureCanvasMock = React.forwardRef(
537
+ ({backgroundColor}: {backgroundColor?: string}, _ref) =>
538
+ React.createElement("View", {style: {backgroundColor}, testID: "signature-canvas"})
539
+ );
540
+ return {__esModule: true, default: SignatureCanvasMock};
541
+ });
542
+
543
+ // Mock react-native-portalize. The real `Host` wraps children in an extra View
544
+ // whose presence makes snapshots brittle, and individual tests already mock
545
+ // this to render inline; hoisting the mock to setup keeps test ordering from
546
+ // leaking different shapes into other test files. Shape matches the per-file
547
+ // mock used by Tooltip.test.tsx so the two don't disagree.
548
+ mock.module("react-native-portalize", () => ({
549
+ Host: ({children}: MockComponentProps) =>
550
+ React.createElement("View", {style: undefined, testID: "portal-host"}, children),
551
+ Portal: ({children}: MockComponentProps) =>
552
+ React.createElement("View", {style: undefined, testID: "portal"}, children),
553
+ }));
554
+
533
555
  // Mock IconButton component
534
556
  mock.module("./IconButton", () => ({
535
557
  IconButton: mock(() => null),
@@ -485,7 +485,6 @@ exports[`TableBadge renders select field when editing 1`] = `
485
485
  },
486
486
  ],
487
487
  "props": {
488
- "activeOpacity": 1,
489
488
  "onPress": [Function],
490
489
  "style": {
491
490
  "alignItems": "center",
@@ -149,4 +149,29 @@ describe("useConsentForms", () => {
149
149
  const {result} = renderHook(() => useConsentForms(api as unknown as ConsentFormsApi));
150
150
  expect(result.current.error).toBe("error");
151
151
  });
152
+
153
+ it("injects the pending-consents endpoint only once per (api, baseUrl)", () => {
154
+ let injectCallCount = 0;
155
+ const refetch = mock(() => {});
156
+ const useGetPendingConsentsQuery = mock(() => ({
157
+ data: undefined,
158
+ error: undefined,
159
+ isLoading: false,
160
+ refetch,
161
+ }));
162
+ const api = {
163
+ enhanceEndpoints: () => ({
164
+ injectEndpoints: () => {
165
+ injectCallCount += 1;
166
+ return {useGetPendingConsentsQuery};
167
+ },
168
+ }),
169
+ };
170
+ const {rerender} = renderHook(() => useConsentForms(api as unknown as ConsentFormsApi));
171
+ rerender(undefined);
172
+ rerender(undefined);
173
+ // The hook reuses the cached enhanced api after the first render so the
174
+ // dev-mode RTK warning about re-injecting endpoints never fires.
175
+ expect(injectCallCount).toBe(1);
176
+ });
152
177
  });
@@ -41,13 +41,15 @@ interface ConsentFormsHookState {
41
41
  refetch: () => void | Promise<void>;
42
42
  }
43
43
 
44
+ interface ConsentFormsEnhancedApi {
45
+ useGetPendingConsentsQuery: () => ConsentFormsHookState;
46
+ }
47
+
44
48
  interface ConsentFormsApiWithTags {
45
49
  injectEndpoints: (options: {
46
50
  endpoints: (build: ConsentFormsQueryBuilder) => {getPendingConsents: unknown};
47
51
  overrideExisting: boolean;
48
- }) => {
49
- useGetPendingConsentsQuery: () => ConsentFormsHookState;
50
- };
52
+ }) => ConsentFormsEnhancedApi;
51
53
  }
52
54
 
53
55
  interface ConsentFormsApi {
@@ -73,11 +75,27 @@ export const detectLocale = (): string => {
73
75
  return "en";
74
76
  };
75
77
 
76
- export const useConsentForms = (api: ConsentFormsApi, baseUrl?: string) => {
77
- const base = baseUrl || "";
78
- const apiWithConsentTags = api.enhanceEndpoints({addTagTypes: ["PendingConsents"]});
78
+ /**
79
+ * Cache the enhanced api per (api, baseUrl). `injectEndpoints` logs a console
80
+ * error in development whenever an endpoint with the same name is re-injected
81
+ * (with `overrideExisting: false`), so calling it on every render of every
82
+ * consumer would flood the console. WeakMap-by-api lets the GC reclaim entries
83
+ * when the api object is unreachable.
84
+ */
85
+ const enhancedApiCache = new WeakMap<ConsentFormsApi, Map<string, ConsentFormsEnhancedApi>>();
79
86
 
80
- const enhancedApi = apiWithConsentTags.injectEndpoints({
87
+ const getEnhancedApi = (api: ConsentFormsApi, base: string): ConsentFormsEnhancedApi => {
88
+ let byBase = enhancedApiCache.get(api);
89
+ if (!byBase) {
90
+ byBase = new Map();
91
+ enhancedApiCache.set(api, byBase);
92
+ }
93
+ const cached = byBase.get(base);
94
+ if (cached) {
95
+ return cached;
96
+ }
97
+ const apiWithConsentTags = api.enhanceEndpoints({addTagTypes: ["PendingConsents"]});
98
+ const enhanced = apiWithConsentTags.injectEndpoints({
81
99
  endpoints: (build) => ({
82
100
  getPendingConsents: build.query({
83
101
  async onQueryStarted(_arg: unknown, {queryFulfilled}) {
@@ -97,6 +115,13 @@ export const useConsentForms = (api: ConsentFormsApi, baseUrl?: string) => {
97
115
  }),
98
116
  overrideExisting: false,
99
117
  });
118
+ byBase.set(base, enhanced);
119
+ return enhanced;
120
+ };
121
+
122
+ export const useConsentForms = (api: ConsentFormsApi, baseUrl?: string) => {
123
+ const base = baseUrl || "";
124
+ const enhancedApi = getEnhancedApi(api, base);
100
125
 
101
126
  const {data, isLoading, error, refetch} = enhancedApi.useGetPendingConsentsQuery();
102
127
  const forms: ConsentFormPublic[] = Array.isArray(data) ? data : (data?.data ?? []);