dce-reactkit 4.1.0 → 4.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dce-reactkit",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "description": "Shared components for Harvard DCE apps",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -5,7 +5,10 @@
5
5
  */
6
6
 
7
7
  // Import React
8
- import React from 'react';
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';
@@ -51,32 +54,86 @@ type Props = {
51
54
  };
52
55
 
53
56
  /*------------------------------------------------------------------------*/
54
- /* ------------------------------ Component ----------------------------- */
57
+ /* -------------------------------- State ------------------------------- */
55
58
  /*------------------------------------------------------------------------*/
56
59
 
57
- const SimpleDateChooser: React.FC<Props> = (props) => {
58
- /*------------------------------------------------------------------------*/
59
- /* -------------------------------- Setup ------------------------------- */
60
- /*------------------------------------------------------------------------*/
60
+ /* -------------- Views ------------- */
61
61
 
62
- /* -------------- Props ------------- */
62
+ enum View {
63
+ // Date chooser
64
+ DateChooser = 'DateChooser',
65
+ // Invalid date
66
+ InvalidDate = 'InvalidDate',
67
+ }
63
68
 
69
+ /* -------- State Definition -------- */
70
+
71
+ type State = {
72
+ view: View,
73
+ };
74
+
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
64
132
  const {
65
- ariaLabel,
66
- name,
67
- onChange,
68
- numMonthsToShow = 6,
69
133
  dontAllowFuture,
70
134
  dontAllowPast,
71
- } = props;
72
-
73
- /*------------------------------------------------------------------------*/
74
- /* ------------------------------- Render ------------------------------- */
75
- /*------------------------------------------------------------------------*/
76
-
77
- /*----------------------------------------*/
78
- /* --------------- Main UI -------------- */
79
- /*----------------------------------------*/
135
+ numMonthsToShow = 6,
136
+ } = opts;
80
137
 
81
138
  // Determine the set of choices allowed
82
139
  const today = getTimeInfoInET();
@@ -171,93 +228,278 @@ const SimpleDateChooser: React.FC<Props> = (props) => {
171
228
  });
172
229
  }
173
230
 
174
- // 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 => {
175
257
  const {
176
258
  month,
177
259
  day,
178
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
+
284
+ const {
285
+ ariaLabel,
286
+ name,
287
+ dontAllowPast,
288
+ dontAllowFuture,
289
+ numMonthsToShow,
290
+ onChange,
291
+ month,
292
+ day,
293
+ year,
179
294
  } = props;
180
- const monthOptions: React.ReactNode[] = [];
181
- const dayOptions: React.ReactNode[] = [];
182
- choices.forEach((choice) => {
183
- // Create month option
184
- monthOptions.push(
185
- <option
186
- key={`${choice.year}-${choice.month}`}
187
- value={`${choice.month}-${choice.year}`}
188
- aria-label={`choose ${choice.choiceName}`}
189
- onSelect={() => {
190
- onChange(choice.month, choice.days[0], choice.year);
191
- }}
192
- >
193
- {choice.choiceName}
194
- </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
+ },
195
347
  );
196
348
 
197
- // This is the currently selected month
198
- if (month === choice.month) {
199
- // Create day options
200
- choice.days.forEach((dayChoice) => {
201
- const ordinal = getOrdinal(dayChoice);
202
- dayOptions.push(
203
- <option
204
- key={`${choice.year}-${choice.month}-${dayChoice}`}
205
- value={dayChoice}
206
- aria-label={`choose date ${dayChoice}`}
207
- >
208
- {dayChoice}
209
- {ordinal}
210
- </option>,
211
- );
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,
212
358
  });
213
359
  }
214
- });
360
+ };
215
361
 
216
- return (
217
- <div
218
- className="SimpleDateChooser d-inline-block"
219
- aria-label={`date chooser with selected date: ${month}/${day}/${year}`}
220
- >
221
- {/* Month Chooser */}
222
- <select
223
- aria-label={`month for ${ariaLabel}`}
224
- className="custom-select d-inline-block mr-1"
225
- style={{ width: 'auto' }}
226
- id={`SimpleDateChooser-${name}-month`}
227
- value={`${month}-${year}`}
228
- onChange={(e) => {
229
- const choice = choices[e.target.selectedIndex];
230
-
231
- // Change day, month, and year
232
- onChange(
233
- choice.month,
234
- choice.days[0],
235
- choice.year,
236
- );
237
- }}
238
- >
239
- {monthOptions}
240
- </select>
241
-
242
- {/* Day Chooser */}
243
- <select
244
- aria-label={`day for ${ariaLabel}`}
245
- className="custom-select d-inline-block"
246
- style={{ width: 'auto' }}
247
- id={`SimpleDateChooser-${name}-day`}
248
- value={day}
249
- onChange={(e) => {
250
- // Only change the day
251
- onChange(
252
- month,
253
- Number.parseInt(e.target.value, 10),
254
- 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>,
255
410
  );
256
- }}
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}`}
257
420
  >
258
- {dayOptions}
259
- </select>
260
- </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>
261
503
  );
262
504
  };
263
505