dce-reactkit 3.2.1 → 3.2.2-beta.2

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