@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.
- package/dist/ConsentFormScreen.js +8 -7
- package/dist/ConsentFormScreen.js.map +1 -1
- package/dist/PickerSelect.d.ts +22 -10
- package/dist/PickerSelect.js +11 -9
- package/dist/PickerSelect.js.map +1 -1
- package/dist/SelectBadge.js +11 -1
- package/dist/SelectBadge.js.map +1 -1
- package/dist/SelectField.js +2 -2
- package/dist/SelectField.js.map +1 -1
- package/dist/SidebarNavigation.native.js.map +1 -1
- package/dist/Signature.js +4 -0
- package/dist/Signature.js.map +1 -1
- package/dist/Signature.native.js +4 -0
- package/dist/Signature.native.js.map +1 -1
- package/dist/Theme.d.ts +1 -1
- package/dist/Theme.js.map +1 -1
- package/dist/useConsentForms.d.ts +4 -3
- package/dist/useConsentForms.js +26 -4
- package/dist/useConsentForms.js.map +1 -1
- package/dist/useConsentHistory.d.ts +4 -3
- package/dist/useConsentHistory.js +26 -4
- package/dist/useConsentHistory.js.map +1 -1
- package/dist/useSubmitConsent.d.ts +7 -6
- package/dist/useSubmitConsent.js +25 -3
- package/dist/useSubmitConsent.js.map +1 -1
- package/package.json +1 -1
- package/src/ConsentFormScreen.test.tsx +47 -0
- package/src/ConsentFormScreen.tsx +11 -0
- package/src/HeightField.test.tsx +68 -0
- package/src/Modal.tsx +2 -2
- package/src/PickerSelect.tsx +48 -34
- package/src/SelectBadge.tsx +7 -3
- package/src/SelectField.tsx +2 -2
- package/src/SidebarNavigation.native.tsx +6 -2
- package/src/Signature.native.tsx +4 -0
- package/src/Signature.test.tsx +10 -0
- package/src/Signature.tsx +4 -0
- package/src/Theme.tsx +17 -14
- package/src/Tooltip.test.tsx +41 -22
- package/src/__snapshots__/AddressField.test.tsx.snap +0 -1
- package/src/__snapshots__/CustomSelectField.test.tsx.snap +0 -7
- package/src/__snapshots__/Field.test.tsx.snap +0 -6
- package/src/__snapshots__/PickerSelect.test.tsx.snap +0 -7
- package/src/__snapshots__/SelectField.test.tsx.snap +0 -6
- package/src/__snapshots__/TerrenoProvider.test.tsx.snap +4 -18
- package/src/__snapshots__/TimezonePicker.test.tsx.snap +0 -6
- package/src/bunSetup.ts +22 -0
- package/src/table/__snapshots__/TableBadge.test.tsx.snap +0 -1
- package/src/useConsentForms.test.ts +25 -0
- package/src/useConsentForms.ts +32 -7
- package/src/useConsentHistory.test.ts +99 -0
- package/src/useConsentHistory.ts +31 -6
- package/src/useSubmitConsent.test.ts +24 -0
- package/src/useSubmitConsent.ts +35 -10
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Cache the enhanced api per (api, baseUrl). `injectEndpoints` logs a console
|
|
3
|
+
* error in development whenever an endpoint with the same name is re-injected
|
|
4
|
+
* (with `overrideExisting: false`), so calling it on every render of every
|
|
5
|
+
* consumer would flood the console. WeakMap-by-api lets the GC reclaim entries
|
|
6
|
+
* when the api object is unreachable.
|
|
7
|
+
*/
|
|
8
|
+
const enhancedApiCache = new WeakMap();
|
|
9
|
+
const getEnhancedApi = (api, base) => {
|
|
10
|
+
let byBase = enhancedApiCache.get(api);
|
|
11
|
+
if (!byBase) {
|
|
12
|
+
byBase = new Map();
|
|
13
|
+
enhancedApiCache.set(api, byBase);
|
|
14
|
+
}
|
|
15
|
+
const cached = byBase.get(base);
|
|
16
|
+
if (cached) {
|
|
17
|
+
return cached;
|
|
18
|
+
}
|
|
19
|
+
const enhanced = api.injectEndpoints({
|
|
5
20
|
endpoints: (build) => ({
|
|
6
21
|
getMyConsents: build.query({
|
|
7
22
|
providesTags: ["MyConsents"],
|
|
@@ -10,6 +25,13 @@ export const useConsentHistory = (api, baseUrl) => {
|
|
|
10
25
|
}),
|
|
11
26
|
overrideExisting: false,
|
|
12
27
|
});
|
|
28
|
+
byBase.set(base, enhanced);
|
|
29
|
+
return enhanced;
|
|
30
|
+
};
|
|
31
|
+
export const useConsentHistory = (api, baseUrl) => {
|
|
32
|
+
var _a;
|
|
33
|
+
const base = baseUrl || "";
|
|
34
|
+
const enhancedApi = getEnhancedApi(api, base);
|
|
13
35
|
const { data, isLoading, error, refetch } = enhancedApi.useGetMyConsentsQuery();
|
|
14
36
|
const entries = Array.isArray(data) ? data : ((_a = data === null || data === void 0 ? void 0 : data.data) !== null && _a !== void 0 ? _a : []);
|
|
15
37
|
return { entries, error, isLoading, refetch };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useConsentHistory.js","sourceRoot":"","sources":["../src/useConsentHistory.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useConsentHistory.js","sourceRoot":"","sources":["../src/useConsentHistory.ts"],"names":[],"mappings":"AAgDA;;;;;;GAMG;AACH,MAAM,gBAAgB,GAAG,IAAI,OAAO,EAA6D,CAAC;AAElG,MAAM,cAAc,GAAG,CAAC,GAAsB,EAAE,IAAY,EAA6B,EAAE;IACzF,IAAI,MAAM,GAAG,gBAAgB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACvC,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;QACnB,gBAAgB,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACpC,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAChC,IAAI,MAAM,EAAE,CAAC;QACX,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,MAAM,QAAQ,GAAG,GAAG,CAAC,eAAe,CAAC;QACnC,SAAS,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;YACrB,aAAa,EAAE,KAAK,CAAC,KAAK,CAAC;gBACzB,YAAY,EAAE,CAAC,YAAY,CAAC;gBAC5B,KAAK,EAAE,GAAG,EAAE,CAAC,GAAG,IAAI,cAAc;aACnC,CAAC;SACH,CAAC;QACF,gBAAgB,EAAE,KAAK;KACxB,CAAC,CAAC;IACH,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC3B,OAAO,QAAQ,CAAC;AAClB,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,GAAsB,EAAE,OAAgB,EAAE,EAAE;;IAC5E,MAAM,IAAI,GAAG,OAAO,IAAI,EAAE,CAAC;IAC3B,MAAM,WAAW,GAAG,cAAc,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAE9C,MAAM,EAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAC,GAAG,WAAW,CAAC,qBAAqB,EAAE,CAAC;IAC9E,MAAM,OAAO,GAA0B,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAA,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,IAAI,mCAAI,EAAE,CAAC,CAAC;IAEvF,OAAO,EAAC,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAC,CAAC;AAC9C,CAAC,CAAC"}
|
|
@@ -22,18 +22,19 @@ interface SubmitConsentMutationBuilder {
|
|
|
22
22
|
};
|
|
23
23
|
}) => unknown;
|
|
24
24
|
}
|
|
25
|
+
interface SubmitConsentEnhancedApi {
|
|
26
|
+
useSubmitConsentResponseMutation: () => [
|
|
27
|
+
(body: SubmitConsentBody) => SubmitConsentMutationResult,
|
|
28
|
+
SubmitConsentMutationHookState
|
|
29
|
+
];
|
|
30
|
+
}
|
|
25
31
|
interface SubmitConsentApiWithTags {
|
|
26
32
|
injectEndpoints: (options: {
|
|
27
33
|
endpoints: (build: SubmitConsentMutationBuilder) => {
|
|
28
34
|
submitConsentResponse: unknown;
|
|
29
35
|
};
|
|
30
36
|
overrideExisting: boolean;
|
|
31
|
-
}) =>
|
|
32
|
-
useSubmitConsentResponseMutation: () => [
|
|
33
|
-
(body: SubmitConsentBody) => SubmitConsentMutationResult,
|
|
34
|
-
SubmitConsentMutationHookState
|
|
35
|
-
];
|
|
36
|
-
};
|
|
37
|
+
}) => SubmitConsentEnhancedApi;
|
|
37
38
|
}
|
|
38
39
|
interface SubmitConsentApi {
|
|
39
40
|
enhanceEndpoints: (options: {
|
package/dist/useSubmitConsent.js
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Cache the enhanced api per (api, baseUrl). `injectEndpoints` logs a console
|
|
3
|
+
* error in development whenever an endpoint with the same name is re-injected
|
|
4
|
+
* (with `overrideExisting: false`), so calling it on every render of every
|
|
5
|
+
* consumer would flood the console. WeakMap-by-api lets the GC reclaim entries
|
|
6
|
+
* when the api object is unreachable.
|
|
7
|
+
*/
|
|
8
|
+
const enhancedApiCache = new WeakMap();
|
|
9
|
+
const getEnhancedApi = (api, base) => {
|
|
10
|
+
let byBase = enhancedApiCache.get(api);
|
|
11
|
+
if (!byBase) {
|
|
12
|
+
byBase = new Map();
|
|
13
|
+
enhancedApiCache.set(api, byBase);
|
|
14
|
+
}
|
|
15
|
+
const cached = byBase.get(base);
|
|
16
|
+
if (cached) {
|
|
17
|
+
return cached;
|
|
18
|
+
}
|
|
3
19
|
const apiWithConsentTags = api.enhanceEndpoints({ addTagTypes: ["PendingConsents"] });
|
|
4
|
-
const
|
|
20
|
+
const enhanced = apiWithConsentTags.injectEndpoints({
|
|
5
21
|
endpoints: (build) => ({
|
|
6
22
|
submitConsentResponse: build.mutation({
|
|
7
23
|
invalidatesTags: ["PendingConsents"],
|
|
@@ -14,6 +30,12 @@ export const useSubmitConsent = (api, baseUrl) => {
|
|
|
14
30
|
}),
|
|
15
31
|
overrideExisting: false,
|
|
16
32
|
});
|
|
33
|
+
byBase.set(base, enhanced);
|
|
34
|
+
return enhanced;
|
|
35
|
+
};
|
|
36
|
+
export const useSubmitConsent = (api, baseUrl) => {
|
|
37
|
+
const base = baseUrl || "";
|
|
38
|
+
const enhancedApi = getEnhancedApi(api, base);
|
|
17
39
|
const [submitMutation, { isLoading: isSubmitting, error }] = enhancedApi.useSubmitConsentResponseMutation();
|
|
18
40
|
const submit = async (body) => {
|
|
19
41
|
return submitMutation(body).unwrap();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSubmitConsent.js","sourceRoot":"","sources":["../src/useSubmitConsent.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useSubmitConsent.js","sourceRoot":"","sources":["../src/useSubmitConsent.ts"],"names":[],"mappings":"AA8CA;;;;;;GAMG;AACH,MAAM,gBAAgB,GAAG,IAAI,OAAO,EAA2D,CAAC;AAEhG,MAAM,cAAc,GAAG,CAAC,GAAqB,EAAE,IAAY,EAA4B,EAAE;IACvF,IAAI,MAAM,GAAG,gBAAgB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACvC,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;QACnB,gBAAgB,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACpC,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAChC,IAAI,MAAM,EAAE,CAAC;QACX,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,MAAM,kBAAkB,GAAG,GAAG,CAAC,gBAAgB,CAAC,EAAC,WAAW,EAAE,CAAC,iBAAiB,CAAC,EAAC,CAAC,CAAC;IACpF,MAAM,QAAQ,GAAG,kBAAkB,CAAC,eAAe,CAAC;QAClD,SAAS,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;YACrB,qBAAqB,EAAE,KAAK,CAAC,QAAQ,CAAC;gBACpC,eAAe,EAAE,CAAC,iBAAiB,CAAC;gBACpC,KAAK,EAAE,CAAC,IAAuB,EAAE,EAAE,CAAC,CAAC;oBACnC,IAAI;oBACJ,MAAM,EAAE,MAAM;oBACd,GAAG,EAAE,GAAG,IAAI,mBAAmB;iBAChC,CAAC;aACH,CAAC;SACH,CAAC;QACF,gBAAgB,EAAE,KAAK;KACxB,CAAC,CAAC;IACH,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC3B,OAAO,QAAQ,CAAC;AAClB,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,GAAqB,EAAE,OAAgB,EAAE,EAAE;IAC1E,MAAM,IAAI,GAAG,OAAO,IAAI,EAAE,CAAC;IAC3B,MAAM,WAAW,GAAG,cAAc,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAE9C,MAAM,CAAC,cAAc,EAAE,EAAC,SAAS,EAAE,YAAY,EAAE,KAAK,EAAC,CAAC,GACtD,WAAW,CAAC,gCAAgC,EAAE,CAAC;IAEjD,MAAM,MAAM,GAAG,KAAK,EAAE,IAAuB,EAAE,EAAE;QAC/C,OAAO,cAAc,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC;IACvC,CAAC,CAAC;IAEF,OAAO,EAAC,KAAK,EAAE,YAAY,EAAE,MAAM,EAAC,CAAC;AACvC,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -185,4 +185,51 @@ describe("ConsentFormScreen", () => {
|
|
|
185
185
|
expect(getByTestId("consent-footer-scroll-hint")).toBeTruthy();
|
|
186
186
|
expect(getByTestId("consent-footer-signature-hint")).toBeTruthy();
|
|
187
187
|
});
|
|
188
|
+
|
|
189
|
+
it("shows the required-items legend when any checkbox is required", () => {
|
|
190
|
+
const form: ConsentFormPublic = {
|
|
191
|
+
...baseForm,
|
|
192
|
+
checkboxes: [{label: "Required box", required: true}],
|
|
193
|
+
};
|
|
194
|
+
const {getByTestId} = renderWithTheme(
|
|
195
|
+
<ConsentFormScreen form={form} locale="en" onAgree={() => {}} />
|
|
196
|
+
);
|
|
197
|
+
expect(getByTestId("consent-form-required-legend")).toBeTruthy();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("hides the required-items legend when no checkbox is required", () => {
|
|
201
|
+
const form: ConsentFormPublic = {
|
|
202
|
+
...baseForm,
|
|
203
|
+
checkboxes: [{label: "Optional box", required: false}],
|
|
204
|
+
};
|
|
205
|
+
const {queryByTestId} = renderWithTheme(
|
|
206
|
+
<ConsentFormScreen form={form} locale="en" onAgree={() => {}} />
|
|
207
|
+
);
|
|
208
|
+
expect(queryByTestId("consent-form-required-legend")).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("shows the checkbox footer hint when a required checkbox is unchecked", () => {
|
|
212
|
+
const form: ConsentFormPublic = {
|
|
213
|
+
...baseForm,
|
|
214
|
+
checkboxes: [{label: "Required box", required: true}],
|
|
215
|
+
};
|
|
216
|
+
const {getByTestId} = renderWithTheme(
|
|
217
|
+
<ConsentFormScreen form={form} locale="en" onAgree={() => {}} />
|
|
218
|
+
);
|
|
219
|
+
expect(getByTestId("consent-footer-checkboxes-hint")).toBeTruthy();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("hides the checkbox footer hint once required checkboxes are checked", () => {
|
|
223
|
+
const form: ConsentFormPublic = {
|
|
224
|
+
...baseForm,
|
|
225
|
+
checkboxes: [{label: "Required box", required: true}],
|
|
226
|
+
};
|
|
227
|
+
const {getByTestId, queryByTestId} = renderWithTheme(
|
|
228
|
+
<ConsentFormScreen form={form} locale="en" onAgree={() => {}} />
|
|
229
|
+
);
|
|
230
|
+
act(() => {
|
|
231
|
+
fireEvent.press(getByTestId("consent-form-checkbox-0"));
|
|
232
|
+
});
|
|
233
|
+
expect(queryByTestId("consent-footer-checkboxes-hint")).toBeNull();
|
|
234
|
+
});
|
|
188
235
|
});
|
|
@@ -50,6 +50,7 @@ export const ConsentFormScreen: React.FC<ConsentFormScreenProps> = ({
|
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
const signatureProvided = !form.captureSignature || Boolean(signatureValue);
|
|
53
|
+
const hasRequiredCheckboxes = form.checkboxes.some((checkbox) => checkbox.required);
|
|
53
54
|
|
|
54
55
|
const canAgree = hasScrolledToBottom && allRequiredCheckboxesChecked && signatureProvided;
|
|
55
56
|
|
|
@@ -145,6 +146,11 @@ export const ConsentFormScreen: React.FC<ConsentFormScreenProps> = ({
|
|
|
145
146
|
Please scroll to the bottom to continue
|
|
146
147
|
</Text>
|
|
147
148
|
)}
|
|
149
|
+
{Boolean(hasRequiredCheckboxes && !allRequiredCheckboxesChecked) && (
|
|
150
|
+
<Text align="center" color="error" size="sm" testID="consent-footer-checkboxes-hint">
|
|
151
|
+
Please check all required items marked with *
|
|
152
|
+
</Text>
|
|
153
|
+
)}
|
|
148
154
|
{Boolean(form.captureSignature && !signatureValue) && (
|
|
149
155
|
<Text align="center" color="error" size="sm" testID="consent-footer-signature-hint">
|
|
150
156
|
Please provide your signature to continue
|
|
@@ -169,6 +175,11 @@ export const ConsentFormScreen: React.FC<ConsentFormScreenProps> = ({
|
|
|
169
175
|
|
|
170
176
|
{form.checkboxes.length > 0 && (
|
|
171
177
|
<Box direction="column" gap={2} testID="consent-form-checkboxes">
|
|
178
|
+
{hasRequiredCheckboxes && (
|
|
179
|
+
<Text color="secondaryDark" size="sm" testID="consent-form-required-legend">
|
|
180
|
+
* indicates a required item
|
|
181
|
+
</Text>
|
|
182
|
+
)}
|
|
172
183
|
{form.checkboxes.map((checkbox, index) => {
|
|
173
184
|
const key = index.toString();
|
|
174
185
|
const isChecked = checkboxValues[key] ?? false;
|
package/src/HeightField.test.tsx
CHANGED
|
@@ -206,3 +206,71 @@ describe("HeightField", () => {
|
|
|
206
206
|
});
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
describe("HeightField - Android platform", () => {
|
|
211
|
+
// Toggle Platform.OS to "android" to exercise the Android rendering branch
|
|
212
|
+
// that uses SelectField pickers instead of the Pressable+ActionSheet path.
|
|
213
|
+
const {Platform} = require("react-native") as {Platform: {OS: string}};
|
|
214
|
+
const originalOS = Platform.OS;
|
|
215
|
+
|
|
216
|
+
beforeEach(() => {
|
|
217
|
+
Platform.OS = "android";
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
afterEach(() => {
|
|
221
|
+
Platform.OS = originalOS;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("renders Android pickers with title, helperText, and errorText", () => {
|
|
225
|
+
const onChange = mock(() => {});
|
|
226
|
+
const {getByText, queryByLabelText} = renderWithTheme(
|
|
227
|
+
<HeightField
|
|
228
|
+
errorText="Required"
|
|
229
|
+
helperText="Enter height"
|
|
230
|
+
onChange={onChange}
|
|
231
|
+
title="Height"
|
|
232
|
+
value="70"
|
|
233
|
+
/>
|
|
234
|
+
);
|
|
235
|
+
// Title and helper/error rendered
|
|
236
|
+
expect(getByText("Height")).toBeTruthy();
|
|
237
|
+
expect(getByText("Enter height")).toBeTruthy();
|
|
238
|
+
expect(getByText("Required")).toBeTruthy();
|
|
239
|
+
// The Pressable from the iOS branch should NOT be present.
|
|
240
|
+
expect(queryByLabelText("Height selector")).toBeNull();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("renders Android pickers in disabled state", () => {
|
|
244
|
+
const onChange = mock(() => {});
|
|
245
|
+
const {root} = renderWithTheme(
|
|
246
|
+
<HeightField disabled onChange={onChange} title="Height" value="60" />
|
|
247
|
+
);
|
|
248
|
+
expect(root).toBeTruthy();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("forwards feet picker changes to onChange (Android)", () => {
|
|
252
|
+
const onChange = mock(() => {});
|
|
253
|
+
const {SelectField} = require("./SelectField") as {
|
|
254
|
+
SelectField: React.ComponentType<{onChange?: (v: string) => void}>;
|
|
255
|
+
};
|
|
256
|
+
const {UNSAFE_getAllByType} = renderWithTheme(<HeightField onChange={onChange} value="70" />);
|
|
257
|
+
const selects = UNSAFE_getAllByType(SelectField);
|
|
258
|
+
expect(selects.length).toBe(2);
|
|
259
|
+
// First SelectField is feet. value="70" → 5ft 10in. Bumping feet to 6 yields 6*12+10=82.
|
|
260
|
+
selects[0].props.onChange?.("6");
|
|
261
|
+
expect(onChange).toHaveBeenCalledWith("82");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("forwards inches picker changes to onChange (Android)", () => {
|
|
265
|
+
const onChange = mock(() => {});
|
|
266
|
+
const {SelectField} = require("./SelectField") as {
|
|
267
|
+
SelectField: React.ComponentType<{onChange?: (v: string) => void}>;
|
|
268
|
+
};
|
|
269
|
+
const {UNSAFE_getAllByType} = renderWithTheme(<HeightField onChange={onChange} value="70" />);
|
|
270
|
+
const selects = UNSAFE_getAllByType(SelectField);
|
|
271
|
+
expect(selects.length).toBe(2);
|
|
272
|
+
// Second SelectField is inches. value="70" → 5ft 10in. Changing inches to 3 yields 5*12+3=63.
|
|
273
|
+
selects[1].props.onChange?.("3");
|
|
274
|
+
expect(onChange).toHaveBeenCalledWith("63");
|
|
275
|
+
});
|
|
276
|
+
});
|
package/src/Modal.tsx
CHANGED
|
@@ -12,7 +12,7 @@ import {Gesture, GestureDetector} from "react-native-gesture-handler";
|
|
|
12
12
|
import {runOnJS} from "react-native-reanimated";
|
|
13
13
|
|
|
14
14
|
import {Button} from "./Button";
|
|
15
|
-
import type {ModalProps} from "./Common";
|
|
15
|
+
import type {ModalProps, TerrenoTheme} from "./Common";
|
|
16
16
|
import {Heading} from "./Heading";
|
|
17
17
|
import {Icon} from "./Icon";
|
|
18
18
|
import {isMobileDevice} from "./MediaQuery";
|
|
@@ -45,7 +45,7 @@ const ModalContent: FC<{
|
|
|
45
45
|
secondaryButtonOnClick?: ModalProps["secondaryButtonOnClick"];
|
|
46
46
|
onDismiss: ModalProps["onDismiss"];
|
|
47
47
|
sizePx: DimensionValue;
|
|
48
|
-
theme:
|
|
48
|
+
theme: TerrenoTheme;
|
|
49
49
|
isMobile: boolean;
|
|
50
50
|
}> = ({
|
|
51
51
|
children,
|
package/src/PickerSelect.tsx
CHANGED
|
@@ -25,15 +25,18 @@
|
|
|
25
25
|
|
|
26
26
|
import {Picker} from "@react-native-picker/picker";
|
|
27
27
|
import isEqual from "lodash/isEqual";
|
|
28
|
-
import {useCallback, useEffect, useMemo, useState} from "react";
|
|
28
|
+
import {type ComponentType, type ReactNode, useCallback, useEffect, useMemo, useState} from "react";
|
|
29
29
|
import {
|
|
30
30
|
Keyboard,
|
|
31
31
|
Modal,
|
|
32
|
+
type ModalProps,
|
|
32
33
|
Platform,
|
|
33
34
|
Pressable,
|
|
35
|
+
type PressableProps,
|
|
34
36
|
StyleSheet,
|
|
35
37
|
Text,
|
|
36
38
|
TextInput,
|
|
39
|
+
type TextInputProps,
|
|
37
40
|
TouchableOpacity,
|
|
38
41
|
View,
|
|
39
42
|
} from "react-native";
|
|
@@ -67,14 +70,23 @@ export const defaultStyles = StyleSheet.create({
|
|
|
67
70
|
},
|
|
68
71
|
});
|
|
69
72
|
|
|
73
|
+
/** A single option for the picker select component. */
|
|
74
|
+
export interface PickerSelectItem {
|
|
75
|
+
label: string;
|
|
76
|
+
value: string | number | null;
|
|
77
|
+
key?: string | number;
|
|
78
|
+
color?: string;
|
|
79
|
+
inputLabel?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
70
82
|
export interface RNPickerSelectProps {
|
|
71
|
-
onValueChange: (value:
|
|
72
|
-
items:
|
|
73
|
-
value?:
|
|
74
|
-
placeholder?:
|
|
83
|
+
onValueChange: (value: string | number | null, index: number) => void;
|
|
84
|
+
items: PickerSelectItem[];
|
|
85
|
+
value?: string | number | null;
|
|
86
|
+
placeholder?: Partial<PickerSelectItem>;
|
|
75
87
|
disabled?: boolean;
|
|
76
88
|
itemKey?: string | number;
|
|
77
|
-
children?:
|
|
89
|
+
children?: ReactNode;
|
|
78
90
|
onOpen?: () => void;
|
|
79
91
|
useNativeAndroidPickerStyle?: boolean;
|
|
80
92
|
fixAndroidTouchableBug?: boolean;
|
|
@@ -87,18 +99,18 @@ export interface RNPickerSelectProps {
|
|
|
87
99
|
onClose?: () => void;
|
|
88
100
|
|
|
89
101
|
// Modal props (iOS only)
|
|
90
|
-
modalProps?:
|
|
102
|
+
modalProps?: Partial<ModalProps>;
|
|
91
103
|
|
|
92
104
|
// TextInput props
|
|
93
|
-
textInputProps?:
|
|
105
|
+
textInputProps?: Partial<TextInputProps>;
|
|
94
106
|
|
|
95
107
|
// Touchable Done props (iOS only)
|
|
96
|
-
touchableDoneProps?:
|
|
108
|
+
touchableDoneProps?: Partial<PressableProps>;
|
|
97
109
|
|
|
98
110
|
// Touchable wrapper props
|
|
99
|
-
touchableWrapperProps?:
|
|
111
|
+
touchableWrapperProps?: Partial<PressableProps>;
|
|
100
112
|
|
|
101
|
-
InputAccessoryView?:
|
|
113
|
+
InputAccessoryView?: ComponentType<{testID?: string}>;
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
export function RNPickerSelect({
|
|
@@ -125,7 +137,9 @@ export function RNPickerSelect({
|
|
|
125
137
|
InputAccessoryView,
|
|
126
138
|
}: RNPickerSelectProps) {
|
|
127
139
|
const [showPicker, setShowPicker] = useState<boolean>(false);
|
|
128
|
-
const [animationType, setAnimationType] = useState(
|
|
140
|
+
const [animationType, setAnimationType] = useState<"none" | "slide" | "fade" | undefined>(
|
|
141
|
+
undefined
|
|
142
|
+
);
|
|
129
143
|
const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait");
|
|
130
144
|
const [doneDepressed, setDoneDepressed] = useState<boolean>(false);
|
|
131
145
|
const {theme} = useTheme();
|
|
@@ -159,12 +173,12 @@ export function RNPickerSelect({
|
|
|
159
173
|
}, [items, placeholder]);
|
|
160
174
|
|
|
161
175
|
const getSelectedItem = useCallback(
|
|
162
|
-
(key:
|
|
163
|
-
let idx = options.findIndex((item
|
|
164
|
-
if (item
|
|
176
|
+
(key: string | number | undefined, val: string | number | null | undefined) => {
|
|
177
|
+
let idx = options.findIndex((item) => {
|
|
178
|
+
if (item?.key && key) {
|
|
165
179
|
return isEqual(item.key, key);
|
|
166
180
|
}
|
|
167
|
-
return isEqual(item
|
|
181
|
+
return isEqual(item?.value, val);
|
|
168
182
|
});
|
|
169
183
|
if (idx === -1) {
|
|
170
184
|
idx = 0;
|
|
@@ -177,7 +191,7 @@ export function RNPickerSelect({
|
|
|
177
191
|
[options]
|
|
178
192
|
);
|
|
179
193
|
|
|
180
|
-
const [selectedItem, setSelectedItem] = useState<
|
|
194
|
+
const [selectedItem, setSelectedItem] = useState<Partial<PickerSelectItem>>(() => {
|
|
181
195
|
return getSelectedItem(itemKey, value).selectedItem;
|
|
182
196
|
});
|
|
183
197
|
|
|
@@ -195,13 +209,17 @@ export function RNPickerSelect({
|
|
|
195
209
|
togglePicker(false, onDownArrow);
|
|
196
210
|
};
|
|
197
211
|
|
|
198
|
-
const onValueChangeEvent = (val:
|
|
212
|
+
const onValueChangeEvent = (val: string | number | null, index: number) => {
|
|
199
213
|
const item = getSelectedItem(itemKey, val);
|
|
200
214
|
onValueChange(val, index);
|
|
201
215
|
setSelectedItem(item.selectedItem);
|
|
202
216
|
};
|
|
203
217
|
|
|
204
|
-
const onOrientationChange = ({
|
|
218
|
+
const onOrientationChange = ({
|
|
219
|
+
nativeEvent,
|
|
220
|
+
}: {
|
|
221
|
+
nativeEvent: {orientation: "portrait" | "landscape"};
|
|
222
|
+
}) => {
|
|
205
223
|
setOrientation(nativeEvent.orientation);
|
|
206
224
|
};
|
|
207
225
|
|
|
@@ -215,7 +233,7 @@ export function RNPickerSelect({
|
|
|
215
233
|
}
|
|
216
234
|
};
|
|
217
235
|
|
|
218
|
-
const togglePicker = (animate = false, postToggleCallback?:
|
|
236
|
+
const togglePicker = (animate = false, postToggleCallback?: () => void) => {
|
|
219
237
|
if (disabled) {
|
|
220
238
|
return;
|
|
221
239
|
}
|
|
@@ -237,13 +255,13 @@ export function RNPickerSelect({
|
|
|
237
255
|
};
|
|
238
256
|
|
|
239
257
|
const renderPickerItems = () => {
|
|
240
|
-
return options?.map((item
|
|
258
|
+
return options?.map((item) => {
|
|
241
259
|
return (
|
|
242
260
|
<Picker.Item
|
|
243
|
-
color={item
|
|
244
|
-
key={item
|
|
245
|
-
label={item
|
|
246
|
-
value={item
|
|
261
|
+
color={item?.color}
|
|
262
|
+
key={item?.key || item?.label}
|
|
263
|
+
label={item?.label}
|
|
264
|
+
value={item?.value}
|
|
247
265
|
/>
|
|
248
266
|
);
|
|
249
267
|
});
|
|
@@ -408,7 +426,6 @@ export function RNPickerSelect({
|
|
|
408
426
|
]}
|
|
409
427
|
>
|
|
410
428
|
<Pressable
|
|
411
|
-
activeOpacity={1}
|
|
412
429
|
onPress={() => {
|
|
413
430
|
togglePicker(true);
|
|
414
431
|
}}
|
|
@@ -467,14 +484,11 @@ export function RNPickerSelect({
|
|
|
467
484
|
};
|
|
468
485
|
|
|
469
486
|
const renderAndroidHeadless = () => {
|
|
470
|
-
|
|
487
|
+
// noExplicitAny: Component is View or Pressable depending on a bug workaround flag. View
|
|
488
|
+
// ignores Pressable-specific props (onPress) at runtime. A type-safe union cannot express this.
|
|
489
|
+
const Component = (fixAndroidTouchableBug ? View : Pressable) as unknown as typeof Pressable;
|
|
471
490
|
return (
|
|
472
|
-
<Component
|
|
473
|
-
activeOpacity={1}
|
|
474
|
-
onPress={onOpen}
|
|
475
|
-
testID="android_touchable_wrapper"
|
|
476
|
-
{...touchableWrapperProps}
|
|
477
|
-
>
|
|
491
|
+
<Component onPress={onOpen} testID="android_touchable_wrapper" {...touchableWrapperProps}>
|
|
478
492
|
<View>
|
|
479
493
|
{renderTextInputOrChildren()}
|
|
480
494
|
<Picker
|
|
@@ -636,7 +650,7 @@ export function RNPickerSelect({
|
|
|
636
650
|
// Pass the original (non-stringified) value through so lodash
|
|
637
651
|
// `isEqual` matching in `getSelectedItem` works for number /
|
|
638
652
|
// object values.
|
|
639
|
-
const originalValue = options[originalIndex]?.value;
|
|
653
|
+
const originalValue = options[originalIndex]?.value ?? null;
|
|
640
654
|
onValueChangeEvent(originalValue, originalIndex);
|
|
641
655
|
closeWebMenu();
|
|
642
656
|
}}
|
package/src/SelectBadge.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import type React from "react";
|
|
|
3
3
|
import {useCallback, useMemo, useState} from "react";
|
|
4
4
|
import {Modal, Platform, Text, TouchableOpacity, View} from "react-native";
|
|
5
5
|
|
|
6
|
-
import type {FieldOption, SelectBadgeProps, SurfaceTheme, TextTheme} from "./Common";
|
|
6
|
+
import type {FieldOption, IconColor, SelectBadgeProps, SurfaceTheme, TextTheme} from "./Common";
|
|
7
7
|
import {Icon} from "./Icon";
|
|
8
8
|
import {useTheme} from "./Theme";
|
|
9
9
|
import {useWebDropdownAnchor, WebDropdownMenu, type WebDropdownMenuOption} from "./WebDropdownMenu";
|
|
@@ -98,7 +98,7 @@ export const SelectBadge = ({
|
|
|
98
98
|
);
|
|
99
99
|
|
|
100
100
|
const renderPickerItems = useCallback(() => {
|
|
101
|
-
return options?.map((item:
|
|
101
|
+
return options?.map((item: FieldOption) => (
|
|
102
102
|
<Picker.Item key={item.key || item.label} label={item.label} value={item.value} />
|
|
103
103
|
));
|
|
104
104
|
}, [options]);
|
|
@@ -321,7 +321,11 @@ export const SelectBadge = ({
|
|
|
321
321
|
}}
|
|
322
322
|
>
|
|
323
323
|
<Icon
|
|
324
|
-
|
|
324
|
+
// noExplicitAny: textColor is a resolved hex string from the theme, but Icon's
|
|
325
|
+
// color prop expects an IconColor key. Icon falls back to the raw string at runtime
|
|
326
|
+
// (theme.text[color] ?? color), but the type cannot be narrowed without changing
|
|
327
|
+
// Icon's type signature to accept arbitrary strings.
|
|
328
|
+
color={textColor as unknown as IconColor}
|
|
325
329
|
iconName={showPicker ? "chevron-up" : "chevron-down"}
|
|
326
330
|
size="sm"
|
|
327
331
|
/>
|
package/src/SelectField.tsx
CHANGED
|
@@ -26,10 +26,10 @@ export const SelectField: FC<SelectFieldProps> = ({
|
|
|
26
26
|
disabled={disabled}
|
|
27
27
|
items={options}
|
|
28
28
|
onValueChange={(v) => {
|
|
29
|
-
if (v === undefined || v === "") {
|
|
29
|
+
if (v === undefined || v === null || v === "") {
|
|
30
30
|
onChange("");
|
|
31
31
|
} else {
|
|
32
|
-
onChange(v);
|
|
32
|
+
onChange(String(v));
|
|
33
33
|
}
|
|
34
34
|
}}
|
|
35
35
|
placeholder={!requireValue ? clearOption : {}}
|
|
@@ -5,7 +5,7 @@ import {Navigator, Slot} from "expo-router";
|
|
|
5
5
|
// update the import path here — this is the only place in the codebase that references it.
|
|
6
6
|
// eslint-disable-next-line import/no-internal-modules
|
|
7
7
|
import {Screen} from "expo-router/build/views/Screen";
|
|
8
|
-
import {type FC, useCallback, useEffect, useMemo, useRef, useState} from "react";
|
|
8
|
+
import {type FC, type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from "react";
|
|
9
9
|
import {
|
|
10
10
|
Animated,
|
|
11
11
|
Dimensions,
|
|
@@ -361,7 +361,11 @@ const SidebarHeader: FC<{onOpen: () => void}> = ({onOpen}) => {
|
|
|
361
361
|
const insets = useSafeAreaInsets();
|
|
362
362
|
const {state, descriptors} = Navigator.useContext();
|
|
363
363
|
const activeRoute = state.routes[state.index];
|
|
364
|
-
const {headerLeft, headerRight, title} = (descriptors[activeRoute?.key]?.options ?? {}) as
|
|
364
|
+
const {headerLeft, headerRight, title} = (descriptors[activeRoute?.key]?.options ?? {}) as {
|
|
365
|
+
headerLeft?: (props: object) => ReactNode;
|
|
366
|
+
headerRight?: (props: object) => ReactNode;
|
|
367
|
+
title?: string;
|
|
368
|
+
};
|
|
365
369
|
|
|
366
370
|
return (
|
|
367
371
|
<View
|
package/src/Signature.native.tsx
CHANGED
|
@@ -18,6 +18,10 @@ export const Signature: FC<Props> = ({onChange, onStart, onEnd}: Props) => {
|
|
|
18
18
|
|
|
19
19
|
const handleClear = () => {
|
|
20
20
|
ref.current?.clearSignature();
|
|
21
|
+
// `clearSignature` on the underlying canvas does not fire `onOK`, so the
|
|
22
|
+
// parent never learns the signature is gone. Push an empty value so any
|
|
23
|
+
// "signature required" gating reflects the cleared state immediately.
|
|
24
|
+
onChange("");
|
|
21
25
|
};
|
|
22
26
|
|
|
23
27
|
const onBegin = () => {
|
package/src/Signature.test.tsx
CHANGED
|
@@ -49,6 +49,16 @@ describe("Signature", () => {
|
|
|
49
49
|
expect(clearMock).toHaveBeenCalledTimes(1);
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
it("notifies the parent with an empty value when Clear is pressed", () => {
|
|
53
|
+
clearMock.mockClear();
|
|
54
|
+
const mockOnChange = mock(() => {});
|
|
55
|
+
const {getByText} = renderWithTheme(<Signature onChange={mockOnChange} />);
|
|
56
|
+
fireEvent.press(getByText("Clear"));
|
|
57
|
+
// Without this, "signature required" gating in parents would never reset
|
|
58
|
+
// because the underlying canvas clear() does not fire onEnd/onOK.
|
|
59
|
+
expect(mockOnChange).toHaveBeenCalledWith("");
|
|
60
|
+
});
|
|
61
|
+
|
|
52
62
|
it("calls onChange with the data URL when a stroke ends", () => {
|
|
53
63
|
toDataURLReturn = "data:image/png;base64,abc";
|
|
54
64
|
const mockOnChange = mock(() => {});
|
package/src/Signature.tsx
CHANGED
|
@@ -17,6 +17,10 @@ export const Signature = ({onChange}: SignatureProps): ReactElement | null => {
|
|
|
17
17
|
|
|
18
18
|
const onClear = () => {
|
|
19
19
|
ref.current?.clear();
|
|
20
|
+
// `clear()` on the underlying canvas does not fire `onEnd`, so the parent
|
|
21
|
+
// never learns the signature is gone. Push an empty value so any
|
|
22
|
+
// "signature required" gating reflects the cleared state immediately.
|
|
23
|
+
onChange("");
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
const onUpdatedSignature = () => {
|