dce-reactkit 3.2.2-beta.1 → 3.2.2-beta.11

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 (59) hide show
  1. package/.vscode/settings.json +3 -0
  2. package/dist/cjs/index.js +2019 -274
  3. package/dist/cjs/index.js.map +1 -1
  4. package/dist/cjs/types/components/ButtonInputGroup.d.ts +1 -0
  5. package/dist/cjs/types/components/CSVDownloadButton.d.ts +19 -0
  6. package/dist/cjs/types/components/IntelliTable.d.ts +17 -0
  7. package/dist/cjs/types/components/ItemPicker/index.d.ts +1 -0
  8. package/dist/cjs/types/components/LogReviewer.d.ts +13 -0
  9. package/dist/cjs/types/components/SimpleDateChooser.d.ts +1 -0
  10. package/dist/cjs/types/constants/LOG_REVIEW_ROUTE_PATH_PREFIX.d.ts +6 -0
  11. package/dist/cjs/types/constants/LOG_REVIEW_STATUS_ROUTE.d.ts +7 -0
  12. package/dist/cjs/types/helpers/canReviewLogs.d.ts +7 -0
  13. package/dist/cjs/types/helpers/genCSV.d.ts +14 -0
  14. package/dist/cjs/types/helpers/getMonthName.d.ts +14 -0
  15. package/dist/cjs/types/index.d.ts +9 -1
  16. package/dist/cjs/types/server/initServer.d.ts +8 -0
  17. package/dist/cjs/types/types/IntelliTableColumn.d.ts +12 -0
  18. package/dist/cjs/types/types/LogMetadataType.d.ts +19 -0
  19. package/dist/cjs/types/types/ReactKitErrorCode.d.ts +2 -1
  20. package/dist/esm/index.js +2015 -276
  21. package/dist/esm/index.js.map +1 -1
  22. package/dist/esm/types/components/ButtonInputGroup.d.ts +1 -0
  23. package/dist/esm/types/components/CSVDownloadButton.d.ts +19 -0
  24. package/dist/esm/types/components/IntelliTable.d.ts +17 -0
  25. package/dist/esm/types/components/ItemPicker/index.d.ts +1 -0
  26. package/dist/esm/types/components/LogReviewer.d.ts +13 -0
  27. package/dist/esm/types/components/SimpleDateChooser.d.ts +1 -0
  28. package/dist/esm/types/constants/LOG_REVIEW_ROUTE_PATH_PREFIX.d.ts +6 -0
  29. package/dist/esm/types/constants/LOG_REVIEW_STATUS_ROUTE.d.ts +7 -0
  30. package/dist/esm/types/helpers/canReviewLogs.d.ts +7 -0
  31. package/dist/esm/types/helpers/genCSV.d.ts +14 -0
  32. package/dist/esm/types/helpers/getMonthName.d.ts +14 -0
  33. package/dist/esm/types/index.d.ts +9 -1
  34. package/dist/esm/types/server/initServer.d.ts +8 -0
  35. package/dist/esm/types/types/IntelliTableColumn.d.ts +12 -0
  36. package/dist/esm/types/types/LogMetadataType.d.ts +19 -0
  37. package/dist/esm/types/types/ReactKitErrorCode.d.ts +2 -1
  38. package/dist/index.d.ts +171 -47
  39. package/package.json +1 -1
  40. package/sandbox.js +78 -17
  41. package/src/components/AppWrapper.tsx +5 -1
  42. package/src/components/ButtonInputGroup.tsx +4 -1
  43. package/src/components/CSVDownloadButton.tsx +102 -0
  44. package/src/components/IntelliTable.tsx +610 -0
  45. package/src/components/ItemPicker/index.tsx +4 -0
  46. package/src/components/LogReviewer.tsx +2097 -0
  47. package/src/components/SimpleDateChooser.tsx +26 -27
  48. package/src/constants/LOG_REVIEW_ROUTE_PATH_PREFIX.ts +9 -0
  49. package/src/constants/LOG_REVIEW_STATUS_ROUTE.ts +10 -0
  50. package/src/helpers/canReviewLogs.ts +33 -0
  51. package/src/helpers/genCSV.ts +59 -0
  52. package/src/helpers/getHumanReadableDate.ts +6 -17
  53. package/src/helpers/getMonthName.ts +29 -0
  54. package/src/helpers/logClientEvent.tsx +5 -0
  55. package/src/index.ts +16 -0
  56. package/src/server/initServer.ts +125 -3
  57. package/src/types/IntelliTableColumn.ts +24 -0
  58. package/src/types/LogMetadataType.ts +23 -0
  59. package/src/types/ReactKitErrorCode.tsx +2 -1
@@ -0,0 +1,2097 @@
1
+ /**
2
+ * Log reviewer panel that allows users (must be approved admins) to
3
+ * review logs written by dce-reactkit
4
+ * @author Gabe Abrams
5
+ */
6
+
7
+ // Import React
8
+ import React, { useReducer, useEffect } from 'react';
9
+
10
+ // Import FontAwesome
11
+ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
12
+ import {
13
+ faCalendar,
14
+ faCircle,
15
+ faHammer,
16
+ faList,
17
+ faTag,
18
+ faTimes,
19
+ } from '@fortawesome/free-solid-svg-icons';
20
+
21
+ // Import shared helpers
22
+ import visitServerEndpoint from '../helpers/visitServerEndpoint';
23
+ import getTimeInfoInET from '../helpers/getTimeInfoInET';
24
+ import { showFatalError } from './AppWrapper';
25
+
26
+ // Import shared constants
27
+ import LOG_REVIEW_ROUTE_PATH_PREFIX from '../constants/LOG_REVIEW_ROUTE_PATH_PREFIX';
28
+
29
+ // Import shared types
30
+ import Log from '../types/Log';
31
+ import LogMetadataType from '../types/LogMetadataType';
32
+ import LogSource from '../types/LogSource';
33
+ import LogType from '../types/LogType';
34
+ import LogAction from '../types/LogAction';
35
+ import ParamType from '../types/ParamType';
36
+ import IntelliTableColumn from '../types/IntelliTableColumn';
37
+
38
+ // Import shared components
39
+ import SimpleDateChooser from './SimpleDateChooser';
40
+ import LoadingSpinner from './LoadingSpinner';
41
+ import Drawer from './Drawer';
42
+ import ItemPicker from './ItemPicker';
43
+ import PickableItem from './ItemPicker/types/PickableItem';
44
+ import CheckboxButton from './CheckboxButton';
45
+ import TabBox from './TabBox';
46
+ import RadioButton from './RadioButton';
47
+ import ButtonInputGroup from './ButtonInputGroup';
48
+ import IntelliTable from './IntelliTable';
49
+
50
+ /*------------------------------------------------------------------------*/
51
+ /* Types */
52
+ /*------------------------------------------------------------------------*/
53
+
54
+ // Props
55
+ type Props = {
56
+ // LogMetadata file for the app
57
+ LogMetadata: LogMetadataType,
58
+ // Function to call when the user wants to close the log reviewer
59
+ onClose: () => void,
60
+ };
61
+
62
+ // Map of loaded logs (year => month => Log[])
63
+ type LogMap = {
64
+ [k: string]: {
65
+ [k: string]: Log[]
66
+ }
67
+ };
68
+
69
+ // Triple of year/month/day
70
+ type DateTriple = {
71
+ // Full year
72
+ year: number,
73
+ // 1-indexed month
74
+ month: number,
75
+ // 1-indexed day
76
+ day: number,
77
+ };
78
+
79
+ // Types of filter drawers
80
+ enum FilterDrawer {
81
+ Date = 'date',
82
+ Context = 'context',
83
+ Tag = 'tag',
84
+ Action = 'action',
85
+ Advanced = 'advanced',
86
+ }
87
+
88
+ // Date filter state
89
+ type DateFilterState = {
90
+ // Current start date
91
+ startDate: {
92
+ // Full year
93
+ year: number,
94
+ // 1-indexed month
95
+ month: number,
96
+ // 1-indexed day
97
+ day: number,
98
+ },
99
+ // Current end date
100
+ endDate: {
101
+ // Full year
102
+ year: number,
103
+ // 1-indexed month
104
+ month: number,
105
+ // 1-indexed day
106
+ day: number,
107
+ },
108
+ };
109
+
110
+ // Context filter state
111
+ type ContextFilterState = {
112
+ [k: string]: (
113
+ // No subcontexts
114
+ | boolean // True if selected
115
+ // Includes subcontexts
116
+ | {
117
+ [k: string]: boolean // True if selected
118
+ }
119
+ )
120
+ };
121
+
122
+ // Tag filter state
123
+ type TagFilterState = {
124
+ [k: string]: boolean
125
+ };
126
+
127
+ // Action filter state (only relevant for action logs)
128
+ type ActionErrorFilterState = {
129
+ // Required type of log
130
+ type: LogType | undefined, // If undefined, no filter applied
131
+ // Query for error message (only relevant if type is error)
132
+ errorMessage: string, // If empty, no filter applied
133
+ // Query for error code (only relevant if type is error)
134
+ errorCode: string, // If empty, no filter applied
135
+ // Action targets to include (only relevant if type is action)
136
+ target: {
137
+ [k: string]: boolean
138
+ },
139
+ // Action types to include (only relevant if type is action)
140
+ action: {
141
+ [k: string]: boolean
142
+ },
143
+ };
144
+
145
+ // Advanced filter state
146
+ type AdvancedFilterState = {
147
+ // Query for user first name (case insensitive)
148
+ userFirstName: string, // If empty, no filter applied
149
+ // Query for user last name (case insensitive)
150
+ userLastName: string, // If empty, no filter applied
151
+ // Query for user email (case insensitive)
152
+ userEmail: string, // If empty, no filter applied
153
+ // Match for userId (numerical)
154
+ userId: string, // If empty, no filter applied
155
+ // If true, include students
156
+ includeLearners: boolean,
157
+ // If true, include ttms
158
+ includeTTMs: boolean,
159
+ // If true, include admins
160
+ includeAdmins: boolean,
161
+ // Match for courseId (numerical)
162
+ courseId: string, // If empty, no filter applied
163
+ // Query for course name (case insensitive)
164
+ courseName: string, // If empty, no filter applied
165
+ // Required isMobile value
166
+ isMobile: (true | false | undefined), // If undefined, no filter applied
167
+ // Required log source value
168
+ source: LogSource | undefined, // If undefined, no filter applied
169
+ // Query for route path (only relevant if source is server)
170
+ routePath: string, // If empty, no filter applied
171
+ // Query for route template (only relevant if source is server)
172
+ routeTemplate: string, // If empty, no filter applied
173
+ };
174
+
175
+ /*------------------------------------------------------------------------*/
176
+ /* Style */
177
+ /*------------------------------------------------------------------------*/
178
+
179
+ const style = `
180
+ .LogReviewer-outer-container {
181
+ /* Full Screen */
182
+ display: inline-block;
183
+ left: 0;
184
+ top: 0;
185
+ width: 100vw;
186
+ height: 100vh;
187
+
188
+ /* On Top and Fixed */
189
+ position: fixed;
190
+ z-index: 90000;
191
+
192
+ /* Space around contents */
193
+ padding: 0.5rem;
194
+
195
+ /* Dark Background */
196
+ background-color: rgba(0, 0, 0, 0.7);
197
+
198
+ /* No Clickthrough */
199
+ pointer-events: none;
200
+ }
201
+
202
+ .LogReviewer-inner-container {
203
+ /* Full screen, rounded modal-like look */
204
+ display: flex;
205
+ height: 100%;
206
+ border: 0.05rem solid black;
207
+ border-radius: 0.5rem;
208
+ overflow: hidden;
209
+ padding: 0.7rem;
210
+
211
+ /* Solid background */
212
+ background-color: white;
213
+ color: black;
214
+
215
+ /* Place contents in flex column */
216
+ flex-direction: column;
217
+
218
+ /* Re-allow interaction */
219
+ pointer-events: all;
220
+ }
221
+
222
+ .LogReviewer-header {
223
+ /* Elements in flex row */
224
+ display: flex;
225
+ flex-direction: row;
226
+ }
227
+
228
+ .LogReviewer-header-title {
229
+ /* Take up remaining width */
230
+ flex-grow: 1;
231
+ }
232
+
233
+ .LogReviewer-contents {
234
+ /* Take up remaining height */
235
+ flex-grow: 1;
236
+
237
+ /* Vertical scroll */
238
+ overflow-y: auto;
239
+ }
240
+
241
+ .LogReviewer-header-close-button {
242
+ border: 0 !important;
243
+ background-color: transparent !important;
244
+ padding-top: 0 !important;
245
+ padding-bottom: 0 !important;
246
+ margin: 0 !important;
247
+ color: #333 !important;
248
+ }
249
+ .LogReviewer-header-close-button:hover {
250
+ border: 0 !important;
251
+ background-color: transparent !important;
252
+ padding-top: 0 !important;
253
+ padding-bottom: 0 !important;
254
+ margin: 0 !important;
255
+ color: #000 !important;
256
+ }
257
+ `;
258
+
259
+ /*------------------------------------------------------------------------*/
260
+ /* Static Functions */
261
+ /*------------------------------------------------------------------------*/
262
+
263
+ /**
264
+ * Turn a machine-readable name into a human-readable name
265
+ * @author Gabe Abrams
266
+ * @param name machine-readable name
267
+ * @returns human-readable name
268
+ */
269
+ const genHumanReadableName = (machineReadableName: string) => {
270
+ let humanReadableName = '';
271
+
272
+ // Add chars and spaces
273
+ const chars = machineReadableName.split('');
274
+ chars.forEach((char) => {
275
+ if (/[A-Z]/.test(char)) {
276
+ // Uppercase! Add a space before
277
+ humanReadableName += ' ';
278
+ }
279
+ humanReadableName += char;
280
+ });
281
+
282
+ // Trim and return
283
+ return humanReadableName.trim();
284
+ };
285
+
286
+ /*------------------------------------------------------------------------*/
287
+ /* State */
288
+ /*------------------------------------------------------------------------*/
289
+
290
+ /* -------- State Definition -------- */
291
+
292
+ type State = {
293
+ /* -------------- Logs -------------- */
294
+ // True if currently loading
295
+ loading: boolean,
296
+ // Loaded logs (year => month => Log[])
297
+ logMap: LogMap,
298
+ /* ------------- Filters ------------ */
299
+ // Current expanded filter drawer
300
+ expandedFilterDrawer: FilterDrawer | undefined,
301
+ // State of date filters
302
+ dateFilterState: DateFilterState,
303
+ // State of context filters
304
+ contextFilterState: ContextFilterState,
305
+ // State of tag filters
306
+ tagFilterState: TagFilterState,
307
+ // State of the action and error filter
308
+ actionErrorFilterState: ActionErrorFilterState,
309
+ // State of the advanced filter
310
+ advancedFilterState: AdvancedFilterState,
311
+ };
312
+
313
+ /* ------------- Actions ------------ */
314
+
315
+ // Types of actions
316
+ enum ActionType {
317
+ // Show the loading bar
318
+ StartLoading = 'start-loading',
319
+ // Finish loading one or more months of logs
320
+ FinishLoading = 'finish-loading',
321
+ // Reset filters to initial values
322
+ ResetFilters = 'reset-filters',
323
+ // Choose a filter drawer to toggle
324
+ ToggleFilterDrawer = 'toggle-filter-drawer',
325
+ // Hide filter drawer
326
+ HideFilterDrawer = 'hide-filter-drawer',
327
+ // Handle the date filter state
328
+ UpdateDateFilterState = 'update-date-filter-state',
329
+ // Update the context filter state
330
+ UpdateContextFilterState = 'update-context-filter-state',
331
+ // Update the tag filter state
332
+ UpdateTagFilterState = 'update-tag-filter-state',
333
+ // Update the action and error filter state
334
+ UpdateActionErrorFilterState = 'update-action-error-filter-state',
335
+ // Update the advanced filter state
336
+ UpdateAdvancedFilterState = 'update-advanced-filter-state',
337
+ }
338
+
339
+ // Action definitions
340
+ type Action = (
341
+ | {
342
+ // Action type
343
+ type: ActionType.FinishLoading,
344
+ // Updated logMap
345
+ logMap: LogMap,
346
+ }
347
+ | {
348
+ // Action type
349
+ type: ActionType.ToggleFilterDrawer,
350
+ // The drawer to show
351
+ filterDrawer: FilterDrawer,
352
+ }
353
+ | {
354
+ // Action type
355
+ type: ActionType.ResetFilters,
356
+ // Initial filter states
357
+ initDateFilterState: DateFilterState,
358
+ initContextFilterState: ContextFilterState,
359
+ initTagFilterState: TagFilterState,
360
+ initActionErrorFilterState: ActionErrorFilterState,
361
+ initAdvancedFilterState: AdvancedFilterState,
362
+ }
363
+ | {
364
+ // Action type
365
+ type: ActionType.UpdateDateFilterState,
366
+ // New date filter state
367
+ dateFilterState: DateFilterState,
368
+ }
369
+ | {
370
+ // Action type
371
+ type: ActionType.UpdateContextFilterState,
372
+ // New context filter state
373
+ contextFilterState: ContextFilterState,
374
+ }
375
+ | {
376
+ // Action type
377
+ type: ActionType.UpdateTagFilterState,
378
+ // New tag filter state
379
+ tagFilterState: TagFilterState,
380
+ }
381
+ | {
382
+ // Action type
383
+ type: ActionType.UpdateActionErrorFilterState,
384
+ // New action and error filter state
385
+ actionErrorFilterState: ActionErrorFilterState,
386
+ }
387
+ | {
388
+ // Action type
389
+ type: ActionType.UpdateAdvancedFilterState,
390
+ // New advanced filter state
391
+ advancedFilterState: AdvancedFilterState,
392
+ }
393
+ | {
394
+ // Action type
395
+ type: (
396
+ | ActionType.StartLoading
397
+ | ActionType.HideFilterDrawer
398
+ ),
399
+ }
400
+ );
401
+
402
+ /**
403
+ * Reducer that executes actions
404
+ * @author Gabe Abrams
405
+ * @param state current state
406
+ * @param action action to execute
407
+ */
408
+ const reducer = (state: State, action: Action): State => {
409
+ switch (action.type) {
410
+ case ActionType.StartLoading: {
411
+ return {
412
+ ...state,
413
+ loading: true,
414
+ };
415
+ }
416
+ case ActionType.FinishLoading: {
417
+ return {
418
+ ...state,
419
+ loading: false,
420
+ logMap: action.logMap,
421
+ };
422
+ }
423
+ case ActionType.ToggleFilterDrawer: {
424
+ return {
425
+ ...state,
426
+ expandedFilterDrawer: (
427
+ state.expandedFilterDrawer === action.filterDrawer
428
+ ? undefined // hide
429
+ : action.filterDrawer
430
+ ),
431
+ };
432
+ }
433
+ case ActionType.HideFilterDrawer: {
434
+ return {
435
+ ...state,
436
+ expandedFilterDrawer: undefined,
437
+ };
438
+ }
439
+ case ActionType.ResetFilters: {
440
+ return {
441
+ ...state,
442
+ dateFilterState: action.initDateFilterState,
443
+ contextFilterState: action.initContextFilterState,
444
+ tagFilterState: action.initTagFilterState,
445
+ actionErrorFilterState: action.initActionErrorFilterState,
446
+ advancedFilterState: action.initAdvancedFilterState,
447
+ };
448
+ }
449
+ case ActionType.UpdateDateFilterState: {
450
+ return {
451
+ ...state,
452
+ dateFilterState: action.dateFilterState,
453
+ };
454
+ }
455
+ case ActionType.UpdateContextFilterState: {
456
+ return {
457
+ ...state,
458
+ contextFilterState: action.contextFilterState,
459
+ };
460
+ }
461
+ case ActionType.UpdateTagFilterState: {
462
+ return {
463
+ ...state,
464
+ tagFilterState: action.tagFilterState,
465
+ };
466
+ }
467
+ case ActionType.UpdateActionErrorFilterState: {
468
+ return {
469
+ ...state,
470
+ actionErrorFilterState: action.actionErrorFilterState,
471
+ };
472
+ }
473
+ case ActionType.UpdateAdvancedFilterState: {
474
+ return {
475
+ ...state,
476
+ advancedFilterState: action.advancedFilterState,
477
+ };
478
+ }
479
+ default: {
480
+ return state;
481
+ }
482
+ }
483
+ };
484
+
485
+ /*------------------------------------------------------------------------*/
486
+ /* Component */
487
+ /*------------------------------------------------------------------------*/
488
+
489
+ const LogReviewer: React.FC<Props> = (props) => {
490
+ /*------------------------------------------------------------------------*/
491
+ /* Setup */
492
+ /*------------------------------------------------------------------------*/
493
+
494
+ /* -------------- Props ------------- */
495
+
496
+ // Destructure props
497
+ const {
498
+ LogMetadata,
499
+ onClose,
500
+ } = props;
501
+
502
+ /* -------------- State ------------- */
503
+
504
+ // Create initial date filter state
505
+ const today = getTimeInfoInET();
506
+ const initStartDate: DateTriple = {
507
+ year: today.year,
508
+ month: today.month,
509
+ day: 1,
510
+ };
511
+ const initEndDate: DateTriple = {
512
+ year: today.year,
513
+ month: today.month,
514
+ day: today.day,
515
+ };
516
+ const initDateFilterState: DateFilterState = {
517
+ startDate: initStartDate,
518
+ endDate: initEndDate,
519
+ };
520
+
521
+ // Create initial context filter state
522
+ const initContextFilterState: ContextFilterState = {};
523
+ Object.keys(LogMetadata.Context ?? {}).forEach((context) => {
524
+ const contextValue = (LogMetadata.Context ?? {})[context];
525
+ if (typeof contextValue === 'string') {
526
+ // Case: no subcontexts
527
+ initContextFilterState[contextValue] = true;
528
+ } else {
529
+ // Case: subcontexts exist
530
+ initContextFilterState[contextValue._] = {};
531
+ Object.values((LogMetadata.Context ?? {})[context]).forEach((subcontext) => {
532
+ const subcontextValue = contextValue[subcontext];
533
+ (initContextFilterState[contextValue._] as { [k: string]: boolean })[subcontextValue] = true;
534
+ });
535
+ }
536
+ });
537
+
538
+ // Create initial tag filter state
539
+ const initTagFilterState: TagFilterState = {};
540
+ Object.values(LogMetadata.Tag ?? {}).forEach((tagValue) => {
541
+ initTagFilterState[tagValue] = true;
542
+ });
543
+
544
+ // Create advanced filter state
545
+ const initAdvancedFilterState: AdvancedFilterState = {
546
+ userFirstName: '',
547
+ userLastName: '',
548
+ userEmail: '',
549
+ userId: '',
550
+ includeLearners: true,
551
+ includeTTMs: true,
552
+ includeAdmins: true,
553
+ courseId: '',
554
+ courseName: '',
555
+ isMobile: undefined,
556
+ source: undefined,
557
+ routePath: '',
558
+ routeTemplate: '',
559
+ };
560
+
561
+ // Create action and error filter state
562
+ const initActionErrorFilterState: ActionErrorFilterState = {
563
+ type: undefined,
564
+ errorMessage: '',
565
+ errorCode: '',
566
+ target: {},
567
+ action: {},
568
+ };
569
+ Object.values(LogMetadata.Target ?? {}).forEach((target) => {
570
+ initActionErrorFilterState.target[target] = true;
571
+ });
572
+ Object.values(LogAction).forEach((action) => {
573
+ initActionErrorFilterState.action[action] = true;
574
+ });
575
+
576
+ // Initial state
577
+ const initialState: State = {
578
+ loading: true,
579
+ logMap: {},
580
+ expandedFilterDrawer: undefined,
581
+ dateFilterState: initDateFilterState,
582
+ contextFilterState: initContextFilterState,
583
+ tagFilterState: initTagFilterState,
584
+ actionErrorFilterState: initActionErrorFilterState,
585
+ advancedFilterState: initAdvancedFilterState,
586
+ };
587
+
588
+ // Initialize state
589
+ const [state, dispatch] = useReducer(reducer, initialState);
590
+
591
+ // Destructure common state
592
+ const {
593
+ loading,
594
+ logMap,
595
+ expandedFilterDrawer,
596
+ dateFilterState,
597
+ contextFilterState,
598
+ tagFilterState,
599
+ actionErrorFilterState,
600
+ advancedFilterState,
601
+ } = state;
602
+
603
+ /*------------------------------------------------------------------------*/
604
+ /* Component Functions */
605
+ /*------------------------------------------------------------------------*/
606
+
607
+ /**
608
+ * Get the list of year/month combos that need to be loaded given a new
609
+ * start or end date and the existing logMap
610
+ * @author Gabe Abrams
611
+ * @param newDateFilterState the new date filter state
612
+ * @returns list of year/month combos that need to be loaded
613
+ */
614
+ const listMonthsToLoad = (
615
+ newDateFilterState: DateFilterState,
616
+ ): { year: number, month: number }[] => {
617
+ // List of year/month combos that need to be loaded
618
+ const toLoad: { year: number, month: number }[] = [];
619
+
620
+ // Loop through dates
621
+ let year = newDateFilterState.startDate.year;
622
+ let month = newDateFilterState.startDate.month;
623
+ while (
624
+ // Earlier year
625
+ (year <= newDateFilterState.endDate.year)
626
+ // Current year but included month
627
+ || (
628
+ year === newDateFilterState.endDate.year
629
+ && month <= newDateFilterState.endDate.month
630
+ )
631
+ ) {
632
+ // Add to list
633
+ toLoad.push({
634
+ year,
635
+ month,
636
+ });
637
+
638
+ // Increment
639
+ month += 1;
640
+ if (month > 12) {
641
+ month -= 12;
642
+ year += 1;
643
+ }
644
+ }
645
+
646
+ // Return
647
+ return toLoad;
648
+ };
649
+
650
+ /**
651
+ * Handle updated start/end dates (updates state, loads if necessary)
652
+ * @author Gabe Abrams
653
+ * @param newDateFilterState the new date filter state
654
+ */
655
+ const handleDateRangeUpdated = async (
656
+ newDateFilterState: DateFilterState,
657
+ ) => {
658
+ // Update state
659
+ dispatch({
660
+ type: ActionType.UpdateDateFilterState,
661
+ dateFilterState: newDateFilterState,
662
+ });
663
+
664
+ // Check which year/month combos we need to load
665
+ const toLoad = listMonthsToLoad(newDateFilterState);
666
+
667
+ // If nothing to load, finished
668
+ if (toLoad.length === 0) {
669
+ return;
670
+ }
671
+
672
+ // Start loading
673
+ dispatch({
674
+ type: ActionType.StartLoading,
675
+ });
676
+
677
+ // Load required months
678
+ try {
679
+ for (let i = 0; i < toLoad.length; i++) {
680
+ // Destructure
681
+ const { year, month } = toLoad[i];
682
+
683
+ // Load
684
+ const logs = await visitServerEndpoint({
685
+ path: `${LOG_REVIEW_ROUTE_PATH_PREFIX}/years/${year}/months/${month}`,
686
+ method: 'GET',
687
+ });
688
+
689
+ // Add to map
690
+ if (!logMap[year]) {
691
+ logMap[year] = {};
692
+ }
693
+ logMap[year][month] = logs;
694
+ }
695
+ } catch (err) {
696
+ return showFatalError(err);
697
+ }
698
+
699
+ // Finish loading
700
+ dispatch({
701
+ type: ActionType.FinishLoading,
702
+ logMap,
703
+ });
704
+ };
705
+
706
+ /*------------------------------------------------------------------------*/
707
+ /* Lifecycle Functions */
708
+ /*------------------------------------------------------------------------*/
709
+
710
+ /**
711
+ * Mount
712
+ * @author Gabe Abrams
713
+ */
714
+ useEffect(
715
+ () => {
716
+ // Perform initial load
717
+ handleDateRangeUpdated(dateFilterState);
718
+ },
719
+ [],
720
+ );
721
+
722
+ /*------------------------------------------------------------------------*/
723
+ /* Render */
724
+ /*------------------------------------------------------------------------*/
725
+
726
+ /*----------------------------------------*/
727
+ /* Main UI */
728
+ /*----------------------------------------*/
729
+
730
+ // Body that will be filled with the contents of the panel
731
+ let body: React.ReactNode;
732
+
733
+ /* ------------- Loading ------------ */
734
+
735
+ if (loading) {
736
+ body = (
737
+ <div className="text-center p-5">
738
+ <LoadingSpinner />
739
+ </div>
740
+ );
741
+ }
742
+
743
+ /* ------------ Review UI ----------- */
744
+
745
+ if (!loading) {
746
+ /*----------------------------------------*/
747
+ /* Filters */
748
+ /*----------------------------------------*/
749
+
750
+ // Filter toggle
751
+ const filterToggles = (
752
+ <div className="LogReviewer-filter-toggles">
753
+ <h3 className="m-0">
754
+ Filters:
755
+ </h3>
756
+ <div className="LogReviewer-filter-toggle-buttons alert alert-secondary p-2 m-0">
757
+ {/* Date */}
758
+ <button
759
+ type="button"
760
+ id="LogReviewer-toggle-date-filter-drawer"
761
+ className={`btn btn-${FilterDrawer.Date === expandedFilterDrawer ? 'warning' : 'light'} me-2`}
762
+ aria-label="toggle date filter drawer"
763
+ onClick={() => {
764
+ dispatch({
765
+ type: ActionType.ToggleFilterDrawer,
766
+ filterDrawer: FilterDrawer.Date,
767
+ });
768
+ }}
769
+ >
770
+ <FontAwesomeIcon
771
+ icon={faCalendar}
772
+ className="me-2"
773
+ />
774
+ Date
775
+ </button>
776
+ {/* Context */}
777
+ <button
778
+ type="button"
779
+ id="LogReviewer-toggle-context-filter-drawer"
780
+ className={`btn btn-${FilterDrawer.Context === expandedFilterDrawer ? 'warning' : 'light'} me-2`}
781
+ aria-label="toggle context filter drawer"
782
+ onClick={() => {
783
+ dispatch({
784
+ type: ActionType.ToggleFilterDrawer,
785
+ filterDrawer: FilterDrawer.Context,
786
+ });
787
+ }}
788
+ >
789
+ <FontAwesomeIcon
790
+ icon={faCircle}
791
+ className="me-2"
792
+ />
793
+ Context
794
+ </button>
795
+ {/* Tag */}
796
+ {/* Skip if no tags are used */}
797
+ {(LogMetadata.Tag && Object.keys(LogMetadata.Tag).length > 0) && (
798
+ <button
799
+ type="button"
800
+ id="LogReviewer-toggle-tag-filter-drawer"
801
+ className={`btn btn-${FilterDrawer.Tag === expandedFilterDrawer ? 'warning' : 'light'} me-2`}
802
+ aria-label="toggle tag filter drawer"
803
+ onClick={() => {
804
+ dispatch({
805
+ type: ActionType.ToggleFilterDrawer,
806
+ filterDrawer: FilterDrawer.Tag,
807
+ });
808
+ }}
809
+ >
810
+ <FontAwesomeIcon
811
+ icon={faTag}
812
+ className="me-2"
813
+ />
814
+ Tag
815
+ </button>
816
+ )}
817
+ {/* Action */}
818
+ <button
819
+ type="button"
820
+ id="LogReviewer-toggle-action-filter-drawer"
821
+ className={`btn btn-${FilterDrawer.Action === expandedFilterDrawer ? 'warning' : 'light'} me-2`}
822
+ aria-label="toggle action and error filter drawer"
823
+ onClick={() => {
824
+ dispatch({
825
+ type: ActionType.ToggleFilterDrawer,
826
+ filterDrawer: FilterDrawer.Action,
827
+ });
828
+ }}
829
+ >
830
+ <FontAwesomeIcon
831
+ icon={faHammer}
832
+ className="me-2"
833
+ />
834
+ Action
835
+ </button>
836
+ {/* Advanced */}
837
+ <button
838
+ type="button"
839
+ id="LogReviewer-toggle-advanced-filter-drawer"
840
+ className={`btn btn-${FilterDrawer.Advanced === expandedFilterDrawer ? 'warning' : 'light'}`}
841
+ aria-label="toggle advanced filter drawer"
842
+ onClick={() => {
843
+ dispatch({
844
+ type: ActionType.ToggleFilterDrawer,
845
+ filterDrawer: FilterDrawer.Advanced,
846
+ });
847
+ }}
848
+ >
849
+ <FontAwesomeIcon
850
+ icon={faList}
851
+ className="me-2"
852
+ />
853
+ Advanced
854
+ </button>
855
+ </div>
856
+ </div>
857
+ );
858
+
859
+ // Filter drawer
860
+ let filterDrawer: React.ReactNode;
861
+ if (expandedFilterDrawer) {
862
+ if (expandedFilterDrawer === FilterDrawer.Date) {
863
+ filterDrawer = (
864
+ <TabBox title="Date">
865
+ <SimpleDateChooser
866
+ ariaLabel="filter start date"
867
+ name="filter-start-date"
868
+ year={dateFilterState.startDate.year}
869
+ month={dateFilterState.startDate.month}
870
+ day={dateFilterState.startDate.day}
871
+ chooseFromPast
872
+ onChange={(month, day, year) => {
873
+ dispatch({
874
+ type: ActionType.UpdateDateFilterState,
875
+ dateFilterState: {
876
+ ...dateFilterState,
877
+ startDate: { month, day, year },
878
+ },
879
+ });
880
+ }}
881
+ />
882
+ {' '}
883
+ to
884
+ {' '}
885
+ <SimpleDateChooser
886
+ ariaLabel="filter end date"
887
+ name="filter-end-date"
888
+ year={dateFilterState.endDate.year}
889
+ month={dateFilterState.endDate.month}
890
+ day={dateFilterState.endDate.day}
891
+ chooseFromPast
892
+ onChange={(month, day, year) => {
893
+ dispatch({
894
+ type: ActionType.UpdateDateFilterState,
895
+ dateFilterState: {
896
+ ...dateFilterState,
897
+ endDate: { month, day, year },
898
+ },
899
+ });
900
+ }}
901
+ />
902
+ </TabBox>
903
+ );
904
+ } else if (expandedFilterDrawer === FilterDrawer.Context) {
905
+ // Create item picker items
906
+ const pickableItems: PickableItem[] = (
907
+ Object.keys(LogMetadata.Context ?? {})
908
+ .map((context) => {
909
+ const value = (LogMetadata.Context ?? {})[context];
910
+ if (typeof value === 'string') {
911
+ // No subcategories
912
+ const item: PickableItem = {
913
+ id: context,
914
+ name: genHumanReadableName(context),
915
+ isGroup: false,
916
+ checked: !!contextFilterState[context],
917
+ };
918
+ return item;
919
+ }
920
+
921
+ // Has subcategories
922
+ const children: PickableItem[] = (
923
+ Object.keys(value)
924
+ .filter((subcontext) => {
925
+ return subcontext !== '_'
926
+ })
927
+ .map((subcontext) => {
928
+ return {
929
+ id: `${context}-${subcontext}`,
930
+ name: genHumanReadableName(subcontext),
931
+ isGroup: false,
932
+ checked: !!value[subcontext],
933
+ };
934
+ })
935
+ );
936
+ const item: PickableItem = {
937
+ id: context,
938
+ name: context,
939
+ isGroup: true,
940
+ children,
941
+ };
942
+ return item;
943
+ })
944
+ );
945
+
946
+ // Create filter UI
947
+ filterDrawer = (
948
+ <ItemPicker
949
+ title="Context"
950
+ items={pickableItems}
951
+ onChanged={(updatedItems) => {
952
+ // Update our state
953
+ updatedItems.forEach((pickableItem) => {
954
+ if (pickableItem.isGroup) {
955
+ // Has subcontexts
956
+ pickableItem.children.forEach((subcontextItem) => {
957
+ (contextFilterState as any)[pickableItem.id][subcontextItem.id] = (
958
+ (subcontextItem as any).checked
959
+ );
960
+ });
961
+ } else {
962
+ // No subcontexts
963
+ (contextFilterState as any)[pickableItem.id] = (
964
+ pickableItem.checked
965
+ );
966
+ }
967
+ });
968
+ dispatch({
969
+ type: ActionType.UpdateContextFilterState,
970
+ contextFilterState,
971
+ });
972
+ }}
973
+ />
974
+ );
975
+ } else if (expandedFilterDrawer === FilterDrawer.Tag) {
976
+ // Create filter UI
977
+ filterDrawer = (
978
+ <TabBox title="Tags">
979
+ {
980
+ Object.keys(tagFilterState)
981
+ .map((tag, i) => {
982
+ const description = genHumanReadableName(tag);
983
+ return (
984
+ <CheckboxButton
985
+ id={`LogReviewer-tag-${tag}-checkbox`}
986
+ text={description}
987
+ ariaLabel={`include logs tagged with "${description}" in results`}
988
+ noMarginOnRight={i === Object.keys(tagFilterState).length - 1}
989
+ onChanged={(checked) => {
990
+ tagFilterState[tag] = checked;
991
+ }}
992
+ />
993
+ );
994
+ })
995
+ }
996
+ </TabBox>
997
+ );
998
+ } else if (expandedFilterDrawer === FilterDrawer.Action) {
999
+ // Create filter UI
1000
+ filterDrawer = (
1001
+ <>
1002
+ {/* Log Type */}
1003
+ <TabBox title="Log Type">
1004
+ <RadioButton
1005
+ id="LogReviewer-type-all"
1006
+ text="All Logs"
1007
+ onSelected={() => {
1008
+ actionErrorFilterState.type = undefined;
1009
+ dispatch({
1010
+ type: ActionType.UpdateActionErrorFilterState,
1011
+ actionErrorFilterState,
1012
+ });
1013
+ }}
1014
+ ariaLabel="show logs of all types"
1015
+ selected={actionErrorFilterState.type === undefined}
1016
+ />
1017
+ <RadioButton
1018
+ id="LogReviewer-type-action-only"
1019
+ text="Action Logs Only"
1020
+ onSelected={() => {
1021
+ actionErrorFilterState.type = LogType.Action;
1022
+ dispatch({
1023
+ type: ActionType.UpdateActionErrorFilterState,
1024
+ actionErrorFilterState,
1025
+ });
1026
+ }}
1027
+ ariaLabel="only show action logs"
1028
+ selected={actionErrorFilterState.type === LogType.Action}
1029
+ />
1030
+ <RadioButton
1031
+ id="LogReviewer-type-error-only"
1032
+ text="Action Error Only"
1033
+ onSelected={() => {
1034
+ actionErrorFilterState.type = LogType.Error;
1035
+ dispatch({
1036
+ type: ActionType.UpdateActionErrorFilterState,
1037
+ actionErrorFilterState,
1038
+ });
1039
+ }}
1040
+ ariaLabel="only show error logs"
1041
+ selected={actionErrorFilterState.type === LogType.Error}
1042
+ noMarginOnRight
1043
+ />
1044
+ </TabBox>
1045
+ {/* Actions */}
1046
+ {
1047
+ (
1048
+ actionErrorFilterState.type === undefined
1049
+ || actionErrorFilterState.type === LogType.Action
1050
+ ) && (
1051
+ <TabBox title="Action Log Details">
1052
+ {/* Action */}
1053
+ <ButtonInputGroup
1054
+ label="Action"
1055
+ className="mb-2"
1056
+ >
1057
+ {
1058
+ Object.keys(LogAction)
1059
+ .map((action, i) => {
1060
+ const description = genHumanReadableName(action);
1061
+ return (
1062
+ <CheckboxButton
1063
+ id={`LogReviewer-action-${action}-checkbox`}
1064
+ text={description}
1065
+ ariaLabel={`include logs with action type "${description}" in results`}
1066
+ noMarginOnRight={i === Object.keys(LogAction).length - 1}
1067
+ onChanged={(checked) => {
1068
+ actionErrorFilterState.action[action] = checked;
1069
+ dispatch({
1070
+ type: ActionType.UpdateActionErrorFilterState,
1071
+ actionErrorFilterState,
1072
+ });
1073
+ }}
1074
+ />
1075
+ );
1076
+ })
1077
+ }
1078
+ </ButtonInputGroup>
1079
+ {/* Target */}
1080
+ <ButtonInputGroup label="Target">
1081
+ {/* Nothing here */}
1082
+ {(Object.keys(LogMetadata.Target ?? {}).length === 0) && (
1083
+ <div>
1084
+ This app does not have any targets yet.
1085
+ </div>
1086
+ )}
1087
+ {/* List of targets */}
1088
+ {
1089
+ Object.keys(LogMetadata.Target ?? {})
1090
+ .map((target, i) => {
1091
+ const description = genHumanReadableName(target);
1092
+ return (
1093
+ <CheckboxButton
1094
+ id={`LogReviewer-target-${target}-checkbox`}
1095
+ text={description}
1096
+ ariaLabel={`include logs with target "${description}" in results`}
1097
+ onChanged={(checked) => {
1098
+ actionErrorFilterState.target[target] = checked;
1099
+ dispatch({
1100
+ type: ActionType.UpdateActionErrorFilterState,
1101
+ actionErrorFilterState,
1102
+ });
1103
+ }}
1104
+ noMarginOnRight={i === Object.keys(LogMetadata.Target ?? {}).length - 1}
1105
+ />
1106
+ );
1107
+ })
1108
+ }
1109
+ </ButtonInputGroup>
1110
+ </TabBox>
1111
+ )
1112
+ }
1113
+ {/* Errors */}
1114
+ {
1115
+ (
1116
+ actionErrorFilterState.type === undefined
1117
+ || actionErrorFilterState.type === LogType.Error
1118
+ ) && (
1119
+ <TabBox title="Error Log Details">
1120
+ {/* Message */}
1121
+ <div className="input-group mb-2">
1122
+ <span className="input-group-text">
1123
+ Error Message
1124
+ </span>
1125
+ <input
1126
+ type="text"
1127
+ className="form-control"
1128
+ aria-label="query for error message"
1129
+ value={actionErrorFilterState.errorMessage}
1130
+ onChange={(e) => {
1131
+ actionErrorFilterState.errorMessage = e.target.value;
1132
+ dispatch({
1133
+ type: ActionType.UpdateActionErrorFilterState,
1134
+ actionErrorFilterState,
1135
+ });
1136
+ }}
1137
+ />
1138
+ </div>
1139
+ {/* Code */}
1140
+ <div className="input-group mb-2">
1141
+ <span className="input-group-text">
1142
+ Error Code
1143
+ </span>
1144
+ <input
1145
+ type="text"
1146
+ className="form-control"
1147
+ aria-label="query for error code"
1148
+ value={actionErrorFilterState.errorMessage}
1149
+ onChange={(e) => {
1150
+ actionErrorFilterState.errorCode = (
1151
+ (e.target.value)
1152
+ .trim()
1153
+ .toUpperCase()
1154
+ );
1155
+ dispatch({
1156
+ type: ActionType.UpdateActionErrorFilterState,
1157
+ actionErrorFilterState,
1158
+ });
1159
+ }}
1160
+ />
1161
+ </div>
1162
+ </TabBox>
1163
+ )
1164
+ }
1165
+ </>
1166
+ );
1167
+ } else if (expandedFilterDrawer === FilterDrawer.Advanced) {
1168
+ // Create advanced filter ui
1169
+ filterDrawer = (
1170
+ <>
1171
+ {/* User Info */}
1172
+ <TabBox title="User Info">
1173
+ {/* First Name */}
1174
+ <div className="input-group mb-2">
1175
+ <span className="input-group-text">
1176
+ User First Name
1177
+ </span>
1178
+ <input
1179
+ type="text"
1180
+ className="form-control"
1181
+ aria-label="query for user first name"
1182
+ value={advancedFilterState.userFirstName}
1183
+ onChange={(e) => {
1184
+ advancedFilterState.userFirstName = e.target.value;
1185
+ dispatch({
1186
+ type: ActionType.UpdateAdvancedFilterState,
1187
+ advancedFilterState,
1188
+ });
1189
+ }}
1190
+ />
1191
+ </div>
1192
+ {/* Last Name */}
1193
+ <div className="input-group mb-2">
1194
+ <span className="input-group-text">
1195
+ User Last Name
1196
+ </span>
1197
+ <input
1198
+ type="text"
1199
+ className="form-control"
1200
+ aria-label="query for user last name"
1201
+ value={advancedFilterState.userLastName}
1202
+ onChange={(e) => {
1203
+ advancedFilterState.userLastName = e.target.value;
1204
+ dispatch({
1205
+ type: ActionType.UpdateAdvancedFilterState,
1206
+ advancedFilterState,
1207
+ });
1208
+ }}
1209
+ />
1210
+ </div>
1211
+ {/* Email */}
1212
+ <div className="input-group mb-2">
1213
+ <span className="input-group-text">
1214
+ User Email
1215
+ </span>
1216
+ <input
1217
+ type="text"
1218
+ className="form-control"
1219
+ aria-label="query for user email"
1220
+ value={advancedFilterState.userEmail}
1221
+ onChange={(e) => {
1222
+ advancedFilterState.userEmail = (
1223
+ (e.target.value)
1224
+ .trim()
1225
+ );
1226
+ dispatch({
1227
+ type: ActionType.UpdateAdvancedFilterState,
1228
+ advancedFilterState,
1229
+ });
1230
+ }}
1231
+ />
1232
+ </div>
1233
+ {/* Canvas Id */}
1234
+ <div className="input-group mb-2">
1235
+ <span className="input-group-text">
1236
+ User Canvas Id
1237
+ </span>
1238
+ <input
1239
+ type="text"
1240
+ className="form-control"
1241
+ aria-label="query for user canvas id"
1242
+ value={advancedFilterState.userId}
1243
+ onChange={(e) => {
1244
+ const { value } = e.target;
1245
+ // Only update if value contains only numbers
1246
+ if (/^\d+$/.test(value)) {
1247
+ advancedFilterState.userId = (
1248
+ (e.target.value)
1249
+ .trim()
1250
+ );
1251
+ }
1252
+ dispatch({
1253
+ type: ActionType.UpdateAdvancedFilterState,
1254
+ advancedFilterState,
1255
+ });
1256
+ }}
1257
+ />
1258
+ </div>
1259
+ {/* Role */}
1260
+ <ButtonInputGroup label="Role">
1261
+ <CheckboxButton
1262
+ text="Students"
1263
+ onChanged={(checked) => {
1264
+ advancedFilterState.includeLearners = checked;
1265
+ dispatch({
1266
+ type: ActionType.UpdateAdvancedFilterState,
1267
+ advancedFilterState,
1268
+ });
1269
+ }}
1270
+ checked={advancedFilterState.includeLearners}
1271
+ ariaLabel="show logs from students"
1272
+ />
1273
+ <CheckboxButton
1274
+ text="Teaching Team Members"
1275
+ onChanged={(checked) => {
1276
+ advancedFilterState.includeTTMs = checked;
1277
+ dispatch({
1278
+ type: ActionType.UpdateAdvancedFilterState,
1279
+ advancedFilterState,
1280
+ });
1281
+ }}
1282
+ checked={advancedFilterState.includeTTMs}
1283
+ ariaLabel="show logs from teaching team members"
1284
+ />
1285
+ <CheckboxButton
1286
+ text="Admins"
1287
+ onChanged={(checked) => {
1288
+ advancedFilterState.includeAdmins = checked;
1289
+ dispatch({
1290
+ type: ActionType.UpdateAdvancedFilterState,
1291
+ advancedFilterState,
1292
+ });
1293
+ }}
1294
+ checked={advancedFilterState.includeAdmins}
1295
+ ariaLabel="show logs from admins"
1296
+ />
1297
+ </ButtonInputGroup>
1298
+ </TabBox>
1299
+
1300
+ {/* Course Info */}
1301
+ <TabBox title="Course Info">
1302
+ {/* Name */}
1303
+ <div className="input-group mb-2">
1304
+ <span className="input-group-text">
1305
+ Course Name
1306
+ </span>
1307
+ <input
1308
+ type="text"
1309
+ className="form-control"
1310
+ aria-label="query for course name"
1311
+ value={advancedFilterState.courseName}
1312
+ onChange={(e) => {
1313
+ advancedFilterState.courseName = e.target.value;
1314
+ dispatch({
1315
+ type: ActionType.UpdateAdvancedFilterState,
1316
+ advancedFilterState,
1317
+ });
1318
+ }}
1319
+ />
1320
+ </div>
1321
+ {/* Canvas Id */}
1322
+ <div className="input-group mb-2">
1323
+ <span className="input-group-text">
1324
+ Course Canvas Id
1325
+ </span>
1326
+ <input
1327
+ type="text"
1328
+ className="form-control"
1329
+ aria-label="query for course canvas id"
1330
+ value={advancedFilterState.courseId}
1331
+ onChange={(e) => {
1332
+ const { value } = e.target;
1333
+ // Only update if value contains only numbers
1334
+ if (/^\d+$/.test(value)) {
1335
+ advancedFilterState.courseId = (
1336
+ (e.target.value)
1337
+ .trim()
1338
+ );
1339
+ }
1340
+ dispatch({
1341
+ type: ActionType.UpdateAdvancedFilterState,
1342
+ advancedFilterState,
1343
+ });
1344
+ }}
1345
+ />
1346
+ </div>
1347
+ </TabBox>
1348
+
1349
+ {/* Device Info */}
1350
+ <TabBox title="Device Info">
1351
+ <ButtonInputGroup label="Device Type">
1352
+ <RadioButton
1353
+ text="All Devices"
1354
+ ariaLabel="show logs from all devices"
1355
+ selected={advancedFilterState.isMobile === undefined}
1356
+ onSelected={() => {
1357
+ advancedFilterState.isMobile = undefined;
1358
+ dispatch({
1359
+ type: ActionType.UpdateAdvancedFilterState,
1360
+ advancedFilterState,
1361
+ });
1362
+ }}
1363
+ />
1364
+ <RadioButton
1365
+ text="Mobile Only"
1366
+ ariaLabel="show logs from mobile devices"
1367
+ selected={advancedFilterState.isMobile === true}
1368
+ onSelected={() => {
1369
+ advancedFilterState.isMobile = true;
1370
+ dispatch({
1371
+ type: ActionType.UpdateAdvancedFilterState,
1372
+ advancedFilterState,
1373
+ });
1374
+ }}
1375
+ />
1376
+ <RadioButton
1377
+ text="Desktop Only"
1378
+ ariaLabel="show logs from desktop devices"
1379
+ selected={advancedFilterState.isMobile === false}
1380
+ onSelected={() => {
1381
+ advancedFilterState.isMobile = false;
1382
+ dispatch({
1383
+ type: ActionType.UpdateAdvancedFilterState,
1384
+ advancedFilterState,
1385
+ });
1386
+ }}
1387
+ noMarginOnRight
1388
+ />
1389
+ </ButtonInputGroup>
1390
+ </TabBox>
1391
+
1392
+ {/* Source */}
1393
+ <TabBox title="Source">
1394
+ <ButtonInputGroup label="Source Type">
1395
+ <RadioButton
1396
+ text="Both"
1397
+ ariaLabel="show logs from all sources"
1398
+ selected={advancedFilterState.source === undefined}
1399
+ onSelected={() => {
1400
+ advancedFilterState.source = undefined;
1401
+ dispatch({
1402
+ type: ActionType.UpdateAdvancedFilterState,
1403
+ advancedFilterState,
1404
+ });
1405
+ }}
1406
+ />
1407
+ <RadioButton
1408
+ text="Client Only"
1409
+ ariaLabel="show logs from client source"
1410
+ selected={advancedFilterState.source === LogSource.Client}
1411
+ onSelected={() => {
1412
+ advancedFilterState.source = LogSource.Client;
1413
+ dispatch({
1414
+ type: ActionType.UpdateAdvancedFilterState,
1415
+ advancedFilterState,
1416
+ });
1417
+ }}
1418
+ />
1419
+ <RadioButton
1420
+ text="Server Only"
1421
+ ariaLabel="show logs from server source"
1422
+ selected={advancedFilterState.source === LogSource.Server}
1423
+ onSelected={() => {
1424
+ advancedFilterState.source = LogSource.Server;
1425
+ dispatch({
1426
+ type: ActionType.UpdateAdvancedFilterState,
1427
+ advancedFilterState,
1428
+ });
1429
+ }}
1430
+ noMarginOnRight
1431
+ />
1432
+ </ButtonInputGroup>
1433
+
1434
+ {/* Server filters */}
1435
+ {advancedFilterState.source !== LogSource.Client && (
1436
+ <div className="mt-2">
1437
+ {/* Route path */}
1438
+ <div className="input-group mb-2">
1439
+ <span className="input-group-text">
1440
+ Server Route Path
1441
+ </span>
1442
+ <input
1443
+ type="text"
1444
+ className="form-control"
1445
+ aria-label="query for server route path"
1446
+ placeholder="e.g. /api/ttm/courses/12345"
1447
+ value={advancedFilterState.routePath}
1448
+ onChange={(e) => {
1449
+ advancedFilterState.courseName = (
1450
+ (e.target.value)
1451
+ .trim()
1452
+ );
1453
+ dispatch({
1454
+ type: ActionType.UpdateAdvancedFilterState,
1455
+ advancedFilterState,
1456
+ });
1457
+ }}
1458
+ />
1459
+ </div>
1460
+
1461
+ {/* Route template */}
1462
+ <div className="input-group mb-2">
1463
+ <span className="input-group-text">
1464
+ Server Route Template
1465
+ </span>
1466
+ <input
1467
+ type="text"
1468
+ className="form-control"
1469
+ aria-label="query for server route template"
1470
+ value={advancedFilterState.routeTemplate}
1471
+ placeholder="e.g. /api/ttm/courses/:courseId"
1472
+ onChange={(e) => {
1473
+ advancedFilterState.courseName = (
1474
+ (e.target.value)
1475
+ .trim()
1476
+ );
1477
+ dispatch({
1478
+ type: ActionType.UpdateAdvancedFilterState,
1479
+ advancedFilterState,
1480
+ });
1481
+ }}
1482
+ />
1483
+ </div>
1484
+ </div>
1485
+ )}
1486
+ </TabBox>
1487
+ </>
1488
+ );
1489
+ }
1490
+ }
1491
+
1492
+ // Filters UI
1493
+ const filters = (
1494
+ <>
1495
+ {filterToggles}
1496
+ {filterDrawer && (
1497
+ <Drawer>
1498
+ {filterDrawer}
1499
+ </Drawer>
1500
+ )}
1501
+ </>
1502
+ );
1503
+
1504
+ // Actually filter the logs
1505
+ // > Perform filters
1506
+ const logs: Log[] = [];
1507
+ Object.keys(logMap).forEach((year) => {
1508
+ Object.keys(logMap[year]).forEach((month) => {
1509
+ logMap[year][month].forEach((log) => {
1510
+ /* ----------- Date Filter ---------- */
1511
+
1512
+ // Before start date
1513
+ if (
1514
+ // Previous year
1515
+ log.year < dateFilterState.startDate.year
1516
+ // Same year, earlier month
1517
+ || (
1518
+ (log.year === dateFilterState.startDate.year)
1519
+ && (log.month < dateFilterState.startDate.month)
1520
+ )
1521
+ // Same year, same month, earlier day
1522
+ || (
1523
+ (log.year === dateFilterState.startDate.year)
1524
+ && (log.month === dateFilterState.startDate.month)
1525
+ && (log.day < dateFilterState.startDate.day)
1526
+ )
1527
+ ) {
1528
+ return;
1529
+ }
1530
+
1531
+ // After end date
1532
+ if (
1533
+ // Later year
1534
+ log.year > dateFilterState.endDate.year
1535
+ // Same year, later month
1536
+ || (
1537
+ (log.year === dateFilterState.endDate.year)
1538
+ && (log.month > dateFilterState.endDate.month)
1539
+ )
1540
+ // Same year, same month, later day
1541
+ || (
1542
+ (log.year === dateFilterState.endDate.year)
1543
+ && (log.month === dateFilterState.endDate.month)
1544
+ && (log.day > dateFilterState.endDate.day)
1545
+ )
1546
+ ) {
1547
+ return;
1548
+ }
1549
+
1550
+ /* --------- Context Filter --------- */
1551
+
1552
+ // Context doesn't match
1553
+ if (
1554
+ // Whole context is deselected
1555
+ contextFilterState[log.context] === false
1556
+ // None of the subcontexts are selected
1557
+ || (
1558
+ Object.values(contextFilterState[log.context] ?? {})
1559
+ .every((isSelected) => {
1560
+ return !isSelected;
1561
+ })
1562
+ )
1563
+ ) {
1564
+ return;
1565
+ }
1566
+
1567
+ // Subcontext doesn't match
1568
+ if (
1569
+ // Log has a subcontext
1570
+ log.subcontext
1571
+ // Context has subcontexts
1572
+ && (
1573
+ contextFilterState[log.context]
1574
+ && contextFilterState[log.context] !== false
1575
+ && contextFilterState[log.context] !== true
1576
+ )
1577
+ // Subcontext is not selected
1578
+ && !(contextFilterState as any)[log.context][log.subcontext]
1579
+ ) {
1580
+ return;
1581
+ }
1582
+
1583
+ /* -------------- Tags -------------- */
1584
+
1585
+ // No tags match
1586
+ if (
1587
+ // Log has at least one tag
1588
+ log.tags.length > 0
1589
+ // No tags match
1590
+ && (
1591
+ log.tags.every((tag) => {
1592
+ return !tagFilterState[tag];
1593
+ })
1594
+ )
1595
+ ) {
1596
+ return;
1597
+ }
1598
+
1599
+ /* ------- Actions and Errors ------- */
1600
+
1601
+ // Log type doesn't match
1602
+ if (
1603
+ // Filter won't allow all types
1604
+ actionErrorFilterState.type !== undefined
1605
+ // Log type doesn't match
1606
+ && actionErrorFilterState.type !== log.type
1607
+ ) {
1608
+ return;
1609
+ }
1610
+
1611
+ // Filter errors
1612
+ if (log.type === LogType.Error) {
1613
+ // Message doesn't match
1614
+ if (
1615
+ // Message exists
1616
+ log.errorMessage
1617
+ // Message filter exists
1618
+ && actionErrorFilterState.errorMessage.trim().length > 0
1619
+ // Message doesn't match
1620
+ && log.errorMessage.toLowerCase().includes(
1621
+ actionErrorFilterState.errorMessage.trim().toLowerCase(),
1622
+ )
1623
+ ) {
1624
+ return;
1625
+ }
1626
+
1627
+ // Code doesn't match
1628
+ if (
1629
+ // Code exists
1630
+ log.errorCode
1631
+ // Code filter exists
1632
+ && actionErrorFilterState.errorCode.trim().length > 0
1633
+ // Code doesn't match
1634
+ && log.errorCode.toUpperCase().includes(
1635
+ actionErrorFilterState.errorCode.trim().toUpperCase(),
1636
+ )
1637
+ ) {
1638
+ return;
1639
+ }
1640
+ }
1641
+
1642
+ // Filter actions
1643
+ if (log.type === LogType.Action) {
1644
+ // Target isn't selected
1645
+ if (
1646
+ // Target exists
1647
+ log.target
1648
+ // Target isn't selected
1649
+ && !actionErrorFilterState.target[log.target]
1650
+ ) {
1651
+ return;
1652
+ }
1653
+
1654
+ // Action
1655
+ if (
1656
+ // Action exists
1657
+ log.action
1658
+ // Action isn't selected
1659
+ && !actionErrorFilterState.action[log.action]
1660
+ ) {
1661
+ return;
1662
+ }
1663
+ }
1664
+
1665
+ /* --------- Advanced Filter -------- */
1666
+
1667
+ // First name doesn't match
1668
+ if (
1669
+ // First name exists
1670
+ log.userFirstName
1671
+ // First name query doesn't match
1672
+ && !log.userFirstName.toLowerCase().includes(
1673
+ advancedFilterState.userFirstName.toLowerCase().trim(),
1674
+ )
1675
+ ) {
1676
+ return;
1677
+ }
1678
+
1679
+ // Last name doesn't match
1680
+ if (
1681
+ // Last name exists
1682
+ log.userLastName
1683
+ // Last name query doesn't match
1684
+ && !log.userLastName.toLowerCase().includes(
1685
+ advancedFilterState.userLastName.toLowerCase().trim(),
1686
+ )
1687
+ ) {
1688
+ return;
1689
+ }
1690
+
1691
+ // Email doesn't match
1692
+ if (
1693
+ // Email exists
1694
+ log.userEmail
1695
+ // Email query doesn't match
1696
+ && !log.userEmail.toLowerCase().includes(
1697
+ advancedFilterState.userEmail.toLowerCase().trim(),
1698
+ )
1699
+ ) {
1700
+ return;
1701
+ }
1702
+
1703
+ // User id doesn't match
1704
+ if (
1705
+ // User id exists
1706
+ log.userId
1707
+ // User id doesn't match
1708
+ && !String(log.userId).includes(
1709
+ advancedFilterState.userId.trim(),
1710
+ )
1711
+ ) {
1712
+ return;
1713
+ }
1714
+
1715
+ // Learner not allowed
1716
+ if (
1717
+ // User is a learner
1718
+ log.isLearner
1719
+ // Learners aren't included
1720
+ && !advancedFilterState.includeLearners
1721
+ ) {
1722
+ return;
1723
+ }
1724
+
1725
+ // TTM not allowed
1726
+ if (
1727
+ // User is a ttm
1728
+ log.isTTM
1729
+ // TTMs aren't included
1730
+ && !advancedFilterState.includeTTMs
1731
+ ) {
1732
+ return;
1733
+ }
1734
+
1735
+ // Admin not allowed
1736
+ if (
1737
+ // User is an admin
1738
+ log.isAdmin
1739
+ // Admins aren't included
1740
+ && !advancedFilterState.includeAdmins
1741
+ ) {
1742
+ return;
1743
+ }
1744
+
1745
+ // Course Id doesn't match
1746
+ if (
1747
+ // Course Id exists
1748
+ log.courseId
1749
+ // Course Id doesn't match
1750
+ && !String(log.courseId).includes(
1751
+ advancedFilterState.courseId.trim(),
1752
+ )
1753
+ ) {
1754
+ return;
1755
+ }
1756
+
1757
+ // Course name doesn't match
1758
+ if (
1759
+ // Course name exists
1760
+ log.courseName
1761
+ // Course name doesn't match
1762
+ && !String(log.courseName).includes(
1763
+ advancedFilterState.courseName.trim(),
1764
+ )
1765
+ ) {
1766
+ return;
1767
+ }
1768
+
1769
+ // Mobile filter doesn't match
1770
+ if (
1771
+ // Mobile filter exists
1772
+ advancedFilterState.isMobile !== undefined
1773
+ // Device info exists
1774
+ && log.device
1775
+ // Mobile filter doesn't match
1776
+ && (advancedFilterState.isMobile === log.device.isMobile)
1777
+ ) {
1778
+ return;
1779
+ }
1780
+
1781
+ // Log source doesn't match
1782
+ if (
1783
+ // Source filter exists
1784
+ advancedFilterState.source !== undefined
1785
+ // Source info exists
1786
+ && log.source
1787
+ // Source filter doesn't match
1788
+ && (advancedFilterState.source !== log.source)
1789
+ ) {
1790
+ return;
1791
+ }
1792
+
1793
+ // Route path doesn't match (Only for server source)
1794
+ if (
1795
+ // Source is server
1796
+ (log.source === LogSource.Server)
1797
+ // Route path is being filtered
1798
+ && (advancedFilterState.routePath.trim().length)
1799
+ // Route path doesn't match
1800
+ && !(log.routePath.includes(advancedFilterState.routePath.trim()))
1801
+ ) {
1802
+ return;
1803
+ }
1804
+
1805
+ // Route template doesn't match (Only for server source)
1806
+ if (
1807
+ // Source is server
1808
+ (log.source === LogSource.Server)
1809
+ // Route template is being filtered
1810
+ && (advancedFilterState.routeTemplate.trim().length)
1811
+ // Route template doesn't match
1812
+ && !(log.routeTemplate.includes(advancedFilterState.routeTemplate.trim()))
1813
+ ) {
1814
+ return;
1815
+ }
1816
+
1817
+ /* -------------- Done -------------- */
1818
+
1819
+ // Made it past all filters. Add to the list
1820
+ logs.push(log);
1821
+ });
1822
+ });
1823
+ });
1824
+
1825
+ /*----------------------------------------*/
1826
+ /* Data */
1827
+ /*----------------------------------------*/
1828
+
1829
+ // Create data table
1830
+ const columns: IntelliTableColumn[] = [
1831
+ {
1832
+ title: 'First Name',
1833
+ param: 'userFirstName',
1834
+ type: ParamType.String,
1835
+ },
1836
+ {
1837
+ title: 'Last Name',
1838
+ param: 'userLastName',
1839
+ type: ParamType.String,
1840
+ },
1841
+ {
1842
+ title: 'Email',
1843
+ param: 'userEmail',
1844
+ type: ParamType.String,
1845
+ },
1846
+ {
1847
+ title: 'Canvas Id',
1848
+ param: 'userId',
1849
+ type: ParamType.Int,
1850
+ },
1851
+ {
1852
+ title: 'Student',
1853
+ param: 'isLearner',
1854
+ type: ParamType.Boolean,
1855
+ },
1856
+ {
1857
+ title: 'Teaching Staff',
1858
+ param: 'isTTM',
1859
+ type: ParamType.Boolean,
1860
+ startsHidden: true,
1861
+ },
1862
+ {
1863
+ title: 'Admin',
1864
+ param: 'isAdmin',
1865
+ type: ParamType.Boolean,
1866
+ startsHidden: true,
1867
+ },
1868
+ {
1869
+ title: 'Course Canvas Id',
1870
+ param: 'courseId',
1871
+ type: ParamType.Int,
1872
+ startsHidden: true,
1873
+ },
1874
+ {
1875
+ title: 'Course Name',
1876
+ param: 'courseName',
1877
+ type: ParamType.String,
1878
+ },
1879
+ {
1880
+ title: 'Browser Name',
1881
+ param: 'browser.name',
1882
+ type: ParamType.String,
1883
+ startsHidden: true,
1884
+ },
1885
+ {
1886
+ title: 'Browser Version',
1887
+ param: 'browser.version',
1888
+ type: ParamType.String,
1889
+ startsHidden: true,
1890
+ },
1891
+ {
1892
+ title: 'OS',
1893
+ param: 'device.os',
1894
+ type: ParamType.String,
1895
+ startsHidden: true,
1896
+ },
1897
+ {
1898
+ title: 'Mobile',
1899
+ param: 'device.isMobile',
1900
+ type: ParamType.Boolean,
1901
+ startsHidden: true,
1902
+ },
1903
+ {
1904
+ title: 'Year',
1905
+ param: 'year',
1906
+ type: ParamType.Int,
1907
+ },
1908
+ {
1909
+ title: 'Month',
1910
+ param: 'month',
1911
+ type: ParamType.Int,
1912
+ },
1913
+ {
1914
+ title: 'Day',
1915
+ param: 'day',
1916
+ type: ParamType.Int,
1917
+ },
1918
+ {
1919
+ title: 'Hour',
1920
+ param: 'hour',
1921
+ type: ParamType.Int,
1922
+ },
1923
+ {
1924
+ title: 'Minute',
1925
+ param: 'minute',
1926
+ type: ParamType.Int,
1927
+ startsHidden: true,
1928
+ },
1929
+ {
1930
+ title: 'Timestamp',
1931
+ param: 'timestamp',
1932
+ type: ParamType.Int,
1933
+ startsHidden: true,
1934
+ },
1935
+ {
1936
+ title: 'Context',
1937
+ param: 'context',
1938
+ type: ParamType.String,
1939
+ },
1940
+ {
1941
+ title: 'Subcontext',
1942
+ param: 'subcontext',
1943
+ type: ParamType.String,
1944
+ },
1945
+ {
1946
+ title: 'Tags',
1947
+ param: 'tags',
1948
+ type: ParamType.JSON,
1949
+ startsHidden: true,
1950
+ },
1951
+ {
1952
+ title: 'Log Level',
1953
+ param: 'level',
1954
+ type: ParamType.String,
1955
+ startsHidden: true,
1956
+ },
1957
+ {
1958
+ title: 'Metadata',
1959
+ param: 'metadata',
1960
+ type: ParamType.JSON,
1961
+ startsHidden: true,
1962
+ },
1963
+ {
1964
+ title: 'Source',
1965
+ param: 'source',
1966
+ type: ParamType.String,
1967
+ },
1968
+ {
1969
+ title: 'Server Route Path',
1970
+ param: 'routePath',
1971
+ type: ParamType.String,
1972
+ startsHidden: true,
1973
+ },
1974
+ {
1975
+ title: 'Server Route Template',
1976
+ param: 'routeTemplate',
1977
+ type: ParamType.String,
1978
+ startsHidden: true,
1979
+ },
1980
+ {
1981
+ title: 'Type',
1982
+ param: 'type',
1983
+ type: ParamType.String,
1984
+ },
1985
+ {
1986
+ title: 'Error Message',
1987
+ param: 'errorMessage',
1988
+ type: ParamType.String,
1989
+ startsHidden: true,
1990
+ },
1991
+ {
1992
+ title: 'Error Code',
1993
+ param: 'errorCode',
1994
+ type: ParamType.String,
1995
+ startsHidden: true,
1996
+ },
1997
+ {
1998
+ title: 'Error Stack',
1999
+ param: 'errorStack',
2000
+ type: ParamType.String,
2001
+ startsHidden: true,
2002
+ },
2003
+ {
2004
+ title: 'Action Target',
2005
+ param: 'target',
2006
+ type: ParamType.String,
2007
+ startsHidden: true,
2008
+ },
2009
+ {
2010
+ title: 'Action Type',
2011
+ param: 'action',
2012
+ type: ParamType.String,
2013
+ startsHidden: true,
2014
+ },
2015
+ ];
2016
+
2017
+ // Create intelliTable
2018
+ const dataTable = (
2019
+ logs.length === 0
2020
+ ? (
2021
+ <>
2022
+ <h3 className="m-0">
2023
+ Matching Logs:
2024
+ </h3>
2025
+ <div className="alert alert-warning text-center">
2026
+ <h4 className="m-1">
2027
+ No Logs to Show
2028
+ </h4>
2029
+ <div>
2030
+ Either your filters are too strict or no matching logs have been
2031
+ created yet.
2032
+ </div>
2033
+ </div>
2034
+ </>
2035
+ )
2036
+ : (
2037
+ <IntelliTable
2038
+ title="Matching Logs:"
2039
+ id="logs"
2040
+ data={logs}
2041
+ columns={columns}
2042
+ />
2043
+ )
2044
+ );
2045
+
2046
+ // Main body
2047
+ body = (
2048
+ <>
2049
+ {filters}
2050
+ <div className="mt-2">
2051
+ {dataTable}
2052
+ </div>
2053
+ </>
2054
+ );
2055
+ }
2056
+
2057
+ /* ---------- Wrap in Modal --------- */
2058
+
2059
+ return (
2060
+ <div className="LogReviewer-outer-container">
2061
+ {/* Style */}
2062
+ <style>{style}</style>
2063
+
2064
+ <div className="LogReviewer-inner-container">
2065
+ <div className="LogReviewer-header">
2066
+ <div className="LogReviewer-header-title">
2067
+ <h3 className="text-center m-0">
2068
+ Log Review Dashboard
2069
+ </h3>
2070
+ </div>
2071
+ <div style={{ width: 0 }}>
2072
+ <button
2073
+ type="button"
2074
+ className="LogReviewer-header-close-button btn btn-dark btn-lg pe-0"
2075
+ aria-label="close log reviewer panel"
2076
+ onClick={onClose}
2077
+ >
2078
+ <FontAwesomeIcon
2079
+ icon={faTimes}
2080
+ />
2081
+ </button>
2082
+ </div>
2083
+ </div>
2084
+ <div className="LogReviewer-contents">
2085
+ {body}
2086
+ </div>
2087
+ </div>
2088
+ </div>
2089
+ );
2090
+ };
2091
+
2092
+ /*------------------------------------------------------------------------*/
2093
+ /* Wrap Up */
2094
+ /*------------------------------------------------------------------------*/
2095
+
2096
+ // Export component
2097
+ export default LogReviewer;