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

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 +2018 -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 +2014 -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 +2091 -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,2091 @@
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
+ onChange={(month, day, year) => {
872
+ dispatch({
873
+ type: ActionType.UpdateDateFilterState,
874
+ dateFilterState: {
875
+ ...dateFilterState,
876
+ startDate: { month, day, year },
877
+ },
878
+ });
879
+ }}
880
+ />
881
+ {' '}
882
+ to
883
+ {' '}
884
+ <SimpleDateChooser
885
+ ariaLabel="filter end date"
886
+ name="filter-end-date"
887
+ year={dateFilterState.endDate.year}
888
+ month={dateFilterState.endDate.month}
889
+ day={dateFilterState.endDate.day}
890
+ onChange={(month, day, year) => {
891
+ dispatch({
892
+ type: ActionType.UpdateDateFilterState,
893
+ dateFilterState: {
894
+ ...dateFilterState,
895
+ endDate: { month, day, year },
896
+ },
897
+ });
898
+ }}
899
+ />
900
+ </TabBox>
901
+ );
902
+ } else if (expandedFilterDrawer === FilterDrawer.Context) {
903
+ // Create item picker items
904
+ const pickableItems: PickableItem[] = (
905
+ Object.keys(LogMetadata.Context ?? {})
906
+ .map((context) => {
907
+ const value = (LogMetadata.Context ?? {})[context];
908
+ if (typeof value === 'string') {
909
+ // No subcategories
910
+ const item: PickableItem = {
911
+ id: context,
912
+ name: genHumanReadableName(context),
913
+ isGroup: false,
914
+ checked: !!contextFilterState[context],
915
+ };
916
+ return item;
917
+ }
918
+
919
+ // Has subcategories
920
+ const children: PickableItem[] = (
921
+ Object.keys(value)
922
+ .filter((subcontext) => {
923
+ return subcontext !== '_'
924
+ })
925
+ .map((subcontext) => {
926
+ return {
927
+ id: `${context}-${subcontext}`,
928
+ name: genHumanReadableName(subcontext),
929
+ isGroup: false,
930
+ checked: !!value[subcontext],
931
+ };
932
+ })
933
+ );
934
+ const item: PickableItem = {
935
+ id: context,
936
+ name: context,
937
+ isGroup: true,
938
+ children,
939
+ };
940
+ return item;
941
+ })
942
+ );
943
+
944
+ // Create filter UI
945
+ filterDrawer = (
946
+ <ItemPicker
947
+ title="Context"
948
+ items={pickableItems}
949
+ onChanged={(updatedItems) => {
950
+ // Update our state
951
+ updatedItems.forEach((pickableItem) => {
952
+ if (pickableItem.isGroup) {
953
+ // Has subcontexts
954
+ pickableItem.children.forEach((subcontextItem) => {
955
+ (contextFilterState as any)[pickableItem.id][subcontextItem.id] = (
956
+ (subcontextItem as any).checked
957
+ );
958
+ });
959
+ } else {
960
+ // No subcontexts
961
+ (contextFilterState as any)[pickableItem.id] = (
962
+ pickableItem.checked
963
+ );
964
+ }
965
+ });
966
+ dispatch({
967
+ type: ActionType.UpdateContextFilterState,
968
+ contextFilterState,
969
+ });
970
+ }}
971
+ />
972
+ );
973
+ } else if (expandedFilterDrawer === FilterDrawer.Tag) {
974
+ // Create filter UI
975
+ filterDrawer = (
976
+ <TabBox title="Tags">
977
+ {
978
+ Object.keys(tagFilterState)
979
+ .map((tag, i) => {
980
+ const description = genHumanReadableName(tag);
981
+ return (
982
+ <CheckboxButton
983
+ id={`LogReviewer-tag-${tag}-checkbox`}
984
+ text={description}
985
+ ariaLabel={`include logs tagged with "${description}" in results`}
986
+ noMarginOnRight={i === Object.keys(tagFilterState).length - 1}
987
+ onChanged={(checked) => {
988
+ tagFilterState[tag] = checked;
989
+ }}
990
+ />
991
+ );
992
+ })
993
+ }
994
+ </TabBox>
995
+ );
996
+ } else if (expandedFilterDrawer === FilterDrawer.Action) {
997
+ // Create filter UI
998
+ filterDrawer = (
999
+ <>
1000
+ {/* Log Type */}
1001
+ <TabBox title="Log Type">
1002
+ <RadioButton
1003
+ id="LogReviewer-type-all"
1004
+ text="All Logs"
1005
+ onSelected={() => {
1006
+ actionErrorFilterState.type = undefined;
1007
+ dispatch({
1008
+ type: ActionType.UpdateActionErrorFilterState,
1009
+ actionErrorFilterState,
1010
+ });
1011
+ }}
1012
+ ariaLabel="show logs of all types"
1013
+ selected={actionErrorFilterState.type === undefined}
1014
+ />
1015
+ <RadioButton
1016
+ id="LogReviewer-type-action-only"
1017
+ text="Action Logs Only"
1018
+ onSelected={() => {
1019
+ actionErrorFilterState.type = LogType.Action;
1020
+ dispatch({
1021
+ type: ActionType.UpdateActionErrorFilterState,
1022
+ actionErrorFilterState,
1023
+ });
1024
+ }}
1025
+ ariaLabel="only show action logs"
1026
+ selected={actionErrorFilterState.type === LogType.Action}
1027
+ />
1028
+ <RadioButton
1029
+ id="LogReviewer-type-error-only"
1030
+ text="Action Error Only"
1031
+ onSelected={() => {
1032
+ actionErrorFilterState.type = LogType.Error;
1033
+ dispatch({
1034
+ type: ActionType.UpdateActionErrorFilterState,
1035
+ actionErrorFilterState,
1036
+ });
1037
+ }}
1038
+ ariaLabel="only show error logs"
1039
+ selected={actionErrorFilterState.type === LogType.Error}
1040
+ noMarginOnRight
1041
+ />
1042
+ </TabBox>
1043
+ {/* Actions */}
1044
+ {
1045
+ (
1046
+ actionErrorFilterState.type === undefined
1047
+ || actionErrorFilterState.type === LogType.Action
1048
+ ) && (
1049
+ <TabBox title="Action Log Details">
1050
+ {/* Action */}
1051
+ <ButtonInputGroup
1052
+ label="Action"
1053
+ className="mb-2"
1054
+ >
1055
+ {
1056
+ Object.keys(LogAction)
1057
+ .map((action, i) => {
1058
+ const description = genHumanReadableName(action);
1059
+ return (
1060
+ <CheckboxButton
1061
+ id={`LogReviewer-action-${action}-checkbox`}
1062
+ text={description}
1063
+ ariaLabel={`include logs with action type "${description}" in results`}
1064
+ noMarginOnRight={i === Object.keys(LogAction).length - 1}
1065
+ onChanged={(checked) => {
1066
+ actionErrorFilterState.action[action] = checked;
1067
+ dispatch({
1068
+ type: ActionType.UpdateActionErrorFilterState,
1069
+ actionErrorFilterState,
1070
+ });
1071
+ }}
1072
+ />
1073
+ );
1074
+ })
1075
+ }
1076
+ </ButtonInputGroup>
1077
+ {/* Target */}
1078
+ <ButtonInputGroup label="Target">
1079
+ {/* Nothing here */}
1080
+ {(Object.keys(LogMetadata.Target ?? {}).length === 0) && (
1081
+ <div>
1082
+ This app does not have any targets yet.
1083
+ </div>
1084
+ )}
1085
+ {/* List of targets */}
1086
+ {
1087
+ Object.keys(LogMetadata.Target ?? {})
1088
+ .map((target, i) => {
1089
+ const description = genHumanReadableName(target);
1090
+ return (
1091
+ <CheckboxButton
1092
+ id={`LogReviewer-target-${target}-checkbox`}
1093
+ text={description}
1094
+ ariaLabel={`include logs with target "${description}" in results`}
1095
+ onChanged={(checked) => {
1096
+ actionErrorFilterState.target[target] = checked;
1097
+ dispatch({
1098
+ type: ActionType.UpdateActionErrorFilterState,
1099
+ actionErrorFilterState,
1100
+ });
1101
+ }}
1102
+ noMarginOnRight={i === Object.keys(LogMetadata.Target ?? {}).length - 1}
1103
+ />
1104
+ );
1105
+ })
1106
+ }
1107
+ </ButtonInputGroup>
1108
+ </TabBox>
1109
+ )
1110
+ }
1111
+ {/* Errors */}
1112
+ {
1113
+ (
1114
+ actionErrorFilterState.type === undefined
1115
+ || actionErrorFilterState.type === LogType.Error
1116
+ ) && (
1117
+ <TabBox title="Error Log Details">
1118
+ {/* Message */}
1119
+ <div className="input-group mb-2">
1120
+ <span className="input-group-text">
1121
+ Error Message
1122
+ </span>
1123
+ <input
1124
+ type="text"
1125
+ className="form-control"
1126
+ aria-label="query for error message"
1127
+ value={actionErrorFilterState.errorMessage}
1128
+ onChange={(e) => {
1129
+ actionErrorFilterState.errorMessage = e.target.value;
1130
+ dispatch({
1131
+ type: ActionType.UpdateActionErrorFilterState,
1132
+ actionErrorFilterState,
1133
+ });
1134
+ }}
1135
+ />
1136
+ </div>
1137
+ {/* Code */}
1138
+ <div className="input-group mb-2">
1139
+ <span className="input-group-text">
1140
+ Error Code
1141
+ </span>
1142
+ <input
1143
+ type="text"
1144
+ className="form-control"
1145
+ aria-label="query for error code"
1146
+ value={actionErrorFilterState.errorMessage}
1147
+ onChange={(e) => {
1148
+ actionErrorFilterState.errorCode = (
1149
+ (e.target.value)
1150
+ .trim()
1151
+ .toUpperCase()
1152
+ );
1153
+ dispatch({
1154
+ type: ActionType.UpdateActionErrorFilterState,
1155
+ actionErrorFilterState,
1156
+ });
1157
+ }}
1158
+ />
1159
+ </div>
1160
+ </TabBox>
1161
+ )
1162
+ }
1163
+ </>
1164
+ );
1165
+ } else if (expandedFilterDrawer === FilterDrawer.Advanced) {
1166
+ // Create advanced filter ui
1167
+ filterDrawer = (
1168
+ <>
1169
+ {/* User Info */}
1170
+ <TabBox title="User Info">
1171
+ {/* First Name */}
1172
+ <div className="input-group mb-2">
1173
+ <span className="input-group-text">
1174
+ User First Name
1175
+ </span>
1176
+ <input
1177
+ type="text"
1178
+ className="form-control"
1179
+ aria-label="query for user first name"
1180
+ value={advancedFilterState.userFirstName}
1181
+ onChange={(e) => {
1182
+ advancedFilterState.userFirstName = e.target.value;
1183
+ dispatch({
1184
+ type: ActionType.UpdateAdvancedFilterState,
1185
+ advancedFilterState,
1186
+ });
1187
+ }}
1188
+ />
1189
+ </div>
1190
+ {/* Last Name */}
1191
+ <div className="input-group mb-2">
1192
+ <span className="input-group-text">
1193
+ User Last Name
1194
+ </span>
1195
+ <input
1196
+ type="text"
1197
+ className="form-control"
1198
+ aria-label="query for user last name"
1199
+ value={advancedFilterState.userLastName}
1200
+ onChange={(e) => {
1201
+ advancedFilterState.userLastName = e.target.value;
1202
+ dispatch({
1203
+ type: ActionType.UpdateAdvancedFilterState,
1204
+ advancedFilterState,
1205
+ });
1206
+ }}
1207
+ />
1208
+ </div>
1209
+ {/* Email */}
1210
+ <div className="input-group mb-2">
1211
+ <span className="input-group-text">
1212
+ User Email
1213
+ </span>
1214
+ <input
1215
+ type="text"
1216
+ className="form-control"
1217
+ aria-label="query for user email"
1218
+ value={advancedFilterState.userEmail}
1219
+ onChange={(e) => {
1220
+ advancedFilterState.userEmail = (
1221
+ (e.target.value)
1222
+ .trim()
1223
+ );
1224
+ dispatch({
1225
+ type: ActionType.UpdateAdvancedFilterState,
1226
+ advancedFilterState,
1227
+ });
1228
+ }}
1229
+ />
1230
+ </div>
1231
+ {/* Canvas Id */}
1232
+ <div className="input-group mb-2">
1233
+ <span className="input-group-text">
1234
+ User Canvas Id
1235
+ </span>
1236
+ <input
1237
+ type="text"
1238
+ className="form-control"
1239
+ aria-label="query for user canvas id"
1240
+ value={advancedFilterState.userId}
1241
+ onChange={(e) => {
1242
+ const { value } = e.target;
1243
+ // Only update if value contains only numbers
1244
+ if (/^\d+$/.test(value)) {
1245
+ advancedFilterState.userId = (
1246
+ (e.target.value)
1247
+ .trim()
1248
+ );
1249
+ }
1250
+ dispatch({
1251
+ type: ActionType.UpdateAdvancedFilterState,
1252
+ advancedFilterState,
1253
+ });
1254
+ }}
1255
+ />
1256
+ </div>
1257
+ {/* Role */}
1258
+ <ButtonInputGroup label="Role">
1259
+ <CheckboxButton
1260
+ text="Students"
1261
+ onChanged={(checked) => {
1262
+ advancedFilterState.includeLearners = checked;
1263
+ dispatch({
1264
+ type: ActionType.UpdateAdvancedFilterState,
1265
+ advancedFilterState,
1266
+ });
1267
+ }}
1268
+ checked={advancedFilterState.includeLearners}
1269
+ ariaLabel="show logs from students"
1270
+ />
1271
+ <CheckboxButton
1272
+ text="Teaching Team Members"
1273
+ onChanged={(checked) => {
1274
+ advancedFilterState.includeTTMs = checked;
1275
+ dispatch({
1276
+ type: ActionType.UpdateAdvancedFilterState,
1277
+ advancedFilterState,
1278
+ });
1279
+ }}
1280
+ checked={advancedFilterState.includeTTMs}
1281
+ ariaLabel="show logs from teaching team members"
1282
+ />
1283
+ <CheckboxButton
1284
+ text="Admins"
1285
+ onChanged={(checked) => {
1286
+ advancedFilterState.includeAdmins = checked;
1287
+ dispatch({
1288
+ type: ActionType.UpdateAdvancedFilterState,
1289
+ advancedFilterState,
1290
+ });
1291
+ }}
1292
+ checked={advancedFilterState.includeAdmins}
1293
+ ariaLabel="show logs from admins"
1294
+ />
1295
+ </ButtonInputGroup>
1296
+ </TabBox>
1297
+
1298
+ {/* Course Info */}
1299
+ <TabBox title="Course Info">
1300
+ {/* Name */}
1301
+ <div className="input-group mb-2">
1302
+ <span className="input-group-text">
1303
+ Course Name
1304
+ </span>
1305
+ <input
1306
+ type="text"
1307
+ className="form-control"
1308
+ aria-label="query for course name"
1309
+ value={advancedFilterState.courseName}
1310
+ onChange={(e) => {
1311
+ advancedFilterState.courseName = e.target.value;
1312
+ dispatch({
1313
+ type: ActionType.UpdateAdvancedFilterState,
1314
+ advancedFilterState,
1315
+ });
1316
+ }}
1317
+ />
1318
+ </div>
1319
+ {/* Canvas Id */}
1320
+ <div className="input-group mb-2">
1321
+ <span className="input-group-text">
1322
+ Course Canvas Id
1323
+ </span>
1324
+ <input
1325
+ type="text"
1326
+ className="form-control"
1327
+ aria-label="query for course canvas id"
1328
+ value={advancedFilterState.courseId}
1329
+ onChange={(e) => {
1330
+ const { value } = e.target;
1331
+ // Only update if value contains only numbers
1332
+ if (/^\d+$/.test(value)) {
1333
+ advancedFilterState.courseId = (
1334
+ (e.target.value)
1335
+ .trim()
1336
+ );
1337
+ }
1338
+ dispatch({
1339
+ type: ActionType.UpdateAdvancedFilterState,
1340
+ advancedFilterState,
1341
+ });
1342
+ }}
1343
+ />
1344
+ </div>
1345
+ </TabBox>
1346
+
1347
+ {/* Device Info */}
1348
+ <TabBox title="Device Info">
1349
+ <ButtonInputGroup label="Device Type">
1350
+ <RadioButton
1351
+ text="All Devices"
1352
+ ariaLabel="show logs from all devices"
1353
+ selected={advancedFilterState.isMobile === undefined}
1354
+ onSelected={() => {
1355
+ advancedFilterState.isMobile = undefined;
1356
+ dispatch({
1357
+ type: ActionType.UpdateAdvancedFilterState,
1358
+ advancedFilterState,
1359
+ });
1360
+ }}
1361
+ />
1362
+ <RadioButton
1363
+ text="Mobile Only"
1364
+ ariaLabel="show logs from mobile devices"
1365
+ selected={advancedFilterState.isMobile === true}
1366
+ onSelected={() => {
1367
+ advancedFilterState.isMobile = true;
1368
+ dispatch({
1369
+ type: ActionType.UpdateAdvancedFilterState,
1370
+ advancedFilterState,
1371
+ });
1372
+ }}
1373
+ />
1374
+ <RadioButton
1375
+ text="Desktop Only"
1376
+ ariaLabel="show logs from desktop devices"
1377
+ selected={advancedFilterState.isMobile === false}
1378
+ onSelected={() => {
1379
+ advancedFilterState.isMobile = false;
1380
+ dispatch({
1381
+ type: ActionType.UpdateAdvancedFilterState,
1382
+ advancedFilterState,
1383
+ });
1384
+ }}
1385
+ noMarginOnRight
1386
+ />
1387
+ </ButtonInputGroup>
1388
+ </TabBox>
1389
+
1390
+ {/* Source */}
1391
+ <TabBox title="Source">
1392
+ <ButtonInputGroup label="Source Type">
1393
+ <RadioButton
1394
+ text="Both"
1395
+ ariaLabel="show logs from all sources"
1396
+ selected={advancedFilterState.source === undefined}
1397
+ onSelected={() => {
1398
+ advancedFilterState.source = undefined;
1399
+ dispatch({
1400
+ type: ActionType.UpdateAdvancedFilterState,
1401
+ advancedFilterState,
1402
+ });
1403
+ }}
1404
+ />
1405
+ <RadioButton
1406
+ text="Client Only"
1407
+ ariaLabel="show logs from client source"
1408
+ selected={advancedFilterState.source === LogSource.Client}
1409
+ onSelected={() => {
1410
+ advancedFilterState.source = LogSource.Client;
1411
+ dispatch({
1412
+ type: ActionType.UpdateAdvancedFilterState,
1413
+ advancedFilterState,
1414
+ });
1415
+ }}
1416
+ />
1417
+ <RadioButton
1418
+ text="Server Only"
1419
+ ariaLabel="show logs from server source"
1420
+ selected={advancedFilterState.source === LogSource.Server}
1421
+ onSelected={() => {
1422
+ advancedFilterState.source = LogSource.Server;
1423
+ dispatch({
1424
+ type: ActionType.UpdateAdvancedFilterState,
1425
+ advancedFilterState,
1426
+ });
1427
+ }}
1428
+ noMarginOnRight
1429
+ />
1430
+ </ButtonInputGroup>
1431
+
1432
+ {/* Server filters */}
1433
+ {advancedFilterState.source !== LogSource.Client && (
1434
+ <div className="mt-2">
1435
+ {/* Route path */}
1436
+ <div className="input-group mb-2">
1437
+ <span className="input-group-text">
1438
+ Server Route Path
1439
+ </span>
1440
+ <input
1441
+ type="text"
1442
+ className="form-control"
1443
+ aria-label="query for server route path"
1444
+ placeholder="e.g. /api/ttm/courses/12345"
1445
+ value={advancedFilterState.routePath}
1446
+ onChange={(e) => {
1447
+ advancedFilterState.courseName = (
1448
+ (e.target.value)
1449
+ .trim()
1450
+ );
1451
+ dispatch({
1452
+ type: ActionType.UpdateAdvancedFilterState,
1453
+ advancedFilterState,
1454
+ });
1455
+ }}
1456
+ />
1457
+ </div>
1458
+
1459
+ {/* Route template */}
1460
+ <div className="input-group mb-2">
1461
+ <span className="input-group-text">
1462
+ Server Route Template
1463
+ </span>
1464
+ <input
1465
+ type="text"
1466
+ className="form-control"
1467
+ aria-label="query for server route template"
1468
+ value={advancedFilterState.routeTemplate}
1469
+ placeholder="e.g. /api/ttm/courses/:courseId"
1470
+ onChange={(e) => {
1471
+ advancedFilterState.courseName = (
1472
+ (e.target.value)
1473
+ .trim()
1474
+ );
1475
+ dispatch({
1476
+ type: ActionType.UpdateAdvancedFilterState,
1477
+ advancedFilterState,
1478
+ });
1479
+ }}
1480
+ />
1481
+ </div>
1482
+ </div>
1483
+ )}
1484
+ </TabBox>
1485
+ </>
1486
+ );
1487
+ }
1488
+ }
1489
+
1490
+ // Filters UI
1491
+ const filters = (
1492
+ <>
1493
+ {filterToggles}
1494
+ {filterDrawer && (
1495
+ <Drawer>
1496
+ {filterDrawer}
1497
+ </Drawer>
1498
+ )}
1499
+ </>
1500
+ );
1501
+
1502
+ // Actually filter the logs
1503
+ // > Perform filters
1504
+ const logs: Log[] = [];
1505
+ Object.keys(logMap).forEach((year) => {
1506
+ Object.keys(logMap[year]).forEach((month) => {
1507
+ logMap[year][month].forEach((log) => {
1508
+ /* ----------- Date Filter ---------- */
1509
+
1510
+ // Before start date
1511
+ if (
1512
+ // Previous year
1513
+ log.year < dateFilterState.startDate.year
1514
+ // Same year, earlier month
1515
+ || (
1516
+ (log.year === dateFilterState.startDate.year)
1517
+ && (log.month < dateFilterState.startDate.month)
1518
+ )
1519
+ // Same year, same month, earlier day
1520
+ || (
1521
+ (log.year === dateFilterState.startDate.year)
1522
+ && (log.month === dateFilterState.startDate.month)
1523
+ && (log.day < dateFilterState.startDate.day)
1524
+ )
1525
+ ) {
1526
+ return;
1527
+ }
1528
+
1529
+ // After end date
1530
+ if (
1531
+ // Later year
1532
+ log.year > dateFilterState.endDate.year
1533
+ // Same year, later month
1534
+ || (
1535
+ (log.year === dateFilterState.endDate.year)
1536
+ && (log.month > dateFilterState.endDate.month)
1537
+ )
1538
+ // Same year, same month, later day
1539
+ || (
1540
+ (log.year === dateFilterState.endDate.year)
1541
+ && (log.month === dateFilterState.endDate.month)
1542
+ && (log.day > dateFilterState.endDate.day)
1543
+ )
1544
+ ) {
1545
+ return;
1546
+ }
1547
+
1548
+ /* --------- Context Filter --------- */
1549
+
1550
+ // Context doesn't match
1551
+ if (
1552
+ // Whole context is deselected
1553
+ contextFilterState[log.context] === false
1554
+ // None of the subcontexts are selected
1555
+ || (
1556
+ Object.values(contextFilterState[log.context] ?? {})
1557
+ .every((isSelected) => {
1558
+ return !isSelected;
1559
+ })
1560
+ )
1561
+ ) {
1562
+ return;
1563
+ }
1564
+
1565
+ // Subcontext doesn't match
1566
+ if (
1567
+ // Log has a subcontext
1568
+ log.subcontext
1569
+ // Context has subcontexts
1570
+ && (
1571
+ contextFilterState[log.context]
1572
+ && contextFilterState[log.context] !== false
1573
+ && contextFilterState[log.context] !== true
1574
+ )
1575
+ // Subcontext is not selected
1576
+ && !(contextFilterState as any)[log.context][log.subcontext]
1577
+ ) {
1578
+ return;
1579
+ }
1580
+
1581
+ /* -------------- Tags -------------- */
1582
+
1583
+ // No tags match
1584
+ if (
1585
+ // Log has at least one tag
1586
+ log.tags.length > 0
1587
+ // No tags match
1588
+ && (
1589
+ log.tags.every((tag) => {
1590
+ return !tagFilterState[tag];
1591
+ })
1592
+ )
1593
+ ) {
1594
+ return;
1595
+ }
1596
+
1597
+ /* ------- Actions and Errors ------- */
1598
+
1599
+ // Log type doesn't match
1600
+ if (
1601
+ // Filter won't allow all types
1602
+ actionErrorFilterState.type !== undefined
1603
+ // Log type doesn't match
1604
+ && actionErrorFilterState.type !== log.type
1605
+ ) {
1606
+ return;
1607
+ }
1608
+
1609
+ // Filter errors
1610
+ if (log.type === LogType.Error) {
1611
+ // Message doesn't match
1612
+ if (
1613
+ // Message exists
1614
+ log.errorMessage
1615
+ // Message filter exists
1616
+ && actionErrorFilterState.errorMessage.trim().length > 0
1617
+ // Message doesn't match
1618
+ && log.errorMessage.toLowerCase().includes(
1619
+ actionErrorFilterState.errorMessage.trim().toLowerCase(),
1620
+ )
1621
+ ) {
1622
+ return;
1623
+ }
1624
+
1625
+ // Code doesn't match
1626
+ if (
1627
+ // Code exists
1628
+ log.errorCode
1629
+ // Code filter exists
1630
+ && actionErrorFilterState.errorCode.trim().length > 0
1631
+ // Code doesn't match
1632
+ && log.errorCode.toUpperCase().includes(
1633
+ actionErrorFilterState.errorCode.trim().toUpperCase(),
1634
+ )
1635
+ ) {
1636
+ return;
1637
+ }
1638
+ }
1639
+
1640
+ // Filter actions
1641
+ if (log.type === LogType.Action) {
1642
+ // Target isn't selected
1643
+ if (
1644
+ // Target exists
1645
+ log.target
1646
+ // Target isn't selected
1647
+ && !actionErrorFilterState.target[log.target]
1648
+ ) {
1649
+ return;
1650
+ }
1651
+
1652
+ // Action
1653
+ if (
1654
+ // Action exists
1655
+ log.action
1656
+ // Action isn't selected
1657
+ && !actionErrorFilterState.action[log.action]
1658
+ ) {
1659
+ return;
1660
+ }
1661
+ }
1662
+
1663
+ /* --------- Advanced Filter -------- */
1664
+
1665
+ // First name doesn't match
1666
+ if (
1667
+ // First name exists
1668
+ log.userFirstName
1669
+ // First name query doesn't match
1670
+ && !log.userFirstName.toLowerCase().includes(
1671
+ advancedFilterState.userFirstName.toLowerCase().trim(),
1672
+ )
1673
+ ) {
1674
+ return;
1675
+ }
1676
+
1677
+ // Last name doesn't match
1678
+ if (
1679
+ // Last name exists
1680
+ log.userLastName
1681
+ // Last name query doesn't match
1682
+ && !log.userLastName.toLowerCase().includes(
1683
+ advancedFilterState.userLastName.toLowerCase().trim(),
1684
+ )
1685
+ ) {
1686
+ return;
1687
+ }
1688
+
1689
+ // Email doesn't match
1690
+ if (
1691
+ // Email exists
1692
+ log.userEmail
1693
+ // Email query doesn't match
1694
+ && !log.userEmail.toLowerCase().includes(
1695
+ advancedFilterState.userEmail.toLowerCase().trim(),
1696
+ )
1697
+ ) {
1698
+ return;
1699
+ }
1700
+
1701
+ // User id doesn't match
1702
+ if (
1703
+ // User id exists
1704
+ log.userId
1705
+ // User id doesn't match
1706
+ && !String(log.userId).includes(
1707
+ advancedFilterState.userId.trim(),
1708
+ )
1709
+ ) {
1710
+ return;
1711
+ }
1712
+
1713
+ // Learner not allowed
1714
+ if (
1715
+ // User is a learner
1716
+ log.isLearner
1717
+ // Learners aren't included
1718
+ && !advancedFilterState.includeLearners
1719
+ ) {
1720
+ return;
1721
+ }
1722
+
1723
+ // TTM not allowed
1724
+ if (
1725
+ // User is a ttm
1726
+ log.isTTM
1727
+ // TTMs aren't included
1728
+ && !advancedFilterState.includeTTMs
1729
+ ) {
1730
+ return;
1731
+ }
1732
+
1733
+ // Admin not allowed
1734
+ if (
1735
+ // User is an admin
1736
+ log.isAdmin
1737
+ // Admins aren't included
1738
+ && !advancedFilterState.includeAdmins
1739
+ ) {
1740
+ return;
1741
+ }
1742
+
1743
+ // Course Id doesn't match
1744
+ if (
1745
+ // Course Id exists
1746
+ log.courseId
1747
+ // Course Id doesn't match
1748
+ && !String(log.courseId).includes(
1749
+ advancedFilterState.courseId.trim(),
1750
+ )
1751
+ ) {
1752
+ return;
1753
+ }
1754
+
1755
+ // Course name doesn't match
1756
+ if (
1757
+ // Course name exists
1758
+ log.courseName
1759
+ // Course name doesn't match
1760
+ && !String(log.courseName).includes(
1761
+ advancedFilterState.courseName.trim(),
1762
+ )
1763
+ ) {
1764
+ return;
1765
+ }
1766
+
1767
+ // Mobile filter doesn't match
1768
+ if (
1769
+ // Mobile filter exists
1770
+ advancedFilterState.isMobile !== undefined
1771
+ // Device info exists
1772
+ && log.device
1773
+ // Mobile filter doesn't match
1774
+ && (advancedFilterState.isMobile === log.device.isMobile)
1775
+ ) {
1776
+ return;
1777
+ }
1778
+
1779
+ // Log source doesn't match
1780
+ if (
1781
+ // Source filter exists
1782
+ advancedFilterState.source !== undefined
1783
+ // Source info exists
1784
+ && log.source
1785
+ // Source filter doesn't match
1786
+ && (advancedFilterState.source !== log.source)
1787
+ ) {
1788
+ return;
1789
+ }
1790
+
1791
+ // Route path doesn't match (Only for server source)
1792
+ if (
1793
+ // Source is server
1794
+ (log.source === LogSource.Server)
1795
+ // Route path is being filtered
1796
+ && (advancedFilterState.routePath.trim().length)
1797
+ // Route path doesn't match
1798
+ && !(log.routePath.includes(advancedFilterState.routePath.trim()))
1799
+ ) {
1800
+ return;
1801
+ }
1802
+
1803
+ // Route template doesn't match (Only for server source)
1804
+ if (
1805
+ // Source is server
1806
+ (log.source === LogSource.Server)
1807
+ // Route template is being filtered
1808
+ && (advancedFilterState.routeTemplate.trim().length)
1809
+ // Route template doesn't match
1810
+ && !(log.routeTemplate.includes(advancedFilterState.routeTemplate.trim()))
1811
+ ) {
1812
+ return;
1813
+ }
1814
+
1815
+ /* -------------- Done -------------- */
1816
+
1817
+ // Made it past all filters. Add to the list
1818
+ logs.push(log);
1819
+ });
1820
+ });
1821
+ });
1822
+
1823
+ /*----------------------------------------*/
1824
+ /* Data */
1825
+ /*----------------------------------------*/
1826
+
1827
+ // Create data table
1828
+ const columns: IntelliTableColumn[] = [
1829
+ {
1830
+ title: 'First Name',
1831
+ param: 'userFirstName',
1832
+ type: ParamType.String,
1833
+ },
1834
+ {
1835
+ title: 'Last Name',
1836
+ param: 'userLastName',
1837
+ type: ParamType.String,
1838
+ },
1839
+ {
1840
+ title: 'Email',
1841
+ param: 'userEmail',
1842
+ type: ParamType.String,
1843
+ },
1844
+ {
1845
+ title: 'Canvas Id',
1846
+ param: 'userId',
1847
+ type: ParamType.Int,
1848
+ },
1849
+ {
1850
+ title: 'Student',
1851
+ param: 'isLearner',
1852
+ type: ParamType.Boolean,
1853
+ },
1854
+ {
1855
+ title: 'Teaching Staff',
1856
+ param: 'isTTM',
1857
+ type: ParamType.Boolean,
1858
+ startsHidden: true,
1859
+ },
1860
+ {
1861
+ title: 'Admin',
1862
+ param: 'isAdmin',
1863
+ type: ParamType.Boolean,
1864
+ startsHidden: true,
1865
+ },
1866
+ {
1867
+ title: 'Course Canvas Id',
1868
+ param: 'courseId',
1869
+ type: ParamType.Int,
1870
+ startsHidden: true,
1871
+ },
1872
+ {
1873
+ title: 'Course Name',
1874
+ param: 'courseName',
1875
+ type: ParamType.String,
1876
+ },
1877
+ {
1878
+ title: 'Browser Name',
1879
+ param: 'browser.name',
1880
+ type: ParamType.String,
1881
+ startsHidden: true,
1882
+ },
1883
+ {
1884
+ title: 'Browser Version',
1885
+ param: 'browser.version',
1886
+ type: ParamType.String,
1887
+ startsHidden: true,
1888
+ },
1889
+ {
1890
+ title: 'OS',
1891
+ param: 'device.os',
1892
+ type: ParamType.String,
1893
+ startsHidden: true,
1894
+ },
1895
+ {
1896
+ title: 'Mobile',
1897
+ param: 'device.isMobile',
1898
+ type: ParamType.Boolean,
1899
+ startsHidden: true,
1900
+ },
1901
+ {
1902
+ title: 'Year',
1903
+ param: 'year',
1904
+ type: ParamType.Int,
1905
+ },
1906
+ {
1907
+ title: 'Month',
1908
+ param: 'month',
1909
+ type: ParamType.Int,
1910
+ },
1911
+ {
1912
+ title: 'Day',
1913
+ param: 'day',
1914
+ type: ParamType.Int,
1915
+ },
1916
+ {
1917
+ title: 'Hour',
1918
+ param: 'hour',
1919
+ type: ParamType.Int,
1920
+ },
1921
+ {
1922
+ title: 'Minute',
1923
+ param: 'minute',
1924
+ type: ParamType.Int,
1925
+ startsHidden: true,
1926
+ },
1927
+ {
1928
+ title: 'Timestamp',
1929
+ param: 'timestamp',
1930
+ type: ParamType.Int,
1931
+ startsHidden: true,
1932
+ },
1933
+ {
1934
+ title: 'Context',
1935
+ param: 'context',
1936
+ type: ParamType.String,
1937
+ },
1938
+ {
1939
+ title: 'Subcontext',
1940
+ param: 'subcontext',
1941
+ type: ParamType.String,
1942
+ },
1943
+ {
1944
+ title: 'Tags',
1945
+ param: 'tags',
1946
+ type: ParamType.JSON,
1947
+ startsHidden: true,
1948
+ },
1949
+ {
1950
+ title: 'Log Level',
1951
+ param: 'level',
1952
+ type: ParamType.String,
1953
+ startsHidden: true,
1954
+ },
1955
+ {
1956
+ title: 'Metadata',
1957
+ param: 'metadata',
1958
+ type: ParamType.JSON,
1959
+ startsHidden: true,
1960
+ },
1961
+ {
1962
+ title: 'Source',
1963
+ param: 'source',
1964
+ type: ParamType.String,
1965
+ },
1966
+ {
1967
+ title: 'Server Route Path',
1968
+ param: 'routePath',
1969
+ type: ParamType.String,
1970
+ startsHidden: true,
1971
+ },
1972
+ {
1973
+ title: 'Server Route Template',
1974
+ param: 'routeTemplate',
1975
+ type: ParamType.String,
1976
+ startsHidden: true,
1977
+ },
1978
+ {
1979
+ title: 'Type',
1980
+ param: 'type',
1981
+ type: ParamType.String,
1982
+ },
1983
+ {
1984
+ title: 'Error Message',
1985
+ param: 'errorMessage',
1986
+ type: ParamType.String,
1987
+ startsHidden: true,
1988
+ },
1989
+ {
1990
+ title: 'Error Code',
1991
+ param: 'errorCode',
1992
+ type: ParamType.String,
1993
+ startsHidden: true,
1994
+ },
1995
+ {
1996
+ title: 'Error Stack',
1997
+ param: 'errorStack',
1998
+ type: ParamType.String,
1999
+ startsHidden: true,
2000
+ },
2001
+ {
2002
+ title: 'Action Target',
2003
+ param: 'target',
2004
+ type: ParamType.String,
2005
+ startsHidden: true,
2006
+ },
2007
+ {
2008
+ title: 'Action Type',
2009
+ param: 'action',
2010
+ type: ParamType.String,
2011
+ startsHidden: true,
2012
+ },
2013
+ ];
2014
+
2015
+ // Create intelliTable
2016
+ const dataTable = (
2017
+ logs.length === 0
2018
+ ? (
2019
+ <>
2020
+ <h3 className="m-0">
2021
+ Matching Logs:
2022
+ </h3>
2023
+ <div className="alert alert-warning text-center">
2024
+ <h4 className="m-1">
2025
+ No Logs to Show
2026
+ </h4>
2027
+ <div>
2028
+ Either your filters are too strict or no matching logs have been
2029
+ created yet.
2030
+ </div>
2031
+ </div>
2032
+ </>
2033
+ )
2034
+ : (
2035
+ <IntelliTable
2036
+ title="Matching Logs:"
2037
+ id="logs"
2038
+ data={logs}
2039
+ columns={columns}
2040
+ />
2041
+ )
2042
+ );
2043
+
2044
+ // Main body
2045
+ body = (
2046
+ <>
2047
+ {filters}
2048
+ {dataTable}
2049
+ </>
2050
+ );
2051
+ }
2052
+
2053
+ /* ---------- Wrap in Modal --------- */
2054
+
2055
+ return (
2056
+ <div className="LogReviewer-outer-container">
2057
+ {/* Style */}
2058
+ <style>{style}</style>
2059
+
2060
+ <div className="LogReviewer-inner-container">
2061
+ <div className="LogReviewer-header">
2062
+ <div className="LogReviewer-header-title">
2063
+ <h3 className="text-center m-0">
2064
+ Log Review Dashboard
2065
+ </h3>
2066
+ </div>
2067
+ <button
2068
+ type="button"
2069
+ className="LogReviewer-header-close-button btn btn-dark btn-lg"
2070
+ aria-label="close log reviewer panel"
2071
+ onClick={onClose}
2072
+ >
2073
+ <FontAwesomeIcon
2074
+ icon={faTimes}
2075
+ />
2076
+ </button>
2077
+ </div>
2078
+ <div className="LogReviewer-contents">
2079
+ {body}
2080
+ </div>
2081
+ </div>
2082
+ </div>
2083
+ );
2084
+ };
2085
+
2086
+ /*------------------------------------------------------------------------*/
2087
+ /* Wrap Up */
2088
+ /*------------------------------------------------------------------------*/
2089
+
2090
+ // Export component
2091
+ export default LogReviewer;