dce-reactkit 4.0.0 → 4.0.2-beta-simple-date-chooser.1

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.
@@ -1,16 +1,26 @@
1
1
  /**
2
2
  * A very simple, lightweight date chooser
3
3
  * @author Gabe Abrams
4
+ * @author Gardenia Liu
4
5
  */
5
6
 
6
7
  // Import React
7
- import React from 'react';
8
- import getMonthName from '../helpers/getMonthName';
8
+ import React, { useReducer } from 'react';
9
+
10
+ // Import AppWrapper helpers
11
+ import { confirm } from './AppWrapper';
9
12
 
10
13
  // Import helpers
11
14
  import getOrdinal from '../helpers/getOrdinal';
15
+ import getMonthName from '../helpers/getMonthName';
12
16
  import getTimeInfoInET from '../helpers/getTimeInfoInET';
13
17
 
18
+ // Import classes
19
+ import ErrorWithCode from '../errors/ErrorWithCode';
20
+
21
+ // Import types
22
+ import ReactKitErrorCode from '../types/ReactKitErrorCode';
23
+
14
24
  /*------------------------------------------------------------------------*/
15
25
  /* -------------------------------- Types ------------------------------- */
16
26
  /*------------------------------------------------------------------------*/
@@ -33,40 +43,97 @@ type Props = {
33
43
  * @param year new full year number
34
44
  */
35
45
  onChange: (month: number, day: number, year: number) => void,
36
- // Number of months to allow the user to choose from
37
- // (max is 12, default is 6)
46
+ // Number of months in either the past or future to allow the user to choose from
47
+ // If we aren't allowing the past or the future, we will throw an error
48
+ // (max is 12, default is 6 in the past and 6 in the future)
38
49
  numMonthsToShow?: number,
39
- // If true, instead of showing numMonthsToShow months into the future,
40
- // show numMonthsToShow months into the past
41
- chooseFromPast?: boolean,
50
+ // If true, the user isn't allowed to select dates in the past
51
+ dontAllowPast?: boolean,
52
+ // If true, the user isn't allowed to select dates in the future
53
+ dontAllowFuture?: boolean,
42
54
  };
43
55
 
44
56
  /*------------------------------------------------------------------------*/
45
- /* ------------------------------ Component ----------------------------- */
57
+ /* -------------------------------- State ------------------------------- */
46
58
  /*------------------------------------------------------------------------*/
47
59
 
48
- const SimpleDateChooser: React.FC<Props> = (props) => {
49
- /*------------------------------------------------------------------------*/
50
- /* -------------------------------- Setup ------------------------------- */
51
- /*------------------------------------------------------------------------*/
60
+ /* -------------- Views ------------- */
52
61
 
53
- /* -------------- Props ------------- */
62
+ enum View {
63
+ // Date chooser
64
+ DateChooser = 'DateChooser',
65
+ // Invalid date
66
+ InvalidDate = 'InvalidDate',
67
+ }
54
68
 
55
- const {
56
- ariaLabel,
57
- name,
58
- onChange,
59
- chooseFromPast,
60
- numMonthsToShow = 6,
61
- } = props;
69
+ /* -------- State Definition -------- */
62
70
 
63
- /*------------------------------------------------------------------------*/
64
- /* ------------------------------- Render ------------------------------- */
65
- /*------------------------------------------------------------------------*/
71
+ type State = {
72
+ view: View,
73
+ };
66
74
 
67
- /*----------------------------------------*/
68
- /* --------------- Main UI -------------- */
69
- /*----------------------------------------*/
75
+ /* ------------- Actions ------------ */
76
+
77
+ // Types of actions
78
+ enum ActionType {
79
+ // Fix invalid date so it is now in range
80
+ FixInvalidDate = 'FixInvalidDate',
81
+ }
82
+
83
+ // Action definitions
84
+ type Action = {
85
+ // Action type
86
+ type: ActionType.FixInvalidDate,
87
+ };
88
+
89
+ /**
90
+ * Reducer that executes actions
91
+ * @author Gardenia Liu
92
+ * @param state current state
93
+ * @param action action to execute
94
+ * @returns updated state
95
+ */
96
+ const reducer = (state: State, action: Action): State => {
97
+ switch (action.type) {
98
+ case ActionType.FixInvalidDate: {
99
+ return {
100
+ ...state,
101
+ view: View.DateChooser,
102
+ };
103
+ }
104
+ default: {
105
+ return state;
106
+ }
107
+ }
108
+ };
109
+
110
+ /*------------------------------------------------------------------------*/
111
+ /* --------------------------- Static Helpers --------------------------- */
112
+ /*------------------------------------------------------------------------*/
113
+
114
+ /**
115
+ * Get the list of choices in the date chooser given props
116
+ * @author Gardenia Liu
117
+ * @author Gabe Abrams
118
+ * @param opts object containing all arguments
119
+ * @param opts.numMonthsToShow number of months to show
120
+ * @param opts.dontAllowPast if true, the user isn't allowed to select dates in the past
121
+ * @param opts.dontAllowFuture if true, the user isn't allowed to select dates in the future
122
+ * @returns choices
123
+ */
124
+ const getChoices = (
125
+ opts: {
126
+ numMonthsToShow?: number,
127
+ dontAllowPast?: boolean,
128
+ dontAllowFuture?: boolean,
129
+ },
130
+ ) => {
131
+ // Destructure props
132
+ const {
133
+ dontAllowFuture,
134
+ dontAllowPast,
135
+ numMonthsToShow = 6,
136
+ } = opts;
70
137
 
71
138
  // Determine the set of choices allowed
72
139
  const today = getTimeInfoInET();
@@ -76,16 +143,41 @@ const SimpleDateChooser: React.FC<Props> = (props) => {
76
143
  days: number[],
77
144
  year: number,
78
145
  }[] = [];
146
+
79
147
  let startYear = today.year;
80
148
  let startMonth = today.month;
81
- if (chooseFromPast) {
149
+
150
+ // Don't allow past or future dates
151
+ if (dontAllowPast && dontAllowFuture) {
152
+ throw new ErrorWithCode(
153
+ 'No past or future dates allowed',
154
+ ReactKitErrorCode.SimpleDateChooserInvalidDateRange,
155
+ );
156
+ }
157
+
158
+ // Require numMonthsToShow to be positive
159
+ if (numMonthsToShow <= 0) {
160
+ throw new ErrorWithCode(
161
+ 'numMonthsToShow must be positive',
162
+ ReactKitErrorCode.SimpleDateChooserInvalidNumMonths,
163
+ );
164
+ }
165
+
166
+ // Recalculate startMonth and startYear when allowing past dates
167
+ if (!dontAllowPast) {
82
168
  startMonth -= Math.max(0, numMonthsToShow - 1);
83
169
  while (startMonth <= 0) {
84
170
  startMonth += 12;
85
171
  startYear -= 1;
86
172
  }
87
173
  }
88
- for (let i = 0; i < numMonthsToShow; i++) {
174
+ // Calculate total number of months to show
175
+ let totalMonthsToShow = numMonthsToShow;
176
+ if (!dontAllowPast && !dontAllowFuture) {
177
+ totalMonthsToShow = totalMonthsToShow * 2 - 1;
178
+ }
179
+
180
+ for (let i = 0; i < totalMonthsToShow; i++) {
89
181
  // Get month and year info
90
182
  const unmoddedMonth = (startMonth + i);
91
183
  let month = unmoddedMonth;
@@ -105,24 +197,25 @@ const SimpleDateChooser: React.FC<Props> = (props) => {
105
197
  // Figure out which days are allowed
106
198
  const days = [];
107
199
  const numDaysInMonth = (new Date(year, month, 0)).getDate();
108
- if (chooseFromPast) {
109
- // Past selection
110
- const numDaysToAdd = (
111
- (month === today.month)
112
- ? today.day // Current month, only add up to today
113
- : numDaysInMonth // Past month, add all days
114
- );
115
- for (let day = 1; day <= numDaysToAdd; day++) {
116
- days.push(day);
200
+
201
+ // Current month
202
+ if (month === today.month && year === today.year) {
203
+ // Past selection: add all previous days of the month
204
+ if (!dontAllowPast) {
205
+ for (let day = 1; day < today.day; day++) {
206
+ days.push(day);
207
+ }
117
208
  }
118
- } else {
209
+ days.push(today.day); // Add current day
119
210
  // Future selection: add all remaining days of the month
120
- const firstDay = (
121
- month === today.month
122
- ? today.day // Current month: start at current date
123
- : 1 // Future month: start at beginning of month
124
- );
125
- for (let day = firstDay; day <= numDaysInMonth; day++) {
211
+ if (!dontAllowFuture) {
212
+ for (let day = today.day + 1; day <= numDaysInMonth; day++) {
213
+ days.push(day);
214
+ }
215
+ }
216
+ } else { // Past or future month
217
+ // Include all days in the month
218
+ for (let day = 1; day <= numDaysInMonth; day++) {
126
219
  days.push(day);
127
220
  }
128
221
  }
@@ -135,93 +228,278 @@ const SimpleDateChooser: React.FC<Props> = (props) => {
135
228
  });
136
229
  }
137
230
 
138
- // Create choice options
231
+ // Return choices
232
+ return choices;
233
+ };
234
+
235
+ /**
236
+ * Checks whether a given date is outside the valid range of allowed choices
237
+ * @author Gardenia Liu
238
+ * @param opts object containing all arguments
239
+ * @param opts.month 1-indexed month
240
+ * @param opts.day day of the month
241
+ * @param opts.year full year
242
+ * @param opts.choices valid date choices
243
+ * @returns true if date is out of range
244
+ */
245
+ const isDateOutOfRange = (
246
+ opts: {
247
+ month: number,
248
+ day: number,
249
+ year: number,
250
+ choices: {
251
+ month: number,
252
+ year: number,
253
+ days: number[]
254
+ }[],
255
+ },
256
+ ): boolean => {
257
+ const {
258
+ month,
259
+ day,
260
+ year,
261
+ choices,
262
+ } = opts;
263
+
264
+ return !choices.some((choice) => {
265
+ return (
266
+ choice.month === month
267
+ && choice.year === year
268
+ && choice.days.includes(day)
269
+ );
270
+ });
271
+ };
272
+
273
+ /*------------------------------------------------------------------------*/
274
+ /* ------------------------------ Component ----------------------------- */
275
+ /*------------------------------------------------------------------------*/
276
+
277
+ const SimpleDateChooser: React.FC<Props> = (props) => {
278
+ /*------------------------------------------------------------------------*/
279
+ /* -------------------------------- Setup ------------------------------- */
280
+ /*------------------------------------------------------------------------*/
281
+
282
+ /* -------------- Props ------------- */
283
+
139
284
  const {
285
+ ariaLabel,
286
+ name,
287
+ dontAllowPast,
288
+ dontAllowFuture,
289
+ numMonthsToShow,
290
+ onChange,
140
291
  month,
141
292
  day,
142
293
  year,
143
294
  } = props;
144
- const monthOptions: React.ReactNode[] = [];
145
- const dayOptions: React.ReactNode[] = [];
146
- choices.forEach((choice) => {
147
- // Create month option
148
- monthOptions.push(
149
- <option
150
- key={`${choice.year}-${choice.month}`}
151
- value={`${choice.month}-${choice.year}`}
152
- aria-label={`choose ${choice.choiceName}`}
153
- onSelect={() => {
154
- onChange(choice.month, choice.days[0], choice.year);
155
- }}
156
- >
157
- {choice.choiceName}
158
- </option>,
295
+
296
+ // Get choices
297
+ const choices = getChoices({
298
+ numMonthsToShow,
299
+ dontAllowPast,
300
+ dontAllowFuture,
301
+ });
302
+
303
+ /* -------------- State ------------- */
304
+
305
+ // Check if the currently selected date is out of range
306
+ const currentSelectedDateOutOfRange = isDateOutOfRange({
307
+ month,
308
+ day,
309
+ year,
310
+ choices,
311
+ });
312
+
313
+ // Initial state
314
+ const initialState: State = {
315
+ view: (
316
+ currentSelectedDateOutOfRange
317
+ ? View.InvalidDate
318
+ : View.DateChooser
319
+ ),
320
+ };
321
+
322
+ // Initialize state
323
+ const [state, dispatch] = useReducer(reducer, initialState);
324
+
325
+ // Destructure common state
326
+ const {
327
+ view,
328
+ } = state;
329
+
330
+ /*------------------------------------------------------------------------*/
331
+ /* ------------------------- Component Functions ------------------------ */
332
+ /*------------------------------------------------------------------------*/
333
+
334
+ /**
335
+ * Ask the user if they want to edit an invalid date
336
+ * @author Gardenia Liu
337
+ * @author Gabe Abrams
338
+ */
339
+ const askToEditInvalidDate = async () => {
340
+ // Ask the user if they want to edit the date
341
+ const confirmed = await confirm(
342
+ 'Are you sure?',
343
+ 'The current date is outside the normal range. If you edit it, you\'ll need to choose a new date in the normal range.',
344
+ {
345
+ confirmButtonText: 'Edit Date',
346
+ },
159
347
  );
160
348
 
161
- if (month === choice.month) {
162
- // This is the currently selected month
163
- // Create day options
164
- choice.days.forEach((dayChoice) => {
165
- const ordinal = getOrdinal(dayChoice);
166
- dayOptions.push(
167
- <option
168
- key={`${choice.year}-${choice.month}-${dayChoice}`}
169
- value={dayChoice}
170
- aria-label={`choose date ${dayChoice}`}
171
- >
172
- {dayChoice}
173
- {ordinal}
174
- </option>,
175
- );
349
+ // Check if user confirmed
350
+ if (confirmed) {
351
+ // Update the date to today
352
+ const today = getTimeInfoInET();
353
+ onChange(today.month, today.day, today.year);
354
+
355
+ // Update state
356
+ dispatch({
357
+ type: ActionType.FixInvalidDate,
176
358
  });
177
359
  }
178
- });
360
+ };
179
361
 
180
- return (
181
- <div
182
- className="SimpleDateChooser d-inline-block"
183
- aria-label={`date chooser with selected date: ${month}/${day}/${year}`}
184
- >
185
- {/* Month Chooser */}
186
- <select
187
- aria-label={`month for ${ariaLabel}`}
188
- className="custom-select d-inline-block mr-1"
189
- style={{ width: 'auto' }}
190
- id={`SimpleDateChooser-${name}-month`}
191
- value={`${month}-${year}`}
192
- onChange={(e) => {
193
- const choice = choices[e.target.selectedIndex];
194
-
195
- // Change day, month, and year
196
- onChange(
197
- choice.month,
198
- choice.days[0],
199
- choice.year,
200
- );
201
- }}
202
- >
203
- {monthOptions}
204
- </select>
205
-
206
- {/* Day Chooser */}
207
- <select
208
- aria-label={`day for ${ariaLabel}`}
209
- className="custom-select d-inline-block"
210
- style={{ width: 'auto' }}
211
- id={`SimpleDateChooser-${name}-day`}
212
- value={day}
213
- onChange={(e) => {
214
- // Only change the day
215
- onChange(
216
- month,
217
- Number.parseInt(e.target.value, 10),
218
- year,
362
+ /*------------------------------------------------------------------------*/
363
+ /* ------------------------------- Render ------------------------------- */
364
+ /*------------------------------------------------------------------------*/
365
+
366
+ /*----------------------------------------*/
367
+ /* ---------------- Views --------------- */
368
+ /*----------------------------------------*/
369
+
370
+ // Body that will be filled with the current view
371
+ let body: React.ReactNode;
372
+
373
+ /* ---------- DateChooser ---------- */
374
+
375
+ if (view === View.DateChooser) {
376
+ // Create lists of options
377
+ const monthOptions: React.ReactNode[] = [];
378
+ const dayOptions: React.ReactNode[] = [];
379
+
380
+ // Render each option and add it to the list
381
+ choices.forEach((choice) => {
382
+ // Create month option
383
+ monthOptions.push(
384
+ <option
385
+ key={`${choice.year}-${choice.month}`}
386
+ value={`${choice.month}-${choice.year}`}
387
+ aria-label={`choose ${choice.choiceName}`}
388
+ onSelect={() => {
389
+ onChange(choice.month, choice.days[0], choice.year);
390
+ }}
391
+ >
392
+ {choice.choiceName}
393
+ </option>,
394
+ );
395
+
396
+ // This is the currently selected month
397
+ if (month === choice.month) {
398
+ // Create day options
399
+ choice.days.forEach((dayChoice) => {
400
+ const ordinal = getOrdinal(dayChoice);
401
+ dayOptions.push(
402
+ <option
403
+ key={`${choice.year}-${choice.month}-${dayChoice}`}
404
+ value={dayChoice}
405
+ aria-label={`choose date ${dayChoice}`}
406
+ >
407
+ {dayChoice}
408
+ {ordinal}
409
+ </option>,
219
410
  );
220
- }}
411
+ });
412
+ }
413
+ });
414
+
415
+ // Create body
416
+ body = (
417
+ <div
418
+ className="SimpleDateChooser-inner-container d-inline-block"
419
+ aria-label={`date chooser with selected date: ${month}/${day}/${year}`}
221
420
  >
222
- {dayOptions}
223
- </select>
224
- </div>
421
+ {/* Month Chooser */}
422
+ <select
423
+ aria-label={`month for ${ariaLabel}`}
424
+ className="custom-select d-inline-block mr-1"
425
+ style={{ width: 'auto' }}
426
+ id={`SimpleDateChooser-${name}-month`}
427
+ value={`${month}-${year}`}
428
+ onChange={(e) => {
429
+ const choice = choices[e.target.selectedIndex];
430
+
431
+ // Change day, month, and year
432
+ onChange(
433
+ choice.month,
434
+ choice.days[0],
435
+ choice.year,
436
+ );
437
+ }}
438
+ >
439
+ {monthOptions}
440
+ </select>
441
+
442
+ {/* Day Chooser */}
443
+ <select
444
+ aria-label={`day for ${ariaLabel}`}
445
+ className="custom-select d-inline-block"
446
+ style={{ width: 'auto' }}
447
+ id={`SimpleDateChooser-${name}-day`}
448
+ value={day}
449
+ onChange={(e) => {
450
+ // Only change the day
451
+ onChange(
452
+ month,
453
+ Number.parseInt(e.target.value, 10),
454
+ year,
455
+ );
456
+ }}
457
+ >
458
+ {dayOptions}
459
+ </select>
460
+ </div>
461
+ );
462
+ }
463
+
464
+ /* --------- DateOutOfRange --------- */
465
+
466
+ if (view === View.InvalidDate) {
467
+ body = (
468
+ <div className="SimpleDateChooser-inner-container d-inline-block">
469
+ <button
470
+ type="button"
471
+ className="btn btn-light"
472
+ onClick={askToEditInvalidDate}
473
+ aria-label={`edit date for ${ariaLabel}`}
474
+ >
475
+ {getMonthName(month).full}
476
+ {' '}
477
+ {day}
478
+ {getOrdinal(day)}
479
+ ,
480
+ {' '}
481
+ {year}
482
+ </button>
483
+ <button
484
+ type="button"
485
+ className="btn btn-secondary"
486
+ onClick={askToEditInvalidDate}
487
+ aria-label={`edit date for ${ariaLabel}`}
488
+ >
489
+ Edit
490
+ </button>
491
+ </div>
492
+ );
493
+ }
494
+
495
+ /*----------------------------------------*/
496
+ /* --------------- Main UI -------------- */
497
+ /*----------------------------------------*/
498
+
499
+ return (
500
+ <span className="SimpleDateChooser-outer-container">
501
+ {body}
502
+ </span>
225
503
  );
226
504
  };
227
505