@jobber/components-native 0.34.0 → 0.35.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.
- package/dist/src/InputTime/InputTime.js +83 -0
- package/dist/src/InputTime/InputTime.style.js +7 -0
- package/dist/src/InputTime/index.js +2 -0
- package/dist/src/InputTime/messages.js +8 -0
- package/dist/src/InputTime/utils/index.js +16 -0
- package/dist/src/index.js +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/src/InputTime/InputTime.d.ts +61 -0
- package/dist/types/src/InputTime/InputTime.style.d.ts +6 -0
- package/dist/types/src/InputTime/index.d.ts +2 -0
- package/dist/types/src/InputTime/messages.d.ts +7 -0
- package/dist/types/src/InputTime/utils/index.d.ts +11 -0
- package/dist/types/src/index.d.ts +1 -0
- package/package.json +7 -3
- package/src/InputTime/InputTime.style.ts +8 -0
- package/src/InputTime/InputTime.test.tsx +323 -0
- package/src/InputTime/InputTime.tsx +221 -0
- package/src/InputTime/index.tsx +2 -0
- package/src/InputTime/messages.ts +9 -0
- package/src/InputTime/utils/index.ts +26 -0
- package/src/InputTime/utils/utils.test.ts +47 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/// <reference types="react" />
|
|
2
|
+
import { UseControllerProps } from "react-hook-form";
|
|
3
|
+
import { XOR } from "ts-xor";
|
|
4
|
+
import { Clearable, InputFieldWrapperProps } from "../InputFieldWrapper";
|
|
5
|
+
interface InputTimeBaseProps extends Pick<InputFieldWrapperProps, "invalid" | "disabled" | "placeholder"> {
|
|
6
|
+
/**
|
|
7
|
+
* Defaulted to "always" so user can clear the time whenever there's a value.
|
|
8
|
+
*/
|
|
9
|
+
readonly clearable?: Extract<Clearable, "always" | "never">;
|
|
10
|
+
/**
|
|
11
|
+
* Add a custom value to display when no time is selected
|
|
12
|
+
* @default undefined
|
|
13
|
+
*/
|
|
14
|
+
readonly emptyValueLabel?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Adjusts the UX of the time picker based on where you'd use it.
|
|
17
|
+
*
|
|
18
|
+
* - `"granular"` - allows the user to pick a very specific time
|
|
19
|
+
* - `"scheduling"` - only allows user to select between 5 minutes interval.
|
|
20
|
+
* If your design is catered towards "scheduling", you should use this type.
|
|
21
|
+
*
|
|
22
|
+
* @default scheduling
|
|
23
|
+
*/
|
|
24
|
+
readonly type?: "granular" | "scheduling";
|
|
25
|
+
/**
|
|
26
|
+
* Hide or show the timer icon.
|
|
27
|
+
*/
|
|
28
|
+
readonly showIcon?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface InputTimeFormControlled extends InputTimeBaseProps {
|
|
31
|
+
/**
|
|
32
|
+
* Adding a `name` would make this component "Form controlled" and must be
|
|
33
|
+
* nested within a `<Form />` component.
|
|
34
|
+
*
|
|
35
|
+
* Cannot be declared if `value` prop is used.
|
|
36
|
+
*/
|
|
37
|
+
readonly name: string;
|
|
38
|
+
/**
|
|
39
|
+
* Shows an error message below the field and highlights it red when the
|
|
40
|
+
* value is invalid. Only applies when nested within a `<Form />` component.
|
|
41
|
+
*/
|
|
42
|
+
readonly validations?: UseControllerProps["rules"];
|
|
43
|
+
/**
|
|
44
|
+
* The callback that fires whenever a time gets selected.
|
|
45
|
+
*/
|
|
46
|
+
readonly onChange?: (value?: Date | null) => void;
|
|
47
|
+
}
|
|
48
|
+
interface InputTimeDevControlled extends InputTimeBaseProps {
|
|
49
|
+
/**
|
|
50
|
+
* The value shown on the field. This gets automatically formatted to the
|
|
51
|
+
* account's time format.
|
|
52
|
+
*/
|
|
53
|
+
readonly value: Date | string | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* The callback that fires whenever a time gets selected.
|
|
56
|
+
*/
|
|
57
|
+
readonly onChange: (value?: Date) => void;
|
|
58
|
+
}
|
|
59
|
+
export type InputTimeProps = XOR<InputTimeFormControlled, InputTimeDevControlled>;
|
|
60
|
+
export declare function InputTime(props: InputTimeProps): JSX.Element;
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type MinutesIncrement = 15 | 30 | 60;
|
|
2
|
+
/**
|
|
3
|
+
* Rounds up the time by increment.
|
|
4
|
+
* - 15 mins - rounds to the next quarter time of `00:15`, `00:30`, `00:45`,
|
|
5
|
+
* and `01:00`
|
|
6
|
+
* - 30 mins - rounds to the next half hour be it `00:30` or `01:00`
|
|
7
|
+
* - 60 mins - rounds to the next hour. I.e., `02:01` gets rounded up
|
|
8
|
+
* to `03:00`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function roundUpToNearestMinutes(date: Date, minutes: MinutesIncrement): Date;
|
|
11
|
+
export declare function getTimeZoneOffsetInMinutes(timeZone: string, date?: Date): number;
|
|
@@ -23,6 +23,7 @@ export * from "./InputNumber";
|
|
|
23
23
|
export * from "./InputPassword";
|
|
24
24
|
export * from "./InputPressable";
|
|
25
25
|
export * from "./InputSearch";
|
|
26
|
+
export * from "./InputTime";
|
|
26
27
|
export * from "./InputText";
|
|
27
28
|
export * from "./TextList";
|
|
28
29
|
export * from "./ProgressBar";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jobber/components-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "React Native implementation of Atlantis",
|
|
6
6
|
"repository": {
|
|
@@ -67,8 +67,12 @@
|
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
69
|
"@babel/core": "^7.4.5",
|
|
70
|
+
"@react-native-community/datetimepicker": ">=6.7.0",
|
|
71
|
+
"date-fns": "^2.0.0",
|
|
72
|
+
"date-fns-tz": "*",
|
|
70
73
|
"react": "^18",
|
|
71
|
-
"react-native": ">=0.69.2"
|
|
74
|
+
"react-native": ">=0.69.2",
|
|
75
|
+
"react-native-modal-datetime-picker": " >=13.0.0"
|
|
72
76
|
},
|
|
73
|
-
"gitHead": "
|
|
77
|
+
"gitHead": "5028c2bd4c97479b567b031432a331e3884afcaa"
|
|
74
78
|
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
cleanup,
|
|
4
|
+
fireEvent,
|
|
5
|
+
render,
|
|
6
|
+
waitFor,
|
|
7
|
+
} from "@testing-library/react-native";
|
|
8
|
+
import { Host } from "react-native-portalize";
|
|
9
|
+
import { FormProvider, useForm } from "react-hook-form";
|
|
10
|
+
import { InputTime } from "./InputTime";
|
|
11
|
+
import * as atlantisContext from "../AtlantisContext/AtlantisContext";
|
|
12
|
+
import { Button } from "../Button";
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
cleanup();
|
|
16
|
+
jest.spyOn(atlantisContext, "useAtlantisContext").mockRestore();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("Visuals", () => {
|
|
20
|
+
const placeholder = "Start time";
|
|
21
|
+
const expectedTime = "11:00 AM";
|
|
22
|
+
const value = new Date(2022, 2, 2, 11, 0);
|
|
23
|
+
const handleChange = jest.fn();
|
|
24
|
+
|
|
25
|
+
const setup = (showIcon = true) =>
|
|
26
|
+
render(
|
|
27
|
+
<InputTime
|
|
28
|
+
placeholder={placeholder}
|
|
29
|
+
value={value}
|
|
30
|
+
onChange={handleChange}
|
|
31
|
+
showIcon={showIcon}
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
it("should show a timer prefix icon", () => {
|
|
36
|
+
const screen = setup();
|
|
37
|
+
|
|
38
|
+
const timerIcon = screen.getByTestId("timer");
|
|
39
|
+
expect(timerIcon).toBeDefined();
|
|
40
|
+
expect(timerIcon.type).toBe("RNSVGSvgView");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should not show a timer icon if showIcon is false", () => {
|
|
44
|
+
const screen = setup(false);
|
|
45
|
+
const timerIcon = screen.queryByTestId("timer");
|
|
46
|
+
|
|
47
|
+
expect(timerIcon).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should show a formatted time", () => {
|
|
51
|
+
const screen = setup();
|
|
52
|
+
expect(
|
|
53
|
+
screen.getByText(expectedTime, { includeHiddenElements: true }),
|
|
54
|
+
).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should be clearable when there's a value", () => {
|
|
58
|
+
const screen = setup();
|
|
59
|
+
const clearAction = screen.getByLabelText("Clear input");
|
|
60
|
+
expect(clearAction).toBeDefined();
|
|
61
|
+
|
|
62
|
+
fireEvent.press(clearAction);
|
|
63
|
+
expect(handleChange).toHaveBeenCalledWith(undefined);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("String value", () => {
|
|
68
|
+
const handleChange = jest.fn();
|
|
69
|
+
|
|
70
|
+
it("should show a formatted time", () => {
|
|
71
|
+
const expectedTime = "11:00 AM";
|
|
72
|
+
const value = new Date(2022, 2, 2, 11, 0).toISOString();
|
|
73
|
+
const screen = render(<InputTime value={value} onChange={handleChange} />);
|
|
74
|
+
|
|
75
|
+
expect(
|
|
76
|
+
screen.getByText(expectedTime, { includeHiddenElements: true }),
|
|
77
|
+
).toBeDefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("With emptyValueLabel", () => {
|
|
82
|
+
const handleChange = jest.fn();
|
|
83
|
+
|
|
84
|
+
it("should show the emptyValueLabel when there's no value", () => {
|
|
85
|
+
const label = "Unscheduled";
|
|
86
|
+
const screen = render(<InputTime name="test" emptyValueLabel={label} />);
|
|
87
|
+
|
|
88
|
+
expect(
|
|
89
|
+
screen.getByText(label, { includeHiddenElements: true }),
|
|
90
|
+
).toBeDefined();
|
|
91
|
+
expect(
|
|
92
|
+
screen.queryByLabelText("Clear input", { includeHiddenElements: true }),
|
|
93
|
+
).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should not show the emptyValueLabel when there's a value", () => {
|
|
97
|
+
const label = "Unscheduled";
|
|
98
|
+
const screen = render(
|
|
99
|
+
<InputTime
|
|
100
|
+
emptyValueLabel={label}
|
|
101
|
+
value={new Date()}
|
|
102
|
+
onChange={handleChange}
|
|
103
|
+
/>,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(screen.queryByText(label)).toBeNull();
|
|
107
|
+
expect(screen.getByLabelText("Clear input")).toBeDefined();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("Time picker", () => {
|
|
112
|
+
const placeholder = "Tap me";
|
|
113
|
+
const handleChange = jest.fn();
|
|
114
|
+
const getType = jest.fn().mockReturnValue(undefined);
|
|
115
|
+
|
|
116
|
+
function renderTimePicker(value?: Date) {
|
|
117
|
+
const screen = render(
|
|
118
|
+
<InputTime
|
|
119
|
+
placeholder={placeholder}
|
|
120
|
+
onChange={handleChange}
|
|
121
|
+
value={value}
|
|
122
|
+
type={getType()}
|
|
123
|
+
/>,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
fireEvent.press(screen.getByLabelText(placeholder));
|
|
127
|
+
expect(screen.getByTestId("inputTime-Picker")).toBeDefined();
|
|
128
|
+
|
|
129
|
+
return screen;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
it("should not show a time picker", () => {
|
|
133
|
+
const screen = render(<InputTime name="test" />);
|
|
134
|
+
expect(screen.queryByTestId("inputTime-Picker")).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("should fire the onChange with the current value after canceling a time selection", () => {
|
|
138
|
+
const value = new Date();
|
|
139
|
+
const screen = renderTimePicker(value);
|
|
140
|
+
|
|
141
|
+
fireEvent.press(screen.getByLabelText("Cancel"));
|
|
142
|
+
expect(handleChange).toHaveBeenCalledWith(value);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should fire the onChange after confirming a time selection", () => {
|
|
146
|
+
const screen = renderTimePicker();
|
|
147
|
+
|
|
148
|
+
fireEvent.press(screen.getByLabelText("Confirm"));
|
|
149
|
+
expect(handleChange).toHaveBeenCalledWith(expect.any(Date));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should be a time picker", () => {
|
|
153
|
+
const screen = renderTimePicker();
|
|
154
|
+
expect(screen.getByTestId("inputTime-Picker").props.mode).toBe("time");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("Locale", () => {
|
|
158
|
+
it("should be set to 12 hours", () => {
|
|
159
|
+
const screen = renderTimePicker();
|
|
160
|
+
expect(screen.getByTestId("inputTime-Picker").props.locale).toBe("en_US");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should be set to 24 hours", () => {
|
|
164
|
+
jest.spyOn(atlantisContext, "useAtlantisContext").mockReturnValue({
|
|
165
|
+
...atlantisContext.defaultValues,
|
|
166
|
+
timeZone: "UTC",
|
|
167
|
+
timeFormat: "HH:mm",
|
|
168
|
+
});
|
|
169
|
+
const screen = renderTimePicker();
|
|
170
|
+
expect(screen.getByTestId("inputTime-Picker").props.locale).toBe("en_GB");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("should set the minute interval to 5 by default", () => {
|
|
175
|
+
const screen = renderTimePicker();
|
|
176
|
+
expect(screen.getByTestId("inputTime-Picker").props.minuteInterval).toBe(5);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("should set the minute interval to 5 when the type is scheduling", () => {
|
|
180
|
+
getType.mockReturnValueOnce("scheduling");
|
|
181
|
+
const screen = renderTimePicker();
|
|
182
|
+
expect(screen.getByTestId("inputTime-Picker").props.minuteInterval).toBe(5);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should set the minute interval to 1 when the type is granular", () => {
|
|
186
|
+
getType.mockReturnValueOnce("granular");
|
|
187
|
+
const screen = renderTimePicker();
|
|
188
|
+
expect(screen.getByTestId("inputTime-Picker").props.minuteInterval).toBe(1);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
const mockOnSubmit = jest.fn();
|
|
192
|
+
const saveButtonText = "Submit";
|
|
193
|
+
|
|
194
|
+
const requiredError = "This is required";
|
|
195
|
+
function SimpleFormWithProvider({ children, defaultValues }) {
|
|
196
|
+
const formMethods = useForm({
|
|
197
|
+
reValidateMode: "onChange",
|
|
198
|
+
defaultValues,
|
|
199
|
+
mode: "onTouched",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<FormProvider {...formMethods}>
|
|
204
|
+
{children}
|
|
205
|
+
<Button
|
|
206
|
+
onPress={formMethods.handleSubmit(values => mockOnSubmit(values))}
|
|
207
|
+
label={saveButtonText}
|
|
208
|
+
accessibilityLabel={saveButtonText}
|
|
209
|
+
/>
|
|
210
|
+
</FormProvider>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
describe("Form controlled", () => {
|
|
215
|
+
const pickerName = "timePicker";
|
|
216
|
+
const expectedTime = "11:00 AM";
|
|
217
|
+
const value = new Date(2022, 2, 2, 11, 0);
|
|
218
|
+
const handleChange = jest.fn();
|
|
219
|
+
|
|
220
|
+
const setup = () =>
|
|
221
|
+
render(
|
|
222
|
+
<SimpleFormWithProvider defaultValues={{ [pickerName]: value }}>
|
|
223
|
+
<Host>
|
|
224
|
+
<InputTime
|
|
225
|
+
name={pickerName}
|
|
226
|
+
onChange={handleChange}
|
|
227
|
+
validations={{ required: requiredError }}
|
|
228
|
+
/>
|
|
229
|
+
</Host>
|
|
230
|
+
</SimpleFormWithProvider>,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
it("should show the initial value", async () => {
|
|
234
|
+
const screen = setup();
|
|
235
|
+
expect(
|
|
236
|
+
screen.getByText(expectedTime, { includeHiddenElements: true }),
|
|
237
|
+
).toBeDefined();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("should update the value", async () => {
|
|
241
|
+
const screen = setup();
|
|
242
|
+
|
|
243
|
+
fireEvent.press(
|
|
244
|
+
screen.getByText(expectedTime, { includeHiddenElements: true }),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const expectedNewTime = "10:00 AM";
|
|
248
|
+
fireEvent(
|
|
249
|
+
screen.getByTestId("inputTime-Picker"),
|
|
250
|
+
"onConfirm",
|
|
251
|
+
new Date(2022, 2, 2, 10, 0),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
expect(
|
|
255
|
+
screen.getByText(expectedNewTime, { includeHiddenElements: true }),
|
|
256
|
+
).toBeDefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("should show the required message", async () => {
|
|
260
|
+
const screen = setup();
|
|
261
|
+
|
|
262
|
+
const clearAction = screen.getByLabelText("Clear input");
|
|
263
|
+
expect(clearAction).toBeDefined();
|
|
264
|
+
|
|
265
|
+
fireEvent.press(clearAction);
|
|
266
|
+
screen.debug();
|
|
267
|
+
await waitFor(() => {
|
|
268
|
+
expect(
|
|
269
|
+
screen.getByText(requiredError, { includeHiddenElements: true }),
|
|
270
|
+
).toBeDefined();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("should clear the input with null value when it is in a form", async () => {
|
|
275
|
+
const screen = setup();
|
|
276
|
+
const clearAction = screen.getByLabelText("Clear input");
|
|
277
|
+
expect(clearAction).toBeDefined();
|
|
278
|
+
|
|
279
|
+
fireEvent.press(clearAction);
|
|
280
|
+
expect(handleChange).toHaveBeenCalledWith(null);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("Timezone conversion", () => {
|
|
285
|
+
const placeholder = "Start time";
|
|
286
|
+
const value = new Date(2022, 2, 2, 11, 0);
|
|
287
|
+
const handleChange = jest.fn();
|
|
288
|
+
const setup = () =>
|
|
289
|
+
render(
|
|
290
|
+
<InputTime
|
|
291
|
+
placeholder={placeholder}
|
|
292
|
+
value={value}
|
|
293
|
+
onChange={handleChange}
|
|
294
|
+
/>,
|
|
295
|
+
);
|
|
296
|
+
it("should display the time in the account timezone", async () => {
|
|
297
|
+
jest.spyOn(atlantisContext, "useAtlantisContext").mockReturnValue({
|
|
298
|
+
...atlantisContext.defaultValues,
|
|
299
|
+
timeZone: "America/Los_Angeles",
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const screen = setup();
|
|
303
|
+
const expectedTimezonedTime = "3:00 AM";
|
|
304
|
+
|
|
305
|
+
expect(
|
|
306
|
+
screen.getByText(expectedTimezonedTime, { includeHiddenElements: true }),
|
|
307
|
+
).toBeDefined();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("should have the correct offset on the time picker", async () => {
|
|
311
|
+
jest.spyOn(atlantisContext, "useAtlantisContext").mockReturnValue({
|
|
312
|
+
...atlantisContext.defaultValues,
|
|
313
|
+
timeZone: "America/Los_Angeles",
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const screen = setup();
|
|
317
|
+
|
|
318
|
+
fireEvent.press(screen.getByLabelText(placeholder));
|
|
319
|
+
expect(
|
|
320
|
+
screen.getByTestId("inputTime-Picker").props.timeZoneOffsetInMinutes,
|
|
321
|
+
).toBe(-8 * 60);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { FieldError, UseControllerProps } from "react-hook-form";
|
|
3
|
+
import { XOR } from "ts-xor";
|
|
4
|
+
import DateTimePicker from "react-native-modal-datetime-picker";
|
|
5
|
+
import { View } from "react-native";
|
|
6
|
+
import { useIntl } from "react-intl";
|
|
7
|
+
import { utcToZonedTime } from "date-fns-tz";
|
|
8
|
+
import { format as formatTime } from "date-fns";
|
|
9
|
+
import { styles } from "./InputTime.style";
|
|
10
|
+
import { messages } from "./messages";
|
|
11
|
+
import { getTimeZoneOffsetInMinutes, roundUpToNearestMinutes } from "./utils";
|
|
12
|
+
import { useAtlantisContext } from "../AtlantisContext";
|
|
13
|
+
import { InputPressable } from "../InputPressable";
|
|
14
|
+
import { FormField } from "../FormField";
|
|
15
|
+
import { Clearable, InputFieldWrapperProps } from "../InputFieldWrapper";
|
|
16
|
+
|
|
17
|
+
interface InputTimeBaseProps
|
|
18
|
+
extends Pick<InputFieldWrapperProps, "invalid" | "disabled" | "placeholder"> {
|
|
19
|
+
/**
|
|
20
|
+
* Defaulted to "always" so user can clear the time whenever there's a value.
|
|
21
|
+
*/
|
|
22
|
+
readonly clearable?: Extract<Clearable, "always" | "never">;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Add a custom value to display when no time is selected
|
|
26
|
+
* @default undefined
|
|
27
|
+
*/
|
|
28
|
+
readonly emptyValueLabel?: string;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Adjusts the UX of the time picker based on where you'd use it.
|
|
32
|
+
*
|
|
33
|
+
* - `"granular"` - allows the user to pick a very specific time
|
|
34
|
+
* - `"scheduling"` - only allows user to select between 5 minutes interval.
|
|
35
|
+
* If your design is catered towards "scheduling", you should use this type.
|
|
36
|
+
*
|
|
37
|
+
* @default scheduling
|
|
38
|
+
*/
|
|
39
|
+
readonly type?: "granular" | "scheduling";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Hide or show the timer icon.
|
|
43
|
+
*/
|
|
44
|
+
readonly showIcon?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface InputTimeFormControlled extends InputTimeBaseProps {
|
|
48
|
+
/**
|
|
49
|
+
* Adding a `name` would make this component "Form controlled" and must be
|
|
50
|
+
* nested within a `<Form />` component.
|
|
51
|
+
*
|
|
52
|
+
* Cannot be declared if `value` prop is used.
|
|
53
|
+
*/
|
|
54
|
+
readonly name: string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Shows an error message below the field and highlights it red when the
|
|
58
|
+
* value is invalid. Only applies when nested within a `<Form />` component.
|
|
59
|
+
*/
|
|
60
|
+
readonly validations?: UseControllerProps["rules"];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The callback that fires whenever a time gets selected.
|
|
64
|
+
*/
|
|
65
|
+
readonly onChange?: (value?: Date | null) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface InputTimeDevControlled extends InputTimeBaseProps {
|
|
69
|
+
/**
|
|
70
|
+
* The value shown on the field. This gets automatically formatted to the
|
|
71
|
+
* account's time format.
|
|
72
|
+
*/
|
|
73
|
+
readonly value: Date | string | undefined;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The callback that fires whenever a time gets selected.
|
|
77
|
+
*/
|
|
78
|
+
readonly onChange: (value?: Date) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type InputTimeProps = XOR<
|
|
82
|
+
InputTimeFormControlled,
|
|
83
|
+
InputTimeDevControlled
|
|
84
|
+
>;
|
|
85
|
+
|
|
86
|
+
const LOCALE_24_HOURS = "en_GB";
|
|
87
|
+
const LOCALE_12_HOURS = "en_US";
|
|
88
|
+
|
|
89
|
+
function formatInvalidState(
|
|
90
|
+
error: FieldError | undefined,
|
|
91
|
+
invalid: InputFieldWrapperProps["invalid"],
|
|
92
|
+
): boolean | string {
|
|
93
|
+
if (invalid) return invalid;
|
|
94
|
+
|
|
95
|
+
if (error && error.message) {
|
|
96
|
+
return error.message;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return Boolean(error);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function InputTime(props: InputTimeProps): JSX.Element {
|
|
103
|
+
if (props.name) {
|
|
104
|
+
return (
|
|
105
|
+
<FormField name={props.name} validations={props.validations}>
|
|
106
|
+
{(field, error) => (
|
|
107
|
+
<InternalInputTime
|
|
108
|
+
{...props}
|
|
109
|
+
value={field.value}
|
|
110
|
+
onChange={(newValue?: Date | null) => {
|
|
111
|
+
field.onChange(newValue);
|
|
112
|
+
field.onBlur();
|
|
113
|
+
props.onChange?.(newValue);
|
|
114
|
+
}}
|
|
115
|
+
invalid={formatInvalidState(error, props.invalid)}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
</FormField>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return <InternalInputTime {...props} />;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function InternalInputTime({
|
|
126
|
+
clearable = "always",
|
|
127
|
+
disabled,
|
|
128
|
+
emptyValueLabel,
|
|
129
|
+
invalid,
|
|
130
|
+
placeholder,
|
|
131
|
+
value,
|
|
132
|
+
name,
|
|
133
|
+
type = "scheduling",
|
|
134
|
+
onChange,
|
|
135
|
+
showIcon = true,
|
|
136
|
+
}: InputTimeProps): JSX.Element {
|
|
137
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
138
|
+
const { formatMessage } = useIntl();
|
|
139
|
+
|
|
140
|
+
const { timeZone, timeFormat } = useAtlantisContext();
|
|
141
|
+
const is24Hour = timeFormat === "HH:mm";
|
|
142
|
+
|
|
143
|
+
const dateTime = useMemo(
|
|
144
|
+
() => (typeof value === "string" ? new Date(value) : value),
|
|
145
|
+
[value],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const formattedTime = useMemo(() => {
|
|
149
|
+
if (dateTime) {
|
|
150
|
+
const zonedTime = utcToZonedTime(dateTime, timeZone);
|
|
151
|
+
return formatTime(zonedTime, timeFormat);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return emptyValueLabel;
|
|
155
|
+
}, [dateTime, emptyValueLabel, timeZone, timeFormat]);
|
|
156
|
+
|
|
157
|
+
const canClearTime = formattedTime === emptyValueLabel ? "never" : clearable;
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<View style={styles.container}>
|
|
161
|
+
<InputPressable
|
|
162
|
+
clearable={canClearTime}
|
|
163
|
+
disabled={disabled}
|
|
164
|
+
invalid={invalid}
|
|
165
|
+
placeholder={placeholder ?? formatMessage(messages.time)}
|
|
166
|
+
prefix={showIcon ? { icon: "timer" } : undefined}
|
|
167
|
+
value={formattedTime}
|
|
168
|
+
onClear={handleClear}
|
|
169
|
+
onPress={showDatePicker}
|
|
170
|
+
/>
|
|
171
|
+
<DateTimePicker
|
|
172
|
+
testID="inputTime-Picker"
|
|
173
|
+
minuteInterval={getMinuteInterval(type)}
|
|
174
|
+
date={getInitialPickerDate(dateTime)}
|
|
175
|
+
timeZoneOffsetInMinutes={getTimeZoneOffsetInMinutes(timeZone, dateTime)}
|
|
176
|
+
isVisible={showPicker}
|
|
177
|
+
mode="time"
|
|
178
|
+
onCancel={handleCancel}
|
|
179
|
+
onConfirm={handleConfirm}
|
|
180
|
+
is24Hour={is24Hour}
|
|
181
|
+
locale={is24Hour ? LOCALE_24_HOURS : LOCALE_12_HOURS}
|
|
182
|
+
/>
|
|
183
|
+
</View>
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
function showDatePicker() {
|
|
187
|
+
setShowPicker(true);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function handleConfirm(newValue: Date) {
|
|
191
|
+
setShowPicker(false);
|
|
192
|
+
onChange?.(newValue);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function handleCancel() {
|
|
196
|
+
setShowPicker(false);
|
|
197
|
+
|
|
198
|
+
// Call onChange with the current value to trigger form's validation.
|
|
199
|
+
onChange?.(dateTime);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function handleClear() {
|
|
203
|
+
// Returns null only for Form controlled scenarios due to a limitation of
|
|
204
|
+
// react-hook-form that doesn't allow passing undefined to form values.
|
|
205
|
+
if (name) {
|
|
206
|
+
onChange?.(null);
|
|
207
|
+
} else {
|
|
208
|
+
onChange?.(undefined);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getInitialPickerDate(date?: Date | null): Date {
|
|
214
|
+
if (date) return date;
|
|
215
|
+
return roundUpToNearestMinutes(new Date(), 30);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function getMinuteInterval(type: InputTimeBaseProps["type"]) {
|
|
219
|
+
if (type === "granular") return 1;
|
|
220
|
+
return 5;
|
|
221
|
+
}
|