dce-reactkit 3.2.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 (62) hide show
  1. package/.vscode/settings.json +3 -0
  2. package/dist/cjs/index.js +2195 -442
  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/LogBuiltInMetadata.d.ts +1 -0
  19. package/dist/cjs/types/types/LogMetadataType.d.ts +19 -0
  20. package/dist/cjs/types/types/ReactKitErrorCode.d.ts +2 -1
  21. package/dist/esm/index.js +2191 -444
  22. package/dist/esm/index.js.map +1 -1
  23. package/dist/esm/types/components/ButtonInputGroup.d.ts +1 -0
  24. package/dist/esm/types/components/CSVDownloadButton.d.ts +19 -0
  25. package/dist/esm/types/components/IntelliTable.d.ts +17 -0
  26. package/dist/esm/types/components/ItemPicker/index.d.ts +1 -0
  27. package/dist/esm/types/components/LogReviewer.d.ts +13 -0
  28. package/dist/esm/types/components/SimpleDateChooser.d.ts +1 -0
  29. package/dist/esm/types/constants/LOG_REVIEW_ROUTE_PATH_PREFIX.d.ts +6 -0
  30. package/dist/esm/types/constants/LOG_REVIEW_STATUS_ROUTE.d.ts +7 -0
  31. package/dist/esm/types/helpers/canReviewLogs.d.ts +7 -0
  32. package/dist/esm/types/helpers/genCSV.d.ts +14 -0
  33. package/dist/esm/types/helpers/getMonthName.d.ts +14 -0
  34. package/dist/esm/types/index.d.ts +9 -1
  35. package/dist/esm/types/server/initServer.d.ts +8 -0
  36. package/dist/esm/types/types/IntelliTableColumn.d.ts +12 -0
  37. package/dist/esm/types/types/LogBuiltInMetadata.d.ts +1 -0
  38. package/dist/esm/types/types/LogMetadataType.d.ts +19 -0
  39. package/dist/esm/types/types/ReactKitErrorCode.d.ts +2 -1
  40. package/dist/index.d.ts +172 -47
  41. package/package.json +1 -1
  42. package/sandbox.js +78 -17
  43. package/src/components/AppWrapper.tsx +15 -0
  44. package/src/components/ButtonInputGroup.tsx +4 -1
  45. package/src/components/CSVDownloadButton.tsx +102 -0
  46. package/src/components/IntelliTable.tsx +610 -0
  47. package/src/components/ItemPicker/index.tsx +4 -0
  48. package/src/components/LogReviewer.tsx +2091 -0
  49. package/src/components/SimpleDateChooser.tsx +26 -27
  50. package/src/constants/LOG_REVIEW_ROUTE_PATH_PREFIX.ts +9 -0
  51. package/src/constants/LOG_REVIEW_STATUS_ROUTE.ts +10 -0
  52. package/src/helpers/canReviewLogs.ts +33 -0
  53. package/src/helpers/genCSV.ts +59 -0
  54. package/src/helpers/getHumanReadableDate.ts +6 -17
  55. package/src/helpers/getMonthName.ts +29 -0
  56. package/src/helpers/logClientEvent.tsx +5 -0
  57. package/src/index.ts +16 -0
  58. package/src/server/initServer.ts +125 -3
  59. package/src/types/IntelliTableColumn.ts +24 -0
  60. package/src/types/LogBuiltInMetadata.ts +1 -0
  61. package/src/types/LogMetadataType.ts +23 -0
  62. package/src/types/ReactKitErrorCode.tsx +2 -1
package/dist/cjs/index.js CHANGED
@@ -36,7 +36,7 @@ function __awaiter(thisArg, _arguments, P, generator) {
36
36
  });
37
37
  }
38
38
 
39
- // Highest error code = DRK10
39
+ // Highest error code = DRK11
40
40
  /**
41
41
  * List of error codes built into the react kit
42
42
  * @author Gabe Abrams
@@ -53,6 +53,7 @@ var ReactKitErrorCode;
53
53
  ReactKitErrorCode["NoCACCLGetLaunchInfoFunction"] = "DRK8";
54
54
  ReactKitErrorCode["NotTTM"] = "DRK9";
55
55
  ReactKitErrorCode["NotAdmin"] = "DRK10";
56
+ ReactKitErrorCode["NotAllowedToReviewLogs"] = "DRK11";
56
57
  })(ReactKitErrorCode || (ReactKitErrorCode = {}));
57
58
  var ReactKitErrorCode$1 = ReactKitErrorCode;
58
59
 
@@ -279,7 +280,7 @@ const ModalButtonTypeToLabelAndVariant = {
279
280
  /*------------------------------------------------------------------------*/
280
281
  /* Style */
281
282
  /*------------------------------------------------------------------------*/
282
- const style$6 = `
283
+ const style$7 = `
283
284
  .Modal-backdrop {
284
285
  position: fixed;
285
286
  top: 0;
@@ -453,7 +454,7 @@ const Modal = (props) => {
453
454
  left: 0,
454
455
  right: 0,
455
456
  } },
456
- React__default["default"].createElement("style", null, style$6),
457
+ React__default["default"].createElement("style", null, style$7),
457
458
  React__default["default"].createElement("div", { className: `Modal-backdrop ${backdropAnimationClass}`, style: {
458
459
  zIndex: 5000000003,
459
460
  }, onClick: () => __awaiter(void 0, void 0, void 0, function* () {
@@ -499,6 +500,197 @@ class ErrorWithCode extends Error {
499
500
  }
500
501
  }
501
502
 
503
+ /**
504
+ * Path that all routes start with
505
+ * @author Gabe Abrams
506
+ */
507
+ const ROUTE_PATH_PREFIX = '/dce-reactkit';
508
+
509
+ /**
510
+ * Path of the route for storing client-side logs
511
+ * @author Gabe Abrams
512
+ */
513
+ const LOG_ROUTE_PATH = `${ROUTE_PATH_PREFIX}/log`;
514
+
515
+ /**
516
+ * Built-in metadata for logs
517
+ * @author Gabe Abrams
518
+ */
519
+ const LogBuiltInMetadata = {
520
+ // Contexts
521
+ Context: {
522
+ Uncategorized: 'n/a',
523
+ ServerRenderedErrorPage: '_server-rendered-error-page',
524
+ ServerEndpointError: '_server-endpoint-error',
525
+ ClientFatalError: '_client-fatal-error',
526
+ },
527
+ // Targets
528
+ Target: {
529
+ NoSpecificTarget: 'n/a',
530
+ },
531
+ };
532
+
533
+ /**
534
+ * Allowed log levels
535
+ * @author Gabe Abrams
536
+ */
537
+ var LogLevel;
538
+ (function (LogLevel) {
539
+ LogLevel["Warn"] = "Warn";
540
+ LogLevel["Info"] = "Info";
541
+ LogLevel["Debug"] = "Debug";
542
+ })(LogLevel || (LogLevel = {}));
543
+ var LogLevel$1 = LogLevel;
544
+
545
+ // Keep track of whether or not session expiry has already been handled
546
+ let sessionAlreadyExpired = false;
547
+ /*------------------------------------------------------------------------*/
548
+ /* Stub Logic */
549
+ /*------------------------------------------------------------------------*/
550
+ // Stored stub responses
551
+ const stubResponses = {};
552
+ /**
553
+ * Add a stub response
554
+ * @author Gabe Abrams
555
+ * @param opts object containing all arguments
556
+ * @param [opts.method=GET] http request method
557
+ * @param opts.path pathname of the request
558
+ * @param [opts.body] body of the response if successful
559
+ * @param [opts.errorMessage] error message if not successful
560
+ * @param [opts.errorCode] error code if not successful
561
+ */
562
+ const _setStubResponse = (opts) => {
563
+ var _a, _b, _c;
564
+ const { path, body, } = opts;
565
+ const method = ((_a = opts.method) !== null && _a !== void 0 ? _a : 'GET').toUpperCase();
566
+ const errorMessage = ((_b = opts.errorMessage) !== null && _b !== void 0 ? _b : 'An unknown error has occurred.');
567
+ const errorCode = ((_c = opts.errorCode) !== null && _c !== void 0 ? _c : ReactKitErrorCode$1.NoCode);
568
+ // Store to stub responses
569
+ if (!stubResponses[method]) {
570
+ stubResponses[method] = {};
571
+ }
572
+ stubResponses[method][path] = ((opts.errorMessage || opts.errorCode)
573
+ ? {
574
+ success: false,
575
+ errorMessage,
576
+ errorCode,
577
+ }
578
+ : {
579
+ success: true,
580
+ body: body !== null && body !== void 0 ? body : undefined,
581
+ });
582
+ };
583
+ /*------------------------------------------------------------------------*/
584
+ /* Main */
585
+ /*------------------------------------------------------------------------*/
586
+ /**
587
+ * Visit an endpoint on the server [for client only]
588
+ * @author Gabe Abrams
589
+ * @param opts object containing all arguments
590
+ * @param opts.path - the path of the server endpoint
591
+ * @param [opts.method=GET] - the method of the endpoint
592
+ * @param [opts.params] - query/body parameters to include
593
+ * @returns response from server
594
+ */
595
+ const visitServerEndpoint = (opts) => __awaiter(void 0, void 0, void 0, function* () {
596
+ var _a, _b, _c;
597
+ const method = ((_a = opts.method) !== null && _a !== void 0 ? _a : 'GET');
598
+ // Handle stubs
599
+ const stubResponse = (_b = stubResponses[method]) === null || _b === void 0 ? void 0 : _b[opts.path];
600
+ if (stubResponse) {
601
+ // Remove from list
602
+ try {
603
+ stubResponses[method][opts.path] = undefined;
604
+ }
605
+ catch (err) {
606
+ // Ignore
607
+ }
608
+ // Success
609
+ if (stubResponse.success) {
610
+ return stubResponse.body;
611
+ }
612
+ // Error
613
+ throw new ErrorWithCode(stubResponse.errorMessage, stubResponse.errorCode);
614
+ }
615
+ // Send the request
616
+ const response = yield cacclSendRequest({
617
+ path: opts.path,
618
+ method: (_c = opts.method) !== null && _c !== void 0 ? _c : 'GET',
619
+ params: opts.params,
620
+ });
621
+ // Check for failure
622
+ if (!response || !response.body) {
623
+ throw new ErrorWithCode('We didn\'t get a response from the server. Please check your internet connection.', ReactKitErrorCode$1.NoResponse);
624
+ }
625
+ if (!response.body.success) {
626
+ // Session expired
627
+ if (response.body.code === ReactKitErrorCode$1.SessionExpired) {
628
+ // Skip notice if session was already expired
629
+ if (sessionAlreadyExpired) {
630
+ // Never return (browser is already reloading)
631
+ yield new Promise(() => {
632
+ // Promise that never returns
633
+ });
634
+ }
635
+ sessionAlreadyExpired = true;
636
+ // Show session expiration message
637
+ {
638
+ // Fallback to alert
639
+ // eslint-disable-next-line no-alert
640
+ alert('Your session has expired. Please start over.');
641
+ }
642
+ // Never return (don't continue execution)
643
+ yield new Promise(() => {
644
+ // Promise that never returns
645
+ });
646
+ }
647
+ // Other errors
648
+ throw new ErrorWithCode((response.body.message
649
+ || 'An unknown error occurred. Please contact an admin.'), (response.body.code
650
+ || ReactKitErrorCode$1.NoCode));
651
+ }
652
+ // Success! Extract the body
653
+ const { body } = response.body;
654
+ // Return
655
+ return body;
656
+ });
657
+
658
+ /**
659
+ * Log a user action on the client (cannot be used on the server)
660
+ * @author Gabe Abrams
661
+ */
662
+ const logClientEvent = (opts) => __awaiter(void 0, void 0, void 0, function* () {
663
+ var _a, _b, _c, _d, _e, _f, _g;
664
+ return visitServerEndpoint({
665
+ path: LOG_ROUTE_PATH,
666
+ method: 'POST',
667
+ params: {
668
+ context: (typeof opts.context === 'string'
669
+ ? opts.context
670
+ : ((_b = ((_a = opts.context) !== null && _a !== void 0 ? _a : {})._) !== null && _b !== void 0 ? _b : LogBuiltInMetadata.Context.Uncategorized)),
671
+ subcontext: ((_c = opts.subcontext) !== null && _c !== void 0 ? _c : LogBuiltInMetadata.Context.Uncategorized),
672
+ level: ((_d = opts.level) !== null && _d !== void 0 ? _d : LogLevel$1.Info),
673
+ tags: JSON.stringify((_e = opts.tags) !== null && _e !== void 0 ? _e : []),
674
+ metadata: JSON.stringify((_f = opts.metadata) !== null && _f !== void 0 ? _f : {}),
675
+ errorMessage: (opts.error
676
+ ? opts.error.message
677
+ : undefined),
678
+ errorCode: (opts.error
679
+ ? opts.error.code
680
+ : undefined),
681
+ errorStack: (opts.error
682
+ ? opts.error.stack
683
+ : undefined),
684
+ target: (opts.action
685
+ ? ((_g = opts.target) !== null && _g !== void 0 ? _g : LogBuiltInMetadata.Target.NoSpecificTarget)
686
+ : undefined),
687
+ action: (opts.action
688
+ ? opts.action
689
+ : undefined),
690
+ },
691
+ });
692
+ });
693
+
502
694
  /**
503
695
  * A wrapper for the entire React app that adds global functionality like
504
696
  * handling for fatal error messages, adds bootstrap support
@@ -618,6 +810,18 @@ const showFatalError = (error, errorTitle = 'An Error Occurred') => {
618
810
  const code = (typeof error === 'string'
619
811
  ? ReactKitErrorCode$1.NoCode
620
812
  : String((_b = error.code) !== null && _b !== void 0 ? _b : ReactKitErrorCode$1.NoCode));
813
+ // Add log
814
+ logClientEvent({
815
+ context: LogBuiltInMetadata.Context.ClientFatalError,
816
+ error: {
817
+ message,
818
+ code,
819
+ stack: (error !== null && error !== void 0 ? error : {}).stack,
820
+ },
821
+ metadata: {
822
+ errorTitle,
823
+ },
824
+ });
621
825
  // Handle case where app hasn't loaded
622
826
  if (!setFatalErrorMessage || !setFatalErrorCode) {
623
827
  alert$1(errorTitle, `${message} (code: ${code}). Please contact support.`);
@@ -727,7 +931,7 @@ const AppWrapper = (props) => {
727
931
  /*------------------------------------------------------------------------*/
728
932
  /* Style */
729
933
  /*------------------------------------------------------------------------*/
730
- const style$5 = `
934
+ const style$6 = `
731
935
  /* Container fades in */
732
936
  .LoadingSpinner-container {
733
937
  animation-name: LoadingSpinner-container-fade-in;
@@ -805,7 +1009,7 @@ const LoadingSpinner = () => {
805
1009
  /*------------------------------------------------------------------------*/
806
1010
  // Add all four blips to a container
807
1011
  return (React__default["default"].createElement("div", { className: "text-center LoadingSpinner LoadingSpinner-container" },
808
- React__default["default"].createElement("style", null, style$5),
1012
+ React__default["default"].createElement("style", null, style$6),
809
1013
  React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faCircle, className: "LoadingSpinner-blip-1 me-1" }),
810
1014
  React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faCircle, className: "LoadingSpinner-blip-2 me-1" }),
811
1015
  React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faCircle, className: "LoadingSpinner-blip-3 me-1" }),
@@ -819,7 +1023,7 @@ const LoadingSpinner = () => {
819
1023
  /*------------------------------------------------------------------------*/
820
1024
  /* Style */
821
1025
  /*------------------------------------------------------------------------*/
822
- const style$4 = `
1026
+ const style$5 = `
823
1027
  /* Tab Box */
824
1028
  .TabBox-box {
825
1029
  /* Light Border */
@@ -899,7 +1103,7 @@ const TabBox = (props) => {
899
1103
  /*----------------------------------------*/
900
1104
  // Full UI
901
1105
  return (React__default["default"].createElement("div", { className: `TabBox-container ${noBottomMargin ? '' : 'mb-2'}` },
902
- React__default["default"].createElement("style", null, style$4),
1106
+ React__default["default"].createElement("style", null, style$5),
903
1107
  React__default["default"].createElement("div", { className: "TabBox-title-container" },
904
1108
  React__default["default"].createElement("div", { className: "TabBox-title" }, title)),
905
1109
  React__default["default"].createElement("div", { className: `TabBox-box ps-2 pt-2 pe-2 ${noBottomPadding ? '' : 'pb-2'}` },
@@ -984,14 +1188,14 @@ const ButtonInputGroup = (props) => {
984
1188
  /*------------------------------------------------------------------------*/
985
1189
  /* -------------- Props ------------- */
986
1190
  // Destructure all props
987
- const { label, minLabelWidth, children, } = props;
1191
+ const { label, minLabelWidth, children, className, } = props;
988
1192
  /*------------------------------------------------------------------------*/
989
1193
  /* Render */
990
1194
  /*------------------------------------------------------------------------*/
991
1195
  /*----------------------------------------*/
992
1196
  /* Main UI */
993
1197
  /*----------------------------------------*/
994
- return (React__default["default"].createElement("div", { className: "input-group" },
1198
+ return (React__default["default"].createElement("div", { className: `input-group ${className !== null && className !== void 0 ? className : ''}` },
995
1199
  React__default["default"].createElement("div", { className: "input-group-prepend d-flex w-100" },
996
1200
  React__default["default"].createElement("span", { className: "input-group-text", style: {
997
1201
  minWidth: (minLabelWidth !== null && minLabelWidth !== void 0 ? minLabelWidth : undefined),
@@ -1006,6 +1210,34 @@ const ButtonInputGroup = (props) => {
1006
1210
  } }, children))));
1007
1211
  };
1008
1212
 
1213
+ const monthNames = [
1214
+ { short: 'Jan', full: 'January' },
1215
+ { short: 'Feb', full: 'February' },
1216
+ { short: 'Mar', full: 'March' },
1217
+ { short: 'Apr', full: 'April' },
1218
+ { short: 'May', full: 'May' },
1219
+ { short: 'Jun', full: 'June' },
1220
+ { short: 'Jul', full: 'July' },
1221
+ { short: 'Aug', full: 'August' },
1222
+ { short: 'Sep', full: 'September' },
1223
+ { short: 'Oct', full: 'October' },
1224
+ { short: 'Nov', full: 'November' },
1225
+ { short: 'Dec', full: 'December' },
1226
+ ];
1227
+ /**
1228
+ * Get the name of a month given the month number (1 = January, etc.)
1229
+ * If an invalid number is provided, we will treat it like January
1230
+ * @author Gabe Abrams
1231
+ * @param month the number of the month
1232
+ * @returns object containing multiple month name formats:
1233
+ * { short, full } where short will look like "Jan" and full will look like
1234
+ * "January"
1235
+ */
1236
+ const getMonthName = (month) => {
1237
+ var _a;
1238
+ return ((_a = monthNames[month - 1]) !== null && _a !== void 0 ? _a : monthNames[0]);
1239
+ };
1240
+
1009
1241
  const ORDINALS = ['th', 'st', 'nd', 'rd'];
1010
1242
  /**
1011
1243
  * Get a number's ordinal
@@ -1075,29 +1307,23 @@ const getTimeInfoInET = (dateOrTimestamp) => {
1075
1307
  };
1076
1308
  };
1077
1309
 
1310
+ /**
1311
+ * Force a number to stay within specific bounds
1312
+ * @author Gabe Abrams
1313
+ * @param num the number to move into the bounds
1314
+ * @param min the minimum number in the bound
1315
+ * @param max the maximum number in the bound
1316
+ * @returns bounded number
1317
+ */
1318
+ const forceNumIntoBounds = (num, min, max) => {
1319
+ return Math.max(min, Math.min(max, num));
1320
+ };
1321
+
1078
1322
  /**
1079
1323
  * A very simple, lightweight date chooser
1080
1324
  * @author Gabe Abrams
1081
1325
  */
1082
1326
  /*------------------------------------------------------------------------*/
1083
- /* Constants */
1084
- /*------------------------------------------------------------------------*/
1085
- // Constants
1086
- const MONTH_NAMES = [
1087
- 'January',
1088
- 'February',
1089
- 'March',
1090
- 'April',
1091
- 'May',
1092
- 'June',
1093
- 'July',
1094
- 'August',
1095
- 'September',
1096
- 'October',
1097
- 'November',
1098
- 'December',
1099
- ];
1100
- /*------------------------------------------------------------------------*/
1101
1327
  /* Component */
1102
1328
  /*------------------------------------------------------------------------*/
1103
1329
  const SimpleDateChooser = (props) => {
@@ -1106,8 +1332,8 @@ const SimpleDateChooser = (props) => {
1106
1332
  /*------------------------------------------------------------------------*/
1107
1333
  var _a;
1108
1334
  /* -------------- Props ------------- */
1109
- const { ariaLabel, name, month, day, year, onChange, } = props;
1110
- const numMonthsToShow = Math.min((_a = props.numMonthsToShow) !== null && _a !== void 0 ? _a : 6, 12);
1335
+ const { ariaLabel, name, month, day, year, onChange, chooseFromPast, } = props;
1336
+ let numMonthsToShow = forceNumIntoBounds(((_a = props.numMonthsToShow) !== null && _a !== void 0 ? _a : 6), 1, 12);
1111
1337
  /*------------------------------------------------------------------------*/
1112
1338
  /* Render */
1113
1339
  /*------------------------------------------------------------------------*/
@@ -1117,16 +1343,25 @@ const SimpleDateChooser = (props) => {
1117
1343
  // Determine the set of choices allowed
1118
1344
  const today = getTimeInfoInET();
1119
1345
  const choices = [];
1346
+ let startYear = today.year;
1347
+ let startMonth = today.month;
1348
+ if (chooseFromPast) {
1349
+ startMonth -= Math.max(0, numMonthsToShow - 1);
1350
+ if (startMonth <= 0) {
1351
+ startMonth += 12;
1352
+ startYear -= 1;
1353
+ }
1354
+ }
1120
1355
  for (let i = 0; i < numMonthsToShow; i++) {
1121
1356
  // Get month and year info
1122
- const unmoddedMonth = (today.month + i);
1357
+ const unmoddedMonth = (startMonth + i);
1123
1358
  const month = (unmoddedMonth > 12
1124
1359
  ? unmoddedMonth - 12
1125
1360
  : unmoddedMonth);
1126
- const monthName = MONTH_NAMES[month - 1];
1361
+ const monthName = getMonthName(month).full;
1127
1362
  const year = (unmoddedMonth > 12
1128
- ? today.year + 1
1129
- : today.year);
1363
+ ? startYear + 1
1364
+ : startYear);
1130
1365
  // Figure out which days are allowed
1131
1366
  const days = [];
1132
1367
  const numDaysInMonth = (new Date(year, month, 0)).getDate();
@@ -1182,7 +1417,7 @@ const SimpleDateChooser = (props) => {
1182
1417
  /*------------------------------------------------------------------------*/
1183
1418
  /* Style */
1184
1419
  /*------------------------------------------------------------------------*/
1185
- const style$3 = `
1420
+ const style$4 = `
1186
1421
  .Drawer-container {
1187
1422
  margin-left: 1rem;
1188
1423
  margin-right: 1rem;
@@ -1214,7 +1449,7 @@ const Drawer = (props) => {
1214
1449
  /* Main UI */
1215
1450
  /*----------------------------------------*/
1216
1451
  return (React__default["default"].createElement("div", { className: "Drawer-container" },
1217
- React__default["default"].createElement("style", null, style$3),
1452
+ React__default["default"].createElement("style", null, style$4),
1218
1453
  children));
1219
1454
  };
1220
1455
 
@@ -1225,7 +1460,7 @@ const Drawer = (props) => {
1225
1460
  /*------------------------------------------------------------------------*/
1226
1461
  /* Style */
1227
1462
  /*------------------------------------------------------------------------*/
1228
- const style$2 = `
1463
+ const style$3 = `
1229
1464
  .PopSuccessMark-outer-container {
1230
1465
  position: relative;
1231
1466
  display: inline-block;
@@ -1330,7 +1565,7 @@ const PopSuccessMark = (props) => {
1330
1565
  width: `${sizeRem}rem`,
1331
1566
  height: `${sizeRem}rem`,
1332
1567
  }, "aria-label": "checkmark indicating success" },
1333
- React__default["default"].createElement("style", null, style$2),
1568
+ React__default["default"].createElement("style", null, style$3),
1334
1569
  React__default["default"].createElement("div", { className: `PopSuccessMark-check-stroke-1 bg-${checkVariant}`, style: {
1335
1570
  borderRadius: `${sizeRem / 5}rem`,
1336
1571
  } }),
@@ -1346,7 +1581,7 @@ const PopSuccessMark = (props) => {
1346
1581
  /*------------------------------------------------------------------------*/
1347
1582
  /* Style */
1348
1583
  /*------------------------------------------------------------------------*/
1349
- const style$1 = `
1584
+ const style$2 = `
1350
1585
  .PopFailureMark-outer-container {
1351
1586
  position: relative;
1352
1587
  display: inline-block;
@@ -1450,7 +1685,7 @@ const PopFailureMark = (props) => {
1450
1685
  width: `${sizeRem}rem`,
1451
1686
  height: `${sizeRem}rem`,
1452
1687
  }, "aria-label": "mark indicating failure" },
1453
- React__default["default"].createElement("style", null, style$1),
1688
+ React__default["default"].createElement("style", null, style$2),
1454
1689
  React__default["default"].createElement("div", { className: `PopFailureMark-x-stroke-1 bg-${xVariant}`, style: {
1455
1690
  borderRadius: `${sizeRem / 5}rem`,
1456
1691
  } }),
@@ -1466,7 +1701,7 @@ const PopFailureMark = (props) => {
1466
1701
  /*------------------------------------------------------------------------*/
1467
1702
  /* Style */
1468
1703
  /*------------------------------------------------------------------------*/
1469
- const style = `
1704
+ const style$1 = `
1470
1705
  .PopPendingMark-outer-container {
1471
1706
  position: relative;
1472
1707
  display: inline-block;
@@ -1539,7 +1774,7 @@ const PopPendingMark = (props) => {
1539
1774
  width: `${sizeRem}rem`,
1540
1775
  height: `${sizeRem}rem`,
1541
1776
  }, "aria-label": "mark indicating that the item is pending" },
1542
- React__default["default"].createElement("style", null, style),
1777
+ React__default["default"].createElement("style", null, style$1),
1543
1778
  React__default["default"].createElement("div", null,
1544
1779
  React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faHourglass, className: `PopPendingMark-hourglass text-${hourglassVariant}`, style: {
1545
1780
  fontSize: `${sizeRem * 0.6}rem`,
@@ -1552,27 +1787,27 @@ const PopPendingMark = (props) => {
1552
1787
  */
1553
1788
  /* ------------- Actions ------------ */
1554
1789
  // Types of actions
1555
- var ActionType$1;
1790
+ var ActionType$3;
1556
1791
  (function (ActionType) {
1557
1792
  // Indicate that the text was recently copied
1558
1793
  ActionType["IndicateRecentlyCopied"] = "indicate-recently-copied";
1559
1794
  // Clear the status
1560
1795
  ActionType["ClearRecentlyCopiedStatus"] = "clear-recently-copied-status";
1561
- })(ActionType$1 || (ActionType$1 = {}));
1796
+ })(ActionType$3 || (ActionType$3 = {}));
1562
1797
  /**
1563
1798
  * Reducer that executes actions
1564
1799
  * @author Gabe Abrams
1565
1800
  * @param state current state
1566
1801
  * @param action action to execute
1567
1802
  */
1568
- const reducer$1 = (state, action) => {
1803
+ const reducer$3 = (state, action) => {
1569
1804
  switch (action.type) {
1570
- case ActionType$1.IndicateRecentlyCopied: {
1805
+ case ActionType$3.IndicateRecentlyCopied: {
1571
1806
  return {
1572
1807
  recentlyCopied: true,
1573
1808
  };
1574
1809
  }
1575
- case ActionType$1.ClearRecentlyCopiedStatus: {
1810
+ case ActionType$3.ClearRecentlyCopiedStatus: {
1576
1811
  return {
1577
1812
  recentlyCopied: false,
1578
1813
  };
@@ -1598,7 +1833,7 @@ const CopiableBox = (props) => {
1598
1833
  recentlyCopied: false,
1599
1834
  };
1600
1835
  // Initialize state
1601
- const [state, dispatch] = React.useReducer(reducer$1, initialState);
1836
+ const [state, dispatch] = React.useReducer(reducer$3, initialState);
1602
1837
  // Destructure common state
1603
1838
  const { recentlyCopied, } = state;
1604
1839
  /*------------------------------------------------------------------------*/
@@ -1618,13 +1853,13 @@ const CopiableBox = (props) => {
1618
1853
  }
1619
1854
  // Show copied notice
1620
1855
  dispatch({
1621
- type: ActionType$1.IndicateRecentlyCopied,
1856
+ type: ActionType$3.IndicateRecentlyCopied,
1622
1857
  });
1623
1858
  // Wait a moment
1624
1859
  yield waitMs(4000);
1625
1860
  // Hide copied notice
1626
1861
  dispatch({
1627
- type: ActionType$1.ClearRecentlyCopiedStatus,
1862
+ type: ActionType$3.ClearRecentlyCopiedStatus,
1628
1863
  });
1629
1864
  });
1630
1865
  /*------------------------------------------------------------------------*/
@@ -1679,20 +1914,20 @@ const CopiableBox = (props) => {
1679
1914
  */
1680
1915
  /* ------------- Actions ------------ */
1681
1916
  // Types of actions
1682
- var ActionType;
1917
+ var ActionType$2;
1683
1918
  (function (ActionType) {
1684
1919
  // Toggle whether the children are being shown
1685
1920
  ActionType["ToggleItems"] = "toggle-items";
1686
- })(ActionType || (ActionType = {}));
1921
+ })(ActionType$2 || (ActionType$2 = {}));
1687
1922
  /**
1688
1923
  * Reducer that executes actions
1689
1924
  * @author Yuen Ler Chow
1690
1925
  * @param state current state
1691
1926
  * @param action action to execute
1692
1927
  */
1693
- const reducer = (state, action) => {
1928
+ const reducer$2 = (state, action) => {
1694
1929
  switch (action.type) {
1695
- case ActionType.ToggleItems: {
1930
+ case ActionType$2.ToggleItems: {
1696
1931
  return { isShowingItems: !state.isShowingItems };
1697
1932
  }
1698
1933
  default: {
@@ -1716,7 +1951,7 @@ const NestableItemList = (props) => {
1716
1951
  isShowingItems: false,
1717
1952
  };
1718
1953
  // Initialize state
1719
- const [state, dispatch] = React.useReducer(reducer, initialState);
1954
+ const [state, dispatch] = React.useReducer(reducer$2, initialState);
1720
1955
  // Destructure common state
1721
1956
  const { isShowingItems, } = state;
1722
1957
  /*------------------------------------------------------------------------*/
@@ -1806,7 +2041,7 @@ const NestableItemList = (props) => {
1806
2041
  backgroundColor: 'transparent',
1807
2042
  }, type: "button", onClick: () => {
1808
2043
  dispatch({
1809
- type: ActionType.ToggleItems,
2044
+ type: ActionType$2.ToggleItems,
1810
2045
  });
1811
2046
  }, "aria-label": `${isShowingItems ? 'Hide' : 'Show'} items in ${item.name}` },
1812
2047
  React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: isShowingItems ? freeSolidSvgIcons.faChevronDown : freeSolidSvgIcons.faChevronRight })))),
@@ -1835,7 +2070,7 @@ const ItemPicker = (props) => {
1835
2070
  /*------------------------------------------------------------------------*/
1836
2071
  /* -------------- Props ------------- */
1837
2072
  // Destructure all props
1838
- const { title, items, onChanged, } = props;
2073
+ const { title, items, onChanged, noBottomMargin, } = props;
1839
2074
  /*------------------------------------------------------------------------*/
1840
2075
  /* Component Functions */
1841
2076
  /*------------------------------------------------------------------------*/
@@ -1845,24 +2080,1728 @@ const ItemPicker = (props) => {
1845
2080
  /*----------------------------------------*/
1846
2081
  /* Main UI */
1847
2082
  /*----------------------------------------*/
1848
- return (React__default["default"].createElement(TabBox, { title: title },
2083
+ return (React__default["default"].createElement(TabBox, { title: title, noBottomMargin: noBottomMargin },
1849
2084
  React__default["default"].createElement("div", { style: { overflowX: 'auto' } },
1850
2085
  React__default["default"].createElement(NestableItemList, { items: items, onChanged: onChanged }))));
1851
2086
  };
1852
2087
 
1853
2088
  /**
1854
- * One minute in ms
2089
+ * Path of the route for storing client-side logs
1855
2090
  * @author Gabe Abrams
1856
2091
  */
1857
- const MINUTE_IN_MS = 60000;
2092
+ const LOG_REVIEW_ROUTE_PATH_PREFIX = `/admin${ROUTE_PATH_PREFIX}/logs`;
1858
2093
 
1859
2094
  /**
1860
- * One hour in ms
2095
+ * Source of a log event
1861
2096
  * @author Gabe Abrams
1862
2097
  */
1863
- const HOUR_IN_MS = 3600000;
1864
-
1865
- /**
2098
+ var LogSource;
2099
+ (function (LogSource) {
2100
+ // Client
2101
+ LogSource["Client"] = "client";
2102
+ // Server
2103
+ LogSource["Server"] = "server";
2104
+ })(LogSource || (LogSource = {}));
2105
+ var LogSource$1 = LogSource;
2106
+
2107
+ /**
2108
+ * Type of a log event
2109
+ * @author Gabe Abrams
2110
+ */
2111
+ var LogType;
2112
+ (function (LogType) {
2113
+ // User action
2114
+ LogType["Action"] = "action";
2115
+ // Error
2116
+ LogType["Error"] = "error";
2117
+ })(LogType || (LogType = {}));
2118
+ var LogType$1 = LogType;
2119
+
2120
+ /**
2121
+ * Types of actions
2122
+ * @author Gabe Abrams
2123
+ */
2124
+ var LogAction;
2125
+ (function (LogAction) {
2126
+ // Target was opened by the user (it was not on screen, but now it is)
2127
+ LogAction["Open"] = "open";
2128
+ // Target was closed by the user (it was on screen, but now it is not)
2129
+ LogAction["Close"] = "close";
2130
+ // Target was cancelled by the user (it was on closed without saving)
2131
+ LogAction["Cancel"] = "cancel";
2132
+ // Target was expanded by the user (it always remains on screen, but size was changed)
2133
+ LogAction["Expand"] = "expand";
2134
+ // Target was collapsed by the user (it always remains on screen, but size was changed)
2135
+ LogAction["Collapse"] = "collapse";
2136
+ // Target was viewed by the user (only for items that are not opened or closed, those must use Open/Close actions)
2137
+ LogAction["View"] = "view";
2138
+ // Target interrupted the user (popup, dialog, validation message, etc. appeared without user prompting)
2139
+ LogAction["Interrupt"] = "interrupt";
2140
+ // Target was created by the user (it did not exist before)
2141
+ LogAction["Create"] = "create";
2142
+ // Target was edited by the user (it existed and was changed)
2143
+ LogAction["Edit"] = "edit";
2144
+ // Target was deleted by the user (it existed and now it doesn't)
2145
+ LogAction["Delete"] = "delete";
2146
+ // Target was added by the user (it already existed and was added to another place)
2147
+ LogAction["Add"] = "add";
2148
+ // Target was removed by the user (it was removed from something but still exists)
2149
+ LogAction["Remove"] = "remove";
2150
+ // Target was activated by the user (click, check, tap, keypress, etc.)
2151
+ LogAction["Activate"] = "activate";
2152
+ // Target was deactivated by the user (click away, uncheck, tap outside of, tab away, etc.)
2153
+ LogAction["Deactivate"] = "deactivate";
2154
+ // User showed interest in a target (hover, peek, etc.)
2155
+ LogAction["Peek"] = "peek";
2156
+ // Unknown action
2157
+ LogAction["Unknown"] = "unknown";
2158
+ })(LogAction || (LogAction = {}));
2159
+ var LogAction$1 = LogAction;
2160
+
2161
+ /**
2162
+ * Server-side API param types
2163
+ * @author Gabe Abrams
2164
+ */
2165
+ var ParamType;
2166
+ (function (ParamType) {
2167
+ ParamType["Boolean"] = "boolean";
2168
+ ParamType["BooleanOptional"] = "boolean-optional";
2169
+ ParamType["Float"] = "float";
2170
+ ParamType["FloatOptional"] = "float-optional";
2171
+ ParamType["Int"] = "int";
2172
+ ParamType["IntOptional"] = "int-optional";
2173
+ ParamType["JSON"] = "json";
2174
+ ParamType["JSONOptional"] = "json-optional";
2175
+ ParamType["String"] = "string";
2176
+ ParamType["StringOptional"] = "string-optional";
2177
+ })(ParamType || (ParamType = {}));
2178
+ var ParamType$1 = ParamType;
2179
+
2180
+ /**
2181
+ * Round a number to a certain number of decimals
2182
+ * @author Gabe Abrams
2183
+ * @param num the number to round
2184
+ * @param numDecimals the number of decimals to round to
2185
+ * @returns rounded number
2186
+ */
2187
+ const roundToNumDecimals = (num, numDecimals) => {
2188
+ const rounder = 10 ** numDecimals;
2189
+ return (Math.round(num * rounder) / rounder);
2190
+ };
2191
+
2192
+ /**
2193
+ * Escape a CSV cell if needed
2194
+ * @author Gabe Abrams
2195
+ * @param text the cell contents
2196
+ * @returns escaped cell text
2197
+ */
2198
+ const escapeCellText = (text) => {
2199
+ if (!text.includes(',')) {
2200
+ // No need to escape
2201
+ return text;
2202
+ }
2203
+ // Perform escape
2204
+ return `"${text.replace(/"/g, '""')}`;
2205
+ };
2206
+ /**
2207
+ * Generate a CSV file
2208
+ * @author Gabe Abrams
2209
+ * @param data list of row data in the form of json objects
2210
+ * @param columns list of columns to include in the csv
2211
+ * @returns multiline csv string
2212
+ */
2213
+ const genCSV = (data, columns) => {
2214
+ let csv = '';
2215
+ // Add header
2216
+ csv += (columns
2217
+ .map((column) => {
2218
+ return escapeCellText(column.title);
2219
+ })
2220
+ .join(','));
2221
+ // Add each row
2222
+ data.forEach((datum) => {
2223
+ csv += (columns
2224
+ .map((column) => {
2225
+ return escapeCellText(datum[column.param]);
2226
+ })
2227
+ .join(','));
2228
+ });
2229
+ // Return
2230
+ return csv;
2231
+ };
2232
+
2233
+ /**
2234
+ * Button for downloading a csv file
2235
+ * @author Gabe Abrams
2236
+ */
2237
+ /*------------------------------------------------------------------------*/
2238
+ /* Component */
2239
+ /*------------------------------------------------------------------------*/
2240
+ const CSVDownloadButton = (props) => {
2241
+ /*------------------------------------------------------------------------*/
2242
+ /* Setup */
2243
+ /*------------------------------------------------------------------------*/
2244
+ /* -------------- Props ------------- */
2245
+ // Destructure all props
2246
+ const { filename, csv, id, className, ariaLabel, style, onClick, children, } = props;
2247
+ /*------------------------------------------------------------------------*/
2248
+ /* Render */
2249
+ /*------------------------------------------------------------------------*/
2250
+ /*----------------------------------------*/
2251
+ /* Main UI */
2252
+ /*----------------------------------------*/
2253
+ // Render the button
2254
+ return (React__default["default"].createElement("a", { id: id, download: filename, href: `data:application/octet-stream,${csv}`, className: `CSVDownloadButton-button ${className !== null && className !== void 0 ? className : 'btn btn-secondary'}`, "aria-label": (ariaLabel
2255
+ ? `Click to download ${filename}`
2256
+ : ariaLabel), style: style, onClick: onClick },
2257
+ !children && (React__default["default"].createElement(React__default["default"].Fragment, null,
2258
+ React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faCloudDownloadAlt, className: "mr-2" }),
2259
+ "Download CSV")),
2260
+ children));
2261
+ };
2262
+
2263
+ /**
2264
+ * Intelligent table
2265
+ * @author Gabe Abrams
2266
+ */
2267
+ // Sort types
2268
+ var SortType;
2269
+ (function (SortType) {
2270
+ // Ascending
2271
+ SortType["Ascending"] = "ascending";
2272
+ // Descending
2273
+ SortType["Descending"] = "descending";
2274
+ })(SortType || (SortType = {}));
2275
+ /* ------------- Actions ------------ */
2276
+ // Types of actions
2277
+ var ActionType$1;
2278
+ (function (ActionType) {
2279
+ // Toggle sort column param
2280
+ ActionType["ToggleSortColumn"] = "toggle-sort-column";
2281
+ // Toggle the visibility of a column
2282
+ ActionType["ToggleColumnVisibility"] = "toggle-column-visibility";
2283
+ // Toggle the column visibility customization modal
2284
+ ActionType["ToggleColVisCusModalVisibility"] = "toggle-col-vis-cus-modal-visibility";
2285
+ })(ActionType$1 || (ActionType$1 = {}));
2286
+ /**
2287
+ * Reducer that executes actions
2288
+ * @author Gabe Abrams
2289
+ * @param state current state
2290
+ * @param action action to execute
2291
+ */
2292
+ const reducer$1 = (state, action) => {
2293
+ switch (action.type) {
2294
+ case ActionType$1.ToggleSortColumn: {
2295
+ if (action.param !== state.sortColumnParam) {
2296
+ // Different column param
2297
+ return Object.assign(Object.assign({}, state), { sortColumnParam: action.param, sortType: SortType.Ascending });
2298
+ }
2299
+ if (state.sortType === SortType.Ascending) {
2300
+ // Switch to descending
2301
+ return Object.assign(Object.assign({}, state), { sortType: SortType.Descending });
2302
+ }
2303
+ // Stop sorting by column
2304
+ return Object.assign(Object.assign({}, state), { sortColumnParam: undefined, sortType: SortType.Ascending });
2305
+ }
2306
+ case ActionType$1.ToggleColumnVisibility: {
2307
+ const { columnVisibilityMap } = state;
2308
+ columnVisibilityMap[action.param] = !columnVisibilityMap[action.param];
2309
+ return Object.assign(Object.assign({}, state), { columnVisibilityMap });
2310
+ }
2311
+ case ActionType$1.ToggleColVisCusModalVisibility: {
2312
+ return Object.assign(Object.assign({}, state), { columnVisibilityCustomizationModalVisible: !state.columnVisibilityCustomizationModalVisible });
2313
+ }
2314
+ default: {
2315
+ return state;
2316
+ }
2317
+ }
2318
+ };
2319
+ /*------------------------------------------------------------------------*/
2320
+ /* Component */
2321
+ /*------------------------------------------------------------------------*/
2322
+ const IntelliTable = (props) => {
2323
+ /*------------------------------------------------------------------------*/
2324
+ /* Setup */
2325
+ /*------------------------------------------------------------------------*/
2326
+ var _a;
2327
+ /* -------------- Props ------------- */
2328
+ // Destructure all props
2329
+ const { title, id, data, columns, } = props;
2330
+ /* -------------- State ------------- */
2331
+ // Initial state
2332
+ const initColumnVisibilityMap = {};
2333
+ columns.forEach((column) => {
2334
+ initColumnVisibilityMap[column.param] = !column.startsHidden;
2335
+ });
2336
+ const initialState = {
2337
+ sortColumnParam: undefined,
2338
+ sortType: SortType.Descending,
2339
+ columnVisibilityMap: initColumnVisibilityMap,
2340
+ columnVisibilityCustomizationModalVisible: false,
2341
+ };
2342
+ // Initialize state
2343
+ const [state, dispatch] = React.useReducer(reducer$1, initialState);
2344
+ // Destructure common state
2345
+ const { sortColumnParam, sortType, columnVisibilityMap, columnVisibilityCustomizationModalVisible, } = state;
2346
+ /* ------- Col Vis Customization Modal ------ */
2347
+ if (columnVisibilityCustomizationModalVisible) {
2348
+ // Create modal
2349
+ (React__default["default"].createElement(Modal, { type: ModalType$1.Okay, title: "Choose columns to show:", onClose: () => {
2350
+ dispatch({
2351
+ type: ActionType$1.ToggleColVisCusModalVisibility,
2352
+ });
2353
+ } }, columns.map((column) => {
2354
+ return (React__default["default"].createElement(CheckboxButton, { key: column.param, id: `IntelliTable-${id}-toggle-visibility-${column.param}`, text: column.title, onChanged: () => {
2355
+ dispatch({
2356
+ type: ActionType$1.ToggleColumnVisibility,
2357
+ param: column.param,
2358
+ });
2359
+ }, checked: columnVisibilityMap[column.param], ariaLabel: `show "${column.title}" column` }));
2360
+ })));
2361
+ }
2362
+ /*----------------------------------------*/
2363
+ /* Main UI */
2364
+ /*----------------------------------------*/
2365
+ // Table header
2366
+ const headerCells = (columns
2367
+ .filter((column) => {
2368
+ return columnVisibilityMap[column.param];
2369
+ })
2370
+ .map((column) => {
2371
+ // Custom info based on current sort type
2372
+ let sortButtonAriaLabel;
2373
+ let sortIcon = freeSolidSvgIcons.faSort;
2374
+ if (!sortColumnParam) {
2375
+ // Not being sorted yet
2376
+ sortButtonAriaLabel = `sort ascending by ${column.title}`;
2377
+ sortIcon = freeSolidSvgIcons.faSort;
2378
+ }
2379
+ else if (column.param === sortColumnParam) {
2380
+ // Already sorted by this column
2381
+ if (sortType === SortType.Ascending) {
2382
+ // Sorted ascending
2383
+ sortButtonAriaLabel = `sort descending by ${column.title}`;
2384
+ sortIcon = freeSolidSvgIcons.faSortDown;
2385
+ }
2386
+ else {
2387
+ // Sorted descending
2388
+ sortButtonAriaLabel = `stop sorting by ${column.title}`;
2389
+ sortIcon = freeSolidSvgIcons.faSortUp;
2390
+ }
2391
+ }
2392
+ else {
2393
+ // Sorted by a different column
2394
+ sortButtonAriaLabel = `sort ascending by ${column.title}`;
2395
+ sortIcon = freeSolidSvgIcons.faSort;
2396
+ }
2397
+ // Create the cell UI
2398
+ return (React__default["default"].createElement("th", { key: column.param, scope: "col", id: `IntelliTable-${id}-header-${column.param}` },
2399
+ React__default["default"].createElement("div", { className: "d-flex align-items-center justify-content-center flex-row h-100" },
2400
+ React__default["default"].createElement("span", { className: "text-nowrap" }, column.title),
2401
+ React__default["default"].createElement("div", null,
2402
+ React__default["default"].createElement("button", { type: "button", className: "btn btn-light btn-sm ms-1", "aria-label": sortButtonAriaLabel, onClick: () => {
2403
+ dispatch({
2404
+ type: ActionType$1.ToggleSortColumn,
2405
+ param: column.param,
2406
+ });
2407
+ } },
2408
+ React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: sortIcon }))))));
2409
+ }));
2410
+ const tableHeader = (React__default["default"].createElement("thead", null,
2411
+ React__default["default"].createElement("tr", null, headerCells)));
2412
+ // Sort data
2413
+ let sortedData = [...data];
2414
+ const paramType = (_a = columns.find((column) => {
2415
+ return (column.param === sortColumnParam);
2416
+ })) === null || _a === void 0 ? void 0 : _a.type;
2417
+ const descending = (sortType === SortType.Descending);
2418
+ if (sortColumnParam) {
2419
+ sortedData.sort((a, b) => {
2420
+ const aVal = a[sortColumnParam];
2421
+ const bVal = b[sortColumnParam];
2422
+ // Sort differently based on the data type
2423
+ // > Boolean
2424
+ if (paramType === ParamType$1.Boolean) {
2425
+ if (aVal && !bVal) {
2426
+ return (descending ? -1 : 1);
2427
+ }
2428
+ if (!aVal && bVal) {
2429
+ return (descending ? 1 : -1);
2430
+ }
2431
+ return 0;
2432
+ }
2433
+ // > Number
2434
+ if (paramType === ParamType$1.Int
2435
+ || paramType === ParamType$1.Float) {
2436
+ return (descending
2437
+ ? (bVal - aVal)
2438
+ : (aVal - bVal));
2439
+ }
2440
+ // > String
2441
+ if (paramType === ParamType$1.String) {
2442
+ if (aVal < bVal) {
2443
+ return (descending ? -1 : 1);
2444
+ }
2445
+ if (aVal > bVal) {
2446
+ return (descending ? 1 : -1);
2447
+ }
2448
+ return 0;
2449
+ }
2450
+ // > JSON
2451
+ if (paramType === ParamType$1.JSON) {
2452
+ const aSize = (Array.isArray(aVal)
2453
+ ? aVal.length
2454
+ : Object.keys(aVal).length);
2455
+ const bSize = (Array.isArray(bVal)
2456
+ ? bVal.length
2457
+ : Object.keys(bVal).length);
2458
+ return (descending
2459
+ ? (bSize - aSize)
2460
+ : (aSize - bSize));
2461
+ }
2462
+ // No sort
2463
+ return 0;
2464
+ });
2465
+ }
2466
+ // Table body
2467
+ const rows = sortedData.map((datum) => {
2468
+ // Build cells
2469
+ const cells = (columns
2470
+ .filter((column) => {
2471
+ return columnVisibilityMap[column.param];
2472
+ })
2473
+ .map((column) => {
2474
+ // Get value
2475
+ let value = datum;
2476
+ const paramParts = column.param.split('.');
2477
+ paramParts.forEach((paramPart) => {
2478
+ value = (value !== null && value !== void 0 ? value : {})[paramPart];
2479
+ });
2480
+ let fullValue;
2481
+ let visibleValue;
2482
+ let title = '';
2483
+ if (column.type === ParamType$1.Boolean) {
2484
+ fullValue = !!(value);
2485
+ const noValue = (value === undefined
2486
+ || value === null);
2487
+ visibleValue = (noValue
2488
+ ? (React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faCheckCircle }))
2489
+ : (React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faXmarkCircle })));
2490
+ title = (fullValue ? 'True' : 'False');
2491
+ }
2492
+ else if (column.type === ParamType$1.Int) {
2493
+ fullValue = Number.parseInt(value, 10);
2494
+ const noValue = Number.isNaN(fullValue);
2495
+ visibleValue = (noValue
2496
+ ? (React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faMinus }))
2497
+ : fullValue);
2498
+ title = String(fullValue);
2499
+ }
2500
+ else if (column.type === ParamType$1.Float) {
2501
+ fullValue = Number.parseFloat(value);
2502
+ const noValue = Number.isNaN(fullValue);
2503
+ visibleValue = (noValue
2504
+ ? (React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faMinus }))
2505
+ : roundToNumDecimals(fullValue, 2));
2506
+ title = String(fullValue);
2507
+ }
2508
+ else if (column.type === ParamType$1.String) {
2509
+ fullValue = String(value).trim();
2510
+ const noValue = (value.trim().length) === 0;
2511
+ visibleValue = (noValue
2512
+ ? (React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faMinus }))
2513
+ : fullValue);
2514
+ title = `"${value}"`;
2515
+ }
2516
+ else if (column.type === ParamType$1.JSON) {
2517
+ fullValue = JSON.stringify(value);
2518
+ const noValue = (Array.isArray(value)
2519
+ ? (!value || value.length === 0)
2520
+ : Object.keys(value !== null && value !== void 0 ? value : {}).length === 0);
2521
+ visibleValue = (noValue
2522
+ ? (React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faMinus }))
2523
+ : fullValue);
2524
+ }
2525
+ // Create UI
2526
+ return (React__default["default"].createElement("td", { key: `${datum.id}-${column.param}`, title: title }, visibleValue));
2527
+ }));
2528
+ // Add cells to a row
2529
+ return (React__default["default"].createElement("tr", { key: datum.id }, cells));
2530
+ });
2531
+ const tableBody = (React__default["default"].createElement("tbody", null, rows));
2532
+ // Build table
2533
+ const table = (React__default["default"].createElement("table", { className: "table table-dark table-striped" },
2534
+ tableHeader,
2535
+ tableBody));
2536
+ // Count the number of hidden columns
2537
+ const numHiddenCols = (Object.values(columnVisibilityMap)
2538
+ .filter((isVisible) => {
2539
+ return !isVisible;
2540
+ })
2541
+ .length);
2542
+ // Build CSV
2543
+ const csv = genCSV(data, columns);
2544
+ // Build main UI
2545
+ return (React__default["default"].createElement("div", { className: `IntelliTable-container-${id}` },
2546
+ React__default["default"].createElement("div", { className: "d-flex align-items-center justify-content-center" },
2547
+ React__default["default"].createElement("h3", { className: "m-0" }, title),
2548
+ React__default["default"].createElement("div", { className: "flex-grow-1 text-end" },
2549
+ React__default["default"].createElement(CSVDownloadButton, { "aria-label": `download data as csv for ${title}`, id: `IntelliTable-${id}-download-as-csv`, filename: `${title}.csv`, csv: csv }),
2550
+ React__default["default"].createElement("button", { type: "button", className: "btn btn-secondary ms-2", "aria-label": `show panel for customizing which columns show in table ${title}`, id: `IntelliTable-${id}-show-column-customization-modal`, onClick: () => {
2551
+ dispatch({
2552
+ type: ActionType$1.ToggleColVisCusModalVisibility,
2553
+ });
2554
+ } },
2555
+ "Show/Hide Cols",
2556
+ numHiddenCols > 0 && (React__default["default"].createElement(React__default["default"].Fragment, null,
2557
+ ' ',
2558
+ "(",
2559
+ numHiddenCols,
2560
+ ' ',
2561
+ "hidden)"))))),
2562
+ React__default["default"].createElement("div", { className: `IntelliTable-table-${id}`, style: {
2563
+ overflowX: 'auto',
2564
+ } }, table)));
2565
+ };
2566
+
2567
+ /**
2568
+ * Log reviewer panel that allows users (must be approved admins) to
2569
+ * review logs written by dce-reactkit
2570
+ * @author Gabe Abrams
2571
+ */
2572
+ // Types of filter drawers
2573
+ var FilterDrawer;
2574
+ (function (FilterDrawer) {
2575
+ FilterDrawer["Date"] = "date";
2576
+ FilterDrawer["Context"] = "context";
2577
+ FilterDrawer["Tag"] = "tag";
2578
+ FilterDrawer["Action"] = "action";
2579
+ FilterDrawer["Advanced"] = "advanced";
2580
+ })(FilterDrawer || (FilterDrawer = {}));
2581
+ /*------------------------------------------------------------------------*/
2582
+ /* Style */
2583
+ /*------------------------------------------------------------------------*/
2584
+ const style = `
2585
+ .LogReviewer-outer-container {
2586
+ /* Full Screen */
2587
+ display: inline-block;
2588
+ left: 0;
2589
+ top: 0;
2590
+ width: 100vw;
2591
+ height: 100vh;
2592
+
2593
+ /* On Top and Fixed */
2594
+ position: fixed;
2595
+ z-index: 90000;
2596
+
2597
+ /* Space around contents */
2598
+ padding: 0.5rem;
2599
+
2600
+ /* Dark Background */
2601
+ background-color: rgba(0, 0, 0, 0.7);
2602
+
2603
+ /* No Clickthrough */
2604
+ pointer-events: none;
2605
+ }
2606
+
2607
+ .LogReviewer-inner-container {
2608
+ /* Full screen, rounded modal-like look */
2609
+ display: flex;
2610
+ height: 100%;
2611
+ border: 0.05rem solid black;
2612
+ border-radius: 0.5rem;
2613
+ overflow: hidden;
2614
+ padding: 0.7rem;
2615
+
2616
+ /* Solid background */
2617
+ background-color: white;
2618
+ color: black;
2619
+
2620
+ /* Place contents in flex column */
2621
+ flex-direction: column;
2622
+
2623
+ /* Re-allow interaction */
2624
+ pointer-events: all;
2625
+ }
2626
+
2627
+ .LogReviewer-header {
2628
+ /* Elements in flex row */
2629
+ display: flex;
2630
+ flex-direction: row;
2631
+ }
2632
+
2633
+ .LogReviewer-header-title {
2634
+ /* Take up remaining width */
2635
+ flex-grow: 1;
2636
+ }
2637
+
2638
+ .LogReviewer-contents {
2639
+ /* Take up remaining height */
2640
+ flex-grow: 1;
2641
+
2642
+ /* Vertical scroll */
2643
+ overflow-y: auto;
2644
+ }
2645
+
2646
+ .LogReviewer-header-close-button {
2647
+ border: 0 !important;
2648
+ background-color: transparent !important;
2649
+ padding-top: 0 !important;
2650
+ padding-bottom: 0 !important;
2651
+ margin: 0 !important;
2652
+ color: #333 !important;
2653
+ }
2654
+ .LogReviewer-header-close-button:hover {
2655
+ border: 0 !important;
2656
+ background-color: transparent !important;
2657
+ padding-top: 0 !important;
2658
+ padding-bottom: 0 !important;
2659
+ margin: 0 !important;
2660
+ color: #000 !important;
2661
+ }
2662
+ `;
2663
+ /*------------------------------------------------------------------------*/
2664
+ /* Static Functions */
2665
+ /*------------------------------------------------------------------------*/
2666
+ /**
2667
+ * Turn a machine-readable name into a human-readable name
2668
+ * @author Gabe Abrams
2669
+ * @param name machine-readable name
2670
+ * @returns human-readable name
2671
+ */
2672
+ const genHumanReadableName = (machineReadableName) => {
2673
+ let humanReadableName = '';
2674
+ // Add chars and spaces
2675
+ const chars = machineReadableName.split('');
2676
+ chars.forEach((char) => {
2677
+ if (/[A-Z]/.test(char)) {
2678
+ // Uppercase! Add a space before
2679
+ humanReadableName += ' ';
2680
+ }
2681
+ humanReadableName += char;
2682
+ });
2683
+ // Trim and return
2684
+ return humanReadableName.trim();
2685
+ };
2686
+ /* ------------- Actions ------------ */
2687
+ // Types of actions
2688
+ var ActionType;
2689
+ (function (ActionType) {
2690
+ // Show the loading bar
2691
+ ActionType["StartLoading"] = "start-loading";
2692
+ // Finish loading one or more months of logs
2693
+ ActionType["FinishLoading"] = "finish-loading";
2694
+ // Reset filters to initial values
2695
+ ActionType["ResetFilters"] = "reset-filters";
2696
+ // Choose a filter drawer to toggle
2697
+ ActionType["ToggleFilterDrawer"] = "toggle-filter-drawer";
2698
+ // Hide filter drawer
2699
+ ActionType["HideFilterDrawer"] = "hide-filter-drawer";
2700
+ // Handle the date filter state
2701
+ ActionType["UpdateDateFilterState"] = "update-date-filter-state";
2702
+ // Update the context filter state
2703
+ ActionType["UpdateContextFilterState"] = "update-context-filter-state";
2704
+ // Update the tag filter state
2705
+ ActionType["UpdateTagFilterState"] = "update-tag-filter-state";
2706
+ // Update the action and error filter state
2707
+ ActionType["UpdateActionErrorFilterState"] = "update-action-error-filter-state";
2708
+ // Update the advanced filter state
2709
+ ActionType["UpdateAdvancedFilterState"] = "update-advanced-filter-state";
2710
+ })(ActionType || (ActionType = {}));
2711
+ /**
2712
+ * Reducer that executes actions
2713
+ * @author Gabe Abrams
2714
+ * @param state current state
2715
+ * @param action action to execute
2716
+ */
2717
+ const reducer = (state, action) => {
2718
+ switch (action.type) {
2719
+ case ActionType.StartLoading: {
2720
+ return Object.assign(Object.assign({}, state), { loading: true });
2721
+ }
2722
+ case ActionType.FinishLoading: {
2723
+ return Object.assign(Object.assign({}, state), { loading: false, logMap: action.logMap });
2724
+ }
2725
+ case ActionType.ToggleFilterDrawer: {
2726
+ return Object.assign(Object.assign({}, state), { expandedFilterDrawer: (state.expandedFilterDrawer === action.filterDrawer
2727
+ ? undefined // hide
2728
+ : action.filterDrawer) });
2729
+ }
2730
+ case ActionType.HideFilterDrawer: {
2731
+ return Object.assign(Object.assign({}, state), { expandedFilterDrawer: undefined });
2732
+ }
2733
+ case ActionType.ResetFilters: {
2734
+ return Object.assign(Object.assign({}, state), { dateFilterState: action.initDateFilterState, contextFilterState: action.initContextFilterState, tagFilterState: action.initTagFilterState, actionErrorFilterState: action.initActionErrorFilterState, advancedFilterState: action.initAdvancedFilterState });
2735
+ }
2736
+ case ActionType.UpdateDateFilterState: {
2737
+ return Object.assign(Object.assign({}, state), { dateFilterState: action.dateFilterState });
2738
+ }
2739
+ case ActionType.UpdateContextFilterState: {
2740
+ return Object.assign(Object.assign({}, state), { contextFilterState: action.contextFilterState });
2741
+ }
2742
+ case ActionType.UpdateTagFilterState: {
2743
+ return Object.assign(Object.assign({}, state), { tagFilterState: action.tagFilterState });
2744
+ }
2745
+ case ActionType.UpdateActionErrorFilterState: {
2746
+ return Object.assign(Object.assign({}, state), { actionErrorFilterState: action.actionErrorFilterState });
2747
+ }
2748
+ case ActionType.UpdateAdvancedFilterState: {
2749
+ return Object.assign(Object.assign({}, state), { advancedFilterState: action.advancedFilterState });
2750
+ }
2751
+ default: {
2752
+ return state;
2753
+ }
2754
+ }
2755
+ };
2756
+ /*------------------------------------------------------------------------*/
2757
+ /* Component */
2758
+ /*------------------------------------------------------------------------*/
2759
+ const LogReviewer = (props) => {
2760
+ /*------------------------------------------------------------------------*/
2761
+ /* Setup */
2762
+ /*------------------------------------------------------------------------*/
2763
+ var _a, _b, _c, _d, _e, _f;
2764
+ /* -------------- Props ------------- */
2765
+ // Destructure props
2766
+ const { LogMetadata, onClose, } = props;
2767
+ /* -------------- State ------------- */
2768
+ // Create initial date filter state
2769
+ const today = getTimeInfoInET();
2770
+ const initStartDate = {
2771
+ year: today.year,
2772
+ month: today.month,
2773
+ day: 1,
2774
+ };
2775
+ const initEndDate = {
2776
+ year: today.year,
2777
+ month: today.month,
2778
+ day: today.day,
2779
+ };
2780
+ const initDateFilterState = {
2781
+ startDate: initStartDate,
2782
+ endDate: initEndDate,
2783
+ };
2784
+ // Create initial context filter state
2785
+ const initContextFilterState = {};
2786
+ Object.keys((_a = LogMetadata.Context) !== null && _a !== void 0 ? _a : {}).forEach((context) => {
2787
+ var _a, _b;
2788
+ const contextValue = ((_a = LogMetadata.Context) !== null && _a !== void 0 ? _a : {})[context];
2789
+ if (typeof contextValue === 'string') {
2790
+ // Case: no subcontexts
2791
+ initContextFilterState[contextValue] = true;
2792
+ }
2793
+ else {
2794
+ // Case: subcontexts exist
2795
+ initContextFilterState[contextValue._] = {};
2796
+ Object.values(((_b = LogMetadata.Context) !== null && _b !== void 0 ? _b : {})[context]).forEach((subcontext) => {
2797
+ const subcontextValue = contextValue[subcontext];
2798
+ initContextFilterState[contextValue._][subcontextValue] = true;
2799
+ });
2800
+ }
2801
+ });
2802
+ // Create initial tag filter state
2803
+ const initTagFilterState = {};
2804
+ Object.values((_b = LogMetadata.Tag) !== null && _b !== void 0 ? _b : {}).forEach((tagValue) => {
2805
+ initTagFilterState[tagValue] = true;
2806
+ });
2807
+ // Create advanced filter state
2808
+ const initAdvancedFilterState = {
2809
+ userFirstName: '',
2810
+ userLastName: '',
2811
+ userEmail: '',
2812
+ userId: '',
2813
+ includeLearners: true,
2814
+ includeTTMs: true,
2815
+ includeAdmins: true,
2816
+ courseId: '',
2817
+ courseName: '',
2818
+ isMobile: undefined,
2819
+ source: undefined,
2820
+ routePath: '',
2821
+ routeTemplate: '',
2822
+ };
2823
+ // Create action and error filter state
2824
+ const initActionErrorFilterState = {
2825
+ type: undefined,
2826
+ errorMessage: '',
2827
+ errorCode: '',
2828
+ target: {},
2829
+ action: {},
2830
+ };
2831
+ Object.values((_c = LogMetadata.Target) !== null && _c !== void 0 ? _c : {}).forEach((target) => {
2832
+ initActionErrorFilterState.target[target] = true;
2833
+ });
2834
+ Object.values(LogAction$1).forEach((action) => {
2835
+ initActionErrorFilterState.action[action] = true;
2836
+ });
2837
+ // Initial state
2838
+ const initialState = {
2839
+ loading: true,
2840
+ logMap: {},
2841
+ expandedFilterDrawer: undefined,
2842
+ dateFilterState: initDateFilterState,
2843
+ contextFilterState: initContextFilterState,
2844
+ tagFilterState: initTagFilterState,
2845
+ actionErrorFilterState: initActionErrorFilterState,
2846
+ advancedFilterState: initAdvancedFilterState,
2847
+ };
2848
+ // Initialize state
2849
+ const [state, dispatch] = React.useReducer(reducer, initialState);
2850
+ // Destructure common state
2851
+ const { loading, logMap, expandedFilterDrawer, dateFilterState, contextFilterState, tagFilterState, actionErrorFilterState, advancedFilterState, } = state;
2852
+ /*------------------------------------------------------------------------*/
2853
+ /* Component Functions */
2854
+ /*------------------------------------------------------------------------*/
2855
+ /**
2856
+ * Get the list of year/month combos that need to be loaded given a new
2857
+ * start or end date and the existing logMap
2858
+ * @author Gabe Abrams
2859
+ * @param newDateFilterState the new date filter state
2860
+ * @returns list of year/month combos that need to be loaded
2861
+ */
2862
+ const listMonthsToLoad = (newDateFilterState) => {
2863
+ // List of year/month combos that need to be loaded
2864
+ const toLoad = [];
2865
+ // Loop through dates
2866
+ let year = newDateFilterState.startDate.year;
2867
+ let month = newDateFilterState.startDate.month;
2868
+ while (
2869
+ // Earlier year
2870
+ (year <= newDateFilterState.endDate.year)
2871
+ // Current year but included month
2872
+ || (year === newDateFilterState.endDate.year
2873
+ && month <= newDateFilterState.endDate.month)) {
2874
+ // Add to list
2875
+ toLoad.push({
2876
+ year,
2877
+ month,
2878
+ });
2879
+ // Increment
2880
+ month += 1;
2881
+ if (month > 12) {
2882
+ month -= 12;
2883
+ year += 1;
2884
+ }
2885
+ }
2886
+ // Return
2887
+ return toLoad;
2888
+ };
2889
+ /**
2890
+ * Handle updated start/end dates (updates state, loads if necessary)
2891
+ * @author Gabe Abrams
2892
+ * @param newDateFilterState the new date filter state
2893
+ */
2894
+ const handleDateRangeUpdated = (newDateFilterState) => __awaiter(void 0, void 0, void 0, function* () {
2895
+ // Update state
2896
+ dispatch({
2897
+ type: ActionType.UpdateDateFilterState,
2898
+ dateFilterState: newDateFilterState,
2899
+ });
2900
+ // Check which year/month combos we need to load
2901
+ const toLoad = listMonthsToLoad(newDateFilterState);
2902
+ // If nothing to load, finished
2903
+ if (toLoad.length === 0) {
2904
+ return;
2905
+ }
2906
+ // Start loading
2907
+ dispatch({
2908
+ type: ActionType.StartLoading,
2909
+ });
2910
+ // Load required months
2911
+ try {
2912
+ for (let i = 0; i < toLoad.length; i++) {
2913
+ // Destructure
2914
+ const { year, month } = toLoad[i];
2915
+ // Load
2916
+ const logs = yield visitServerEndpoint({
2917
+ path: `${LOG_REVIEW_ROUTE_PATH_PREFIX}/years/${year}/months/${month}`,
2918
+ method: 'GET',
2919
+ });
2920
+ // Add to map
2921
+ if (!logMap[year]) {
2922
+ logMap[year] = {};
2923
+ }
2924
+ logMap[year][month] = logs;
2925
+ }
2926
+ }
2927
+ catch (err) {
2928
+ return showFatalError(err);
2929
+ }
2930
+ // Finish loading
2931
+ dispatch({
2932
+ type: ActionType.FinishLoading,
2933
+ logMap,
2934
+ });
2935
+ });
2936
+ /*------------------------------------------------------------------------*/
2937
+ /* Lifecycle Functions */
2938
+ /*------------------------------------------------------------------------*/
2939
+ /**
2940
+ * Mount
2941
+ * @author Gabe Abrams
2942
+ */
2943
+ React.useEffect(() => {
2944
+ // Perform initial load
2945
+ handleDateRangeUpdated(dateFilterState);
2946
+ }, []);
2947
+ /*------------------------------------------------------------------------*/
2948
+ /* Render */
2949
+ /*------------------------------------------------------------------------*/
2950
+ /*----------------------------------------*/
2951
+ /* Main UI */
2952
+ /*----------------------------------------*/
2953
+ // Body that will be filled with the contents of the panel
2954
+ let body;
2955
+ /* ------------- Loading ------------ */
2956
+ if (loading) {
2957
+ body = (React__default["default"].createElement("div", { className: "text-center p-5" },
2958
+ React__default["default"].createElement(LoadingSpinner, null)));
2959
+ }
2960
+ /* ------------ Review UI ----------- */
2961
+ if (!loading) {
2962
+ /*----------------------------------------*/
2963
+ /* Filters */
2964
+ /*----------------------------------------*/
2965
+ // Filter toggle
2966
+ const filterToggles = (React__default["default"].createElement("div", { className: "LogReviewer-filter-toggles" },
2967
+ React__default["default"].createElement("h3", { className: "m-0" }, "Filters:"),
2968
+ React__default["default"].createElement("div", { className: "LogReviewer-filter-toggle-buttons alert alert-secondary p-2 m-0" },
2969
+ React__default["default"].createElement("button", { type: "button", id: "LogReviewer-toggle-date-filter-drawer", className: `btn btn-${FilterDrawer.Date === expandedFilterDrawer ? 'warning' : 'light'} me-2`, "aria-label": "toggle date filter drawer", onClick: () => {
2970
+ dispatch({
2971
+ type: ActionType.ToggleFilterDrawer,
2972
+ filterDrawer: FilterDrawer.Date,
2973
+ });
2974
+ } },
2975
+ React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faCalendar, className: "me-2" }),
2976
+ "Date"),
2977
+ React__default["default"].createElement("button", { type: "button", id: "LogReviewer-toggle-context-filter-drawer", className: `btn btn-${FilterDrawer.Context === expandedFilterDrawer ? 'warning' : 'light'} me-2`, "aria-label": "toggle context filter drawer", onClick: () => {
2978
+ dispatch({
2979
+ type: ActionType.ToggleFilterDrawer,
2980
+ filterDrawer: FilterDrawer.Context,
2981
+ });
2982
+ } },
2983
+ React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faCircle, className: "me-2" }),
2984
+ "Context"),
2985
+ (LogMetadata.Tag && Object.keys(LogMetadata.Tag).length > 0) && (React__default["default"].createElement("button", { type: "button", id: "LogReviewer-toggle-tag-filter-drawer", className: `btn btn-${FilterDrawer.Tag === expandedFilterDrawer ? 'warning' : 'light'} me-2`, "aria-label": "toggle tag filter drawer", onClick: () => {
2986
+ dispatch({
2987
+ type: ActionType.ToggleFilterDrawer,
2988
+ filterDrawer: FilterDrawer.Tag,
2989
+ });
2990
+ } },
2991
+ React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faTag, className: "me-2" }),
2992
+ "Tag")),
2993
+ React__default["default"].createElement("button", { type: "button", id: "LogReviewer-toggle-action-filter-drawer", className: `btn btn-${FilterDrawer.Action === expandedFilterDrawer ? 'warning' : 'light'} me-2`, "aria-label": "toggle action and error filter drawer", onClick: () => {
2994
+ dispatch({
2995
+ type: ActionType.ToggleFilterDrawer,
2996
+ filterDrawer: FilterDrawer.Action,
2997
+ });
2998
+ } },
2999
+ React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faHammer, className: "me-2" }),
3000
+ "Action"),
3001
+ React__default["default"].createElement("button", { type: "button", id: "LogReviewer-toggle-advanced-filter-drawer", className: `btn btn-${FilterDrawer.Advanced === expandedFilterDrawer ? 'warning' : 'light'}`, "aria-label": "toggle advanced filter drawer", onClick: () => {
3002
+ dispatch({
3003
+ type: ActionType.ToggleFilterDrawer,
3004
+ filterDrawer: FilterDrawer.Advanced,
3005
+ });
3006
+ } },
3007
+ React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faList, className: "me-2" }),
3008
+ "Advanced"))));
3009
+ // Filter drawer
3010
+ let filterDrawer;
3011
+ if (expandedFilterDrawer) {
3012
+ if (expandedFilterDrawer === FilterDrawer.Date) {
3013
+ filterDrawer = (React__default["default"].createElement(TabBox, { title: "Date" },
3014
+ React__default["default"].createElement(SimpleDateChooser, { ariaLabel: "filter start date", name: "filter-start-date", year: dateFilterState.startDate.year, month: dateFilterState.startDate.month, day: dateFilterState.startDate.day, onChange: (month, day, year) => {
3015
+ dispatch({
3016
+ type: ActionType.UpdateDateFilterState,
3017
+ dateFilterState: Object.assign(Object.assign({}, dateFilterState), { startDate: { month, day, year } }),
3018
+ });
3019
+ } }),
3020
+ ' ',
3021
+ "to",
3022
+ ' ',
3023
+ React__default["default"].createElement(SimpleDateChooser, { ariaLabel: "filter end date", name: "filter-end-date", year: dateFilterState.endDate.year, month: dateFilterState.endDate.month, day: dateFilterState.endDate.day, onChange: (month, day, year) => {
3024
+ dispatch({
3025
+ type: ActionType.UpdateDateFilterState,
3026
+ dateFilterState: Object.assign(Object.assign({}, dateFilterState), { endDate: { month, day, year } }),
3027
+ });
3028
+ } })));
3029
+ }
3030
+ else if (expandedFilterDrawer === FilterDrawer.Context) {
3031
+ // Create item picker items
3032
+ const pickableItems = (Object.keys((_d = LogMetadata.Context) !== null && _d !== void 0 ? _d : {})
3033
+ .map((context) => {
3034
+ var _a;
3035
+ const value = ((_a = LogMetadata.Context) !== null && _a !== void 0 ? _a : {})[context];
3036
+ if (typeof value === 'string') {
3037
+ // No subcategories
3038
+ const item = {
3039
+ id: context,
3040
+ name: genHumanReadableName(context),
3041
+ isGroup: false,
3042
+ checked: !!contextFilterState[context],
3043
+ };
3044
+ return item;
3045
+ }
3046
+ // Has subcategories
3047
+ const children = (Object.keys(value)
3048
+ .filter((subcontext) => {
3049
+ return subcontext !== '_';
3050
+ })
3051
+ .map((subcontext) => {
3052
+ return {
3053
+ id: `${context}-${subcontext}`,
3054
+ name: genHumanReadableName(subcontext),
3055
+ isGroup: false,
3056
+ checked: !!value[subcontext],
3057
+ };
3058
+ }));
3059
+ const item = {
3060
+ id: context,
3061
+ name: context,
3062
+ isGroup: true,
3063
+ children,
3064
+ };
3065
+ return item;
3066
+ }));
3067
+ // Create filter UI
3068
+ filterDrawer = (React__default["default"].createElement(ItemPicker, { title: "Context", items: pickableItems, onChanged: (updatedItems) => {
3069
+ // Update our state
3070
+ updatedItems.forEach((pickableItem) => {
3071
+ if (pickableItem.isGroup) {
3072
+ // Has subcontexts
3073
+ pickableItem.children.forEach((subcontextItem) => {
3074
+ contextFilterState[pickableItem.id][subcontextItem.id] = (subcontextItem.checked);
3075
+ });
3076
+ }
3077
+ else {
3078
+ // No subcontexts
3079
+ contextFilterState[pickableItem.id] = (pickableItem.checked);
3080
+ }
3081
+ });
3082
+ dispatch({
3083
+ type: ActionType.UpdateContextFilterState,
3084
+ contextFilterState,
3085
+ });
3086
+ } }));
3087
+ }
3088
+ else if (expandedFilterDrawer === FilterDrawer.Tag) {
3089
+ // Create filter UI
3090
+ filterDrawer = (React__default["default"].createElement(TabBox, { title: "Tags" }, Object.keys(tagFilterState)
3091
+ .map((tag, i) => {
3092
+ const description = genHumanReadableName(tag);
3093
+ return (React__default["default"].createElement(CheckboxButton, { id: `LogReviewer-tag-${tag}-checkbox`, text: description, ariaLabel: `include logs tagged with "${description}" in results`, noMarginOnRight: i === Object.keys(tagFilterState).length - 1, onChanged: (checked) => {
3094
+ tagFilterState[tag] = checked;
3095
+ } }));
3096
+ })));
3097
+ }
3098
+ else if (expandedFilterDrawer === FilterDrawer.Action) {
3099
+ // Create filter UI
3100
+ filterDrawer = (React__default["default"].createElement(React__default["default"].Fragment, null,
3101
+ React__default["default"].createElement(TabBox, { title: "Log Type" },
3102
+ React__default["default"].createElement(RadioButton, { id: "LogReviewer-type-all", text: "All Logs", onSelected: () => {
3103
+ actionErrorFilterState.type = undefined;
3104
+ dispatch({
3105
+ type: ActionType.UpdateActionErrorFilterState,
3106
+ actionErrorFilterState,
3107
+ });
3108
+ }, ariaLabel: "show logs of all types", selected: actionErrorFilterState.type === undefined }),
3109
+ React__default["default"].createElement(RadioButton, { id: "LogReviewer-type-action-only", text: "Action Logs Only", onSelected: () => {
3110
+ actionErrorFilterState.type = LogType$1.Action;
3111
+ dispatch({
3112
+ type: ActionType.UpdateActionErrorFilterState,
3113
+ actionErrorFilterState,
3114
+ });
3115
+ }, ariaLabel: "only show action logs", selected: actionErrorFilterState.type === LogType$1.Action }),
3116
+ React__default["default"].createElement(RadioButton, { id: "LogReviewer-type-error-only", text: "Action Error Only", onSelected: () => {
3117
+ actionErrorFilterState.type = LogType$1.Error;
3118
+ dispatch({
3119
+ type: ActionType.UpdateActionErrorFilterState,
3120
+ actionErrorFilterState,
3121
+ });
3122
+ }, ariaLabel: "only show error logs", selected: actionErrorFilterState.type === LogType$1.Error, noMarginOnRight: true })),
3123
+ (actionErrorFilterState.type === undefined
3124
+ || actionErrorFilterState.type === LogType$1.Action) && (React__default["default"].createElement(TabBox, { title: "Action Log Details" },
3125
+ React__default["default"].createElement(ButtonInputGroup, { label: "Action", className: "mb-2" }, Object.keys(LogAction$1)
3126
+ .map((action, i) => {
3127
+ const description = genHumanReadableName(action);
3128
+ return (React__default["default"].createElement(CheckboxButton, { id: `LogReviewer-action-${action}-checkbox`, text: description, ariaLabel: `include logs with action type "${description}" in results`, noMarginOnRight: i === Object.keys(LogAction$1).length - 1, onChanged: (checked) => {
3129
+ actionErrorFilterState.action[action] = checked;
3130
+ dispatch({
3131
+ type: ActionType.UpdateActionErrorFilterState,
3132
+ actionErrorFilterState,
3133
+ });
3134
+ } }));
3135
+ })),
3136
+ React__default["default"].createElement(ButtonInputGroup, { label: "Target" },
3137
+ (Object.keys((_e = LogMetadata.Target) !== null && _e !== void 0 ? _e : {}).length === 0) && (React__default["default"].createElement("div", null, "This app does not have any targets yet.")),
3138
+ Object.keys((_f = LogMetadata.Target) !== null && _f !== void 0 ? _f : {})
3139
+ .map((target, i) => {
3140
+ var _a;
3141
+ const description = genHumanReadableName(target);
3142
+ return (React__default["default"].createElement(CheckboxButton, { id: `LogReviewer-target-${target}-checkbox`, text: description, ariaLabel: `include logs with target "${description}" in results`, onChanged: (checked) => {
3143
+ actionErrorFilterState.target[target] = checked;
3144
+ dispatch({
3145
+ type: ActionType.UpdateActionErrorFilterState,
3146
+ actionErrorFilterState,
3147
+ });
3148
+ }, noMarginOnRight: i === Object.keys((_a = LogMetadata.Target) !== null && _a !== void 0 ? _a : {}).length - 1 }));
3149
+ })))),
3150
+ (actionErrorFilterState.type === undefined
3151
+ || actionErrorFilterState.type === LogType$1.Error) && (React__default["default"].createElement(TabBox, { title: "Error Log Details" },
3152
+ React__default["default"].createElement("div", { className: "input-group mb-2" },
3153
+ React__default["default"].createElement("span", { className: "input-group-text" }, "Error Message"),
3154
+ React__default["default"].createElement("input", { type: "text", className: "form-control", "aria-label": "query for error message", value: actionErrorFilterState.errorMessage, onChange: (e) => {
3155
+ actionErrorFilterState.errorMessage = e.target.value;
3156
+ dispatch({
3157
+ type: ActionType.UpdateActionErrorFilterState,
3158
+ actionErrorFilterState,
3159
+ });
3160
+ } })),
3161
+ React__default["default"].createElement("div", { className: "input-group mb-2" },
3162
+ React__default["default"].createElement("span", { className: "input-group-text" }, "Error Code"),
3163
+ React__default["default"].createElement("input", { type: "text", className: "form-control", "aria-label": "query for error code", value: actionErrorFilterState.errorMessage, onChange: (e) => {
3164
+ actionErrorFilterState.errorCode = ((e.target.value)
3165
+ .trim()
3166
+ .toUpperCase());
3167
+ dispatch({
3168
+ type: ActionType.UpdateActionErrorFilterState,
3169
+ actionErrorFilterState,
3170
+ });
3171
+ } }))))));
3172
+ }
3173
+ else if (expandedFilterDrawer === FilterDrawer.Advanced) {
3174
+ // Create advanced filter ui
3175
+ filterDrawer = (React__default["default"].createElement(React__default["default"].Fragment, null,
3176
+ React__default["default"].createElement(TabBox, { title: "User Info" },
3177
+ React__default["default"].createElement("div", { className: "input-group mb-2" },
3178
+ React__default["default"].createElement("span", { className: "input-group-text" }, "User First Name"),
3179
+ React__default["default"].createElement("input", { type: "text", className: "form-control", "aria-label": "query for user first name", value: advancedFilterState.userFirstName, onChange: (e) => {
3180
+ advancedFilterState.userFirstName = e.target.value;
3181
+ dispatch({
3182
+ type: ActionType.UpdateAdvancedFilterState,
3183
+ advancedFilterState,
3184
+ });
3185
+ } })),
3186
+ React__default["default"].createElement("div", { className: "input-group mb-2" },
3187
+ React__default["default"].createElement("span", { className: "input-group-text" }, "User Last Name"),
3188
+ React__default["default"].createElement("input", { type: "text", className: "form-control", "aria-label": "query for user last name", value: advancedFilterState.userLastName, onChange: (e) => {
3189
+ advancedFilterState.userLastName = e.target.value;
3190
+ dispatch({
3191
+ type: ActionType.UpdateAdvancedFilterState,
3192
+ advancedFilterState,
3193
+ });
3194
+ } })),
3195
+ React__default["default"].createElement("div", { className: "input-group mb-2" },
3196
+ React__default["default"].createElement("span", { className: "input-group-text" }, "User Email"),
3197
+ React__default["default"].createElement("input", { type: "text", className: "form-control", "aria-label": "query for user email", value: advancedFilterState.userEmail, onChange: (e) => {
3198
+ advancedFilterState.userEmail = ((e.target.value)
3199
+ .trim());
3200
+ dispatch({
3201
+ type: ActionType.UpdateAdvancedFilterState,
3202
+ advancedFilterState,
3203
+ });
3204
+ } })),
3205
+ React__default["default"].createElement("div", { className: "input-group mb-2" },
3206
+ React__default["default"].createElement("span", { className: "input-group-text" }, "User Canvas Id"),
3207
+ React__default["default"].createElement("input", { type: "text", className: "form-control", "aria-label": "query for user canvas id", value: advancedFilterState.userId, onChange: (e) => {
3208
+ const { value } = e.target;
3209
+ // Only update if value contains only numbers
3210
+ if (/^\d+$/.test(value)) {
3211
+ advancedFilterState.userId = ((e.target.value)
3212
+ .trim());
3213
+ }
3214
+ dispatch({
3215
+ type: ActionType.UpdateAdvancedFilterState,
3216
+ advancedFilterState,
3217
+ });
3218
+ } })),
3219
+ React__default["default"].createElement(ButtonInputGroup, { label: "Role" },
3220
+ React__default["default"].createElement(CheckboxButton, { text: "Students", onChanged: (checked) => {
3221
+ advancedFilterState.includeLearners = checked;
3222
+ dispatch({
3223
+ type: ActionType.UpdateAdvancedFilterState,
3224
+ advancedFilterState,
3225
+ });
3226
+ }, checked: advancedFilterState.includeLearners, ariaLabel: "show logs from students" }),
3227
+ React__default["default"].createElement(CheckboxButton, { text: "Teaching Team Members", onChanged: (checked) => {
3228
+ advancedFilterState.includeTTMs = checked;
3229
+ dispatch({
3230
+ type: ActionType.UpdateAdvancedFilterState,
3231
+ advancedFilterState,
3232
+ });
3233
+ }, checked: advancedFilterState.includeTTMs, ariaLabel: "show logs from teaching team members" }),
3234
+ React__default["default"].createElement(CheckboxButton, { text: "Admins", onChanged: (checked) => {
3235
+ advancedFilterState.includeAdmins = checked;
3236
+ dispatch({
3237
+ type: ActionType.UpdateAdvancedFilterState,
3238
+ advancedFilterState,
3239
+ });
3240
+ }, checked: advancedFilterState.includeAdmins, ariaLabel: "show logs from admins" }))),
3241
+ React__default["default"].createElement(TabBox, { title: "Course Info" },
3242
+ React__default["default"].createElement("div", { className: "input-group mb-2" },
3243
+ React__default["default"].createElement("span", { className: "input-group-text" }, "Course Name"),
3244
+ React__default["default"].createElement("input", { type: "text", className: "form-control", "aria-label": "query for course name", value: advancedFilterState.courseName, onChange: (e) => {
3245
+ advancedFilterState.courseName = e.target.value;
3246
+ dispatch({
3247
+ type: ActionType.UpdateAdvancedFilterState,
3248
+ advancedFilterState,
3249
+ });
3250
+ } })),
3251
+ React__default["default"].createElement("div", { className: "input-group mb-2" },
3252
+ React__default["default"].createElement("span", { className: "input-group-text" }, "Course Canvas Id"),
3253
+ React__default["default"].createElement("input", { type: "text", className: "form-control", "aria-label": "query for course canvas id", value: advancedFilterState.courseId, onChange: (e) => {
3254
+ const { value } = e.target;
3255
+ // Only update if value contains only numbers
3256
+ if (/^\d+$/.test(value)) {
3257
+ advancedFilterState.courseId = ((e.target.value)
3258
+ .trim());
3259
+ }
3260
+ dispatch({
3261
+ type: ActionType.UpdateAdvancedFilterState,
3262
+ advancedFilterState,
3263
+ });
3264
+ } }))),
3265
+ React__default["default"].createElement(TabBox, { title: "Device Info" },
3266
+ React__default["default"].createElement(ButtonInputGroup, { label: "Device Type" },
3267
+ React__default["default"].createElement(RadioButton, { text: "All Devices", ariaLabel: "show logs from all devices", selected: advancedFilterState.isMobile === undefined, onSelected: () => {
3268
+ advancedFilterState.isMobile = undefined;
3269
+ dispatch({
3270
+ type: ActionType.UpdateAdvancedFilterState,
3271
+ advancedFilterState,
3272
+ });
3273
+ } }),
3274
+ React__default["default"].createElement(RadioButton, { text: "Mobile Only", ariaLabel: "show logs from mobile devices", selected: advancedFilterState.isMobile === true, onSelected: () => {
3275
+ advancedFilterState.isMobile = true;
3276
+ dispatch({
3277
+ type: ActionType.UpdateAdvancedFilterState,
3278
+ advancedFilterState,
3279
+ });
3280
+ } }),
3281
+ React__default["default"].createElement(RadioButton, { text: "Desktop Only", ariaLabel: "show logs from desktop devices", selected: advancedFilterState.isMobile === false, onSelected: () => {
3282
+ advancedFilterState.isMobile = false;
3283
+ dispatch({
3284
+ type: ActionType.UpdateAdvancedFilterState,
3285
+ advancedFilterState,
3286
+ });
3287
+ }, noMarginOnRight: true }))),
3288
+ React__default["default"].createElement(TabBox, { title: "Source" },
3289
+ React__default["default"].createElement(ButtonInputGroup, { label: "Source Type" },
3290
+ React__default["default"].createElement(RadioButton, { text: "Both", ariaLabel: "show logs from all sources", selected: advancedFilterState.source === undefined, onSelected: () => {
3291
+ advancedFilterState.source = undefined;
3292
+ dispatch({
3293
+ type: ActionType.UpdateAdvancedFilterState,
3294
+ advancedFilterState,
3295
+ });
3296
+ } }),
3297
+ React__default["default"].createElement(RadioButton, { text: "Client Only", ariaLabel: "show logs from client source", selected: advancedFilterState.source === LogSource$1.Client, onSelected: () => {
3298
+ advancedFilterState.source = LogSource$1.Client;
3299
+ dispatch({
3300
+ type: ActionType.UpdateAdvancedFilterState,
3301
+ advancedFilterState,
3302
+ });
3303
+ } }),
3304
+ React__default["default"].createElement(RadioButton, { text: "Server Only", ariaLabel: "show logs from server source", selected: advancedFilterState.source === LogSource$1.Server, onSelected: () => {
3305
+ advancedFilterState.source = LogSource$1.Server;
3306
+ dispatch({
3307
+ type: ActionType.UpdateAdvancedFilterState,
3308
+ advancedFilterState,
3309
+ });
3310
+ }, noMarginOnRight: true })),
3311
+ advancedFilterState.source !== LogSource$1.Client && (React__default["default"].createElement("div", { className: "mt-2" },
3312
+ React__default["default"].createElement("div", { className: "input-group mb-2" },
3313
+ React__default["default"].createElement("span", { className: "input-group-text" }, "Server Route Path"),
3314
+ React__default["default"].createElement("input", { type: "text", className: "form-control", "aria-label": "query for server route path", placeholder: "e.g. /api/ttm/courses/12345", value: advancedFilterState.routePath, onChange: (e) => {
3315
+ advancedFilterState.courseName = ((e.target.value)
3316
+ .trim());
3317
+ dispatch({
3318
+ type: ActionType.UpdateAdvancedFilterState,
3319
+ advancedFilterState,
3320
+ });
3321
+ } })),
3322
+ React__default["default"].createElement("div", { className: "input-group mb-2" },
3323
+ React__default["default"].createElement("span", { className: "input-group-text" }, "Server Route Template"),
3324
+ React__default["default"].createElement("input", { type: "text", className: "form-control", "aria-label": "query for server route template", value: advancedFilterState.routeTemplate, placeholder: "e.g. /api/ttm/courses/:courseId", onChange: (e) => {
3325
+ advancedFilterState.courseName = ((e.target.value)
3326
+ .trim());
3327
+ dispatch({
3328
+ type: ActionType.UpdateAdvancedFilterState,
3329
+ advancedFilterState,
3330
+ });
3331
+ } })))))));
3332
+ }
3333
+ }
3334
+ // Filters UI
3335
+ const filters = (React__default["default"].createElement(React__default["default"].Fragment, null,
3336
+ filterToggles,
3337
+ filterDrawer && (React__default["default"].createElement(Drawer, null, filterDrawer))));
3338
+ // Actually filter the logs
3339
+ // > Perform filters
3340
+ const logs = [];
3341
+ Object.keys(logMap).forEach((year) => {
3342
+ Object.keys(logMap[year]).forEach((month) => {
3343
+ logMap[year][month].forEach((log) => {
3344
+ /* ----------- Date Filter ---------- */
3345
+ var _a;
3346
+ // Before start date
3347
+ if (
3348
+ // Previous year
3349
+ log.year < dateFilterState.startDate.year
3350
+ // Same year, earlier month
3351
+ || ((log.year === dateFilterState.startDate.year)
3352
+ && (log.month < dateFilterState.startDate.month))
3353
+ // Same year, same month, earlier day
3354
+ || ((log.year === dateFilterState.startDate.year)
3355
+ && (log.month === dateFilterState.startDate.month)
3356
+ && (log.day < dateFilterState.startDate.day))) {
3357
+ return;
3358
+ }
3359
+ // After end date
3360
+ if (
3361
+ // Later year
3362
+ log.year > dateFilterState.endDate.year
3363
+ // Same year, later month
3364
+ || ((log.year === dateFilterState.endDate.year)
3365
+ && (log.month > dateFilterState.endDate.month))
3366
+ // Same year, same month, later day
3367
+ || ((log.year === dateFilterState.endDate.year)
3368
+ && (log.month === dateFilterState.endDate.month)
3369
+ && (log.day > dateFilterState.endDate.day))) {
3370
+ return;
3371
+ }
3372
+ /* --------- Context Filter --------- */
3373
+ // Context doesn't match
3374
+ if (
3375
+ // Whole context is deselected
3376
+ contextFilterState[log.context] === false
3377
+ // None of the subcontexts are selected
3378
+ || (Object.values((_a = contextFilterState[log.context]) !== null && _a !== void 0 ? _a : {})
3379
+ .every((isSelected) => {
3380
+ return !isSelected;
3381
+ }))) {
3382
+ return;
3383
+ }
3384
+ // Subcontext doesn't match
3385
+ if (
3386
+ // Log has a subcontext
3387
+ log.subcontext
3388
+ // Context has subcontexts
3389
+ && (contextFilterState[log.context]
3390
+ && contextFilterState[log.context] !== false
3391
+ && contextFilterState[log.context] !== true)
3392
+ // Subcontext is not selected
3393
+ && !contextFilterState[log.context][log.subcontext]) {
3394
+ return;
3395
+ }
3396
+ /* -------------- Tags -------------- */
3397
+ // No tags match
3398
+ if (
3399
+ // Log has at least one tag
3400
+ log.tags.length > 0
3401
+ // No tags match
3402
+ && (log.tags.every((tag) => {
3403
+ return !tagFilterState[tag];
3404
+ }))) {
3405
+ return;
3406
+ }
3407
+ /* ------- Actions and Errors ------- */
3408
+ // Log type doesn't match
3409
+ if (
3410
+ // Filter won't allow all types
3411
+ actionErrorFilterState.type !== undefined
3412
+ // Log type doesn't match
3413
+ && actionErrorFilterState.type !== log.type) {
3414
+ return;
3415
+ }
3416
+ // Filter errors
3417
+ if (log.type === LogType$1.Error) {
3418
+ // Message doesn't match
3419
+ if (
3420
+ // Message exists
3421
+ log.errorMessage
3422
+ // Message filter exists
3423
+ && actionErrorFilterState.errorMessage.trim().length > 0
3424
+ // Message doesn't match
3425
+ && log.errorMessage.toLowerCase().includes(actionErrorFilterState.errorMessage.trim().toLowerCase())) {
3426
+ return;
3427
+ }
3428
+ // Code doesn't match
3429
+ if (
3430
+ // Code exists
3431
+ log.errorCode
3432
+ // Code filter exists
3433
+ && actionErrorFilterState.errorCode.trim().length > 0
3434
+ // Code doesn't match
3435
+ && log.errorCode.toUpperCase().includes(actionErrorFilterState.errorCode.trim().toUpperCase())) {
3436
+ return;
3437
+ }
3438
+ }
3439
+ // Filter actions
3440
+ if (log.type === LogType$1.Action) {
3441
+ // Target isn't selected
3442
+ if (
3443
+ // Target exists
3444
+ log.target
3445
+ // Target isn't selected
3446
+ && !actionErrorFilterState.target[log.target]) {
3447
+ return;
3448
+ }
3449
+ // Action
3450
+ if (
3451
+ // Action exists
3452
+ log.action
3453
+ // Action isn't selected
3454
+ && !actionErrorFilterState.action[log.action]) {
3455
+ return;
3456
+ }
3457
+ }
3458
+ /* --------- Advanced Filter -------- */
3459
+ // First name doesn't match
3460
+ if (
3461
+ // First name exists
3462
+ log.userFirstName
3463
+ // First name query doesn't match
3464
+ && !log.userFirstName.toLowerCase().includes(advancedFilterState.userFirstName.toLowerCase().trim())) {
3465
+ return;
3466
+ }
3467
+ // Last name doesn't match
3468
+ if (
3469
+ // Last name exists
3470
+ log.userLastName
3471
+ // Last name query doesn't match
3472
+ && !log.userLastName.toLowerCase().includes(advancedFilterState.userLastName.toLowerCase().trim())) {
3473
+ return;
3474
+ }
3475
+ // Email doesn't match
3476
+ if (
3477
+ // Email exists
3478
+ log.userEmail
3479
+ // Email query doesn't match
3480
+ && !log.userEmail.toLowerCase().includes(advancedFilterState.userEmail.toLowerCase().trim())) {
3481
+ return;
3482
+ }
3483
+ // User id doesn't match
3484
+ if (
3485
+ // User id exists
3486
+ log.userId
3487
+ // User id doesn't match
3488
+ && !String(log.userId).includes(advancedFilterState.userId.trim())) {
3489
+ return;
3490
+ }
3491
+ // Learner not allowed
3492
+ if (
3493
+ // User is a learner
3494
+ log.isLearner
3495
+ // Learners aren't included
3496
+ && !advancedFilterState.includeLearners) {
3497
+ return;
3498
+ }
3499
+ // TTM not allowed
3500
+ if (
3501
+ // User is a ttm
3502
+ log.isTTM
3503
+ // TTMs aren't included
3504
+ && !advancedFilterState.includeTTMs) {
3505
+ return;
3506
+ }
3507
+ // Admin not allowed
3508
+ if (
3509
+ // User is an admin
3510
+ log.isAdmin
3511
+ // Admins aren't included
3512
+ && !advancedFilterState.includeAdmins) {
3513
+ return;
3514
+ }
3515
+ // Course Id doesn't match
3516
+ if (
3517
+ // Course Id exists
3518
+ log.courseId
3519
+ // Course Id doesn't match
3520
+ && !String(log.courseId).includes(advancedFilterState.courseId.trim())) {
3521
+ return;
3522
+ }
3523
+ // Course name doesn't match
3524
+ if (
3525
+ // Course name exists
3526
+ log.courseName
3527
+ // Course name doesn't match
3528
+ && !String(log.courseName).includes(advancedFilterState.courseName.trim())) {
3529
+ return;
3530
+ }
3531
+ // Mobile filter doesn't match
3532
+ if (
3533
+ // Mobile filter exists
3534
+ advancedFilterState.isMobile !== undefined
3535
+ // Device info exists
3536
+ && log.device
3537
+ // Mobile filter doesn't match
3538
+ && (advancedFilterState.isMobile === log.device.isMobile)) {
3539
+ return;
3540
+ }
3541
+ // Log source doesn't match
3542
+ if (
3543
+ // Source filter exists
3544
+ advancedFilterState.source !== undefined
3545
+ // Source info exists
3546
+ && log.source
3547
+ // Source filter doesn't match
3548
+ && (advancedFilterState.source !== log.source)) {
3549
+ return;
3550
+ }
3551
+ // Route path doesn't match (Only for server source)
3552
+ if (
3553
+ // Source is server
3554
+ (log.source === LogSource$1.Server)
3555
+ // Route path is being filtered
3556
+ && (advancedFilterState.routePath.trim().length)
3557
+ // Route path doesn't match
3558
+ && !(log.routePath.includes(advancedFilterState.routePath.trim()))) {
3559
+ return;
3560
+ }
3561
+ // Route template doesn't match (Only for server source)
3562
+ if (
3563
+ // Source is server
3564
+ (log.source === LogSource$1.Server)
3565
+ // Route template is being filtered
3566
+ && (advancedFilterState.routeTemplate.trim().length)
3567
+ // Route template doesn't match
3568
+ && !(log.routeTemplate.includes(advancedFilterState.routeTemplate.trim()))) {
3569
+ return;
3570
+ }
3571
+ /* -------------- Done -------------- */
3572
+ // Made it past all filters. Add to the list
3573
+ logs.push(log);
3574
+ });
3575
+ });
3576
+ });
3577
+ /*----------------------------------------*/
3578
+ /* Data */
3579
+ /*----------------------------------------*/
3580
+ // Create data table
3581
+ const columns = [
3582
+ {
3583
+ title: 'First Name',
3584
+ param: 'userFirstName',
3585
+ type: ParamType$1.String,
3586
+ },
3587
+ {
3588
+ title: 'Last Name',
3589
+ param: 'userLastName',
3590
+ type: ParamType$1.String,
3591
+ },
3592
+ {
3593
+ title: 'Email',
3594
+ param: 'userEmail',
3595
+ type: ParamType$1.String,
3596
+ },
3597
+ {
3598
+ title: 'Canvas Id',
3599
+ param: 'userId',
3600
+ type: ParamType$1.Int,
3601
+ },
3602
+ {
3603
+ title: 'Student',
3604
+ param: 'isLearner',
3605
+ type: ParamType$1.Boolean,
3606
+ },
3607
+ {
3608
+ title: 'Teaching Staff',
3609
+ param: 'isTTM',
3610
+ type: ParamType$1.Boolean,
3611
+ startsHidden: true,
3612
+ },
3613
+ {
3614
+ title: 'Admin',
3615
+ param: 'isAdmin',
3616
+ type: ParamType$1.Boolean,
3617
+ startsHidden: true,
3618
+ },
3619
+ {
3620
+ title: 'Course Canvas Id',
3621
+ param: 'courseId',
3622
+ type: ParamType$1.Int,
3623
+ startsHidden: true,
3624
+ },
3625
+ {
3626
+ title: 'Course Name',
3627
+ param: 'courseName',
3628
+ type: ParamType$1.String,
3629
+ },
3630
+ {
3631
+ title: 'Browser Name',
3632
+ param: 'browser.name',
3633
+ type: ParamType$1.String,
3634
+ startsHidden: true,
3635
+ },
3636
+ {
3637
+ title: 'Browser Version',
3638
+ param: 'browser.version',
3639
+ type: ParamType$1.String,
3640
+ startsHidden: true,
3641
+ },
3642
+ {
3643
+ title: 'OS',
3644
+ param: 'device.os',
3645
+ type: ParamType$1.String,
3646
+ startsHidden: true,
3647
+ },
3648
+ {
3649
+ title: 'Mobile',
3650
+ param: 'device.isMobile',
3651
+ type: ParamType$1.Boolean,
3652
+ startsHidden: true,
3653
+ },
3654
+ {
3655
+ title: 'Year',
3656
+ param: 'year',
3657
+ type: ParamType$1.Int,
3658
+ },
3659
+ {
3660
+ title: 'Month',
3661
+ param: 'month',
3662
+ type: ParamType$1.Int,
3663
+ },
3664
+ {
3665
+ title: 'Day',
3666
+ param: 'day',
3667
+ type: ParamType$1.Int,
3668
+ },
3669
+ {
3670
+ title: 'Hour',
3671
+ param: 'hour',
3672
+ type: ParamType$1.Int,
3673
+ },
3674
+ {
3675
+ title: 'Minute',
3676
+ param: 'minute',
3677
+ type: ParamType$1.Int,
3678
+ startsHidden: true,
3679
+ },
3680
+ {
3681
+ title: 'Timestamp',
3682
+ param: 'timestamp',
3683
+ type: ParamType$1.Int,
3684
+ startsHidden: true,
3685
+ },
3686
+ {
3687
+ title: 'Context',
3688
+ param: 'context',
3689
+ type: ParamType$1.String,
3690
+ },
3691
+ {
3692
+ title: 'Subcontext',
3693
+ param: 'subcontext',
3694
+ type: ParamType$1.String,
3695
+ },
3696
+ {
3697
+ title: 'Tags',
3698
+ param: 'tags',
3699
+ type: ParamType$1.JSON,
3700
+ startsHidden: true,
3701
+ },
3702
+ {
3703
+ title: 'Log Level',
3704
+ param: 'level',
3705
+ type: ParamType$1.String,
3706
+ startsHidden: true,
3707
+ },
3708
+ {
3709
+ title: 'Metadata',
3710
+ param: 'metadata',
3711
+ type: ParamType$1.JSON,
3712
+ startsHidden: true,
3713
+ },
3714
+ {
3715
+ title: 'Source',
3716
+ param: 'source',
3717
+ type: ParamType$1.String,
3718
+ },
3719
+ {
3720
+ title: 'Server Route Path',
3721
+ param: 'routePath',
3722
+ type: ParamType$1.String,
3723
+ startsHidden: true,
3724
+ },
3725
+ {
3726
+ title: 'Server Route Template',
3727
+ param: 'routeTemplate',
3728
+ type: ParamType$1.String,
3729
+ startsHidden: true,
3730
+ },
3731
+ {
3732
+ title: 'Type',
3733
+ param: 'type',
3734
+ type: ParamType$1.String,
3735
+ },
3736
+ {
3737
+ title: 'Error Message',
3738
+ param: 'errorMessage',
3739
+ type: ParamType$1.String,
3740
+ startsHidden: true,
3741
+ },
3742
+ {
3743
+ title: 'Error Code',
3744
+ param: 'errorCode',
3745
+ type: ParamType$1.String,
3746
+ startsHidden: true,
3747
+ },
3748
+ {
3749
+ title: 'Error Stack',
3750
+ param: 'errorStack',
3751
+ type: ParamType$1.String,
3752
+ startsHidden: true,
3753
+ },
3754
+ {
3755
+ title: 'Action Target',
3756
+ param: 'target',
3757
+ type: ParamType$1.String,
3758
+ startsHidden: true,
3759
+ },
3760
+ {
3761
+ title: 'Action Type',
3762
+ param: 'action',
3763
+ type: ParamType$1.String,
3764
+ startsHidden: true,
3765
+ },
3766
+ ];
3767
+ // Create intelliTable
3768
+ const dataTable = (logs.length === 0
3769
+ ? (React__default["default"].createElement(React__default["default"].Fragment, null,
3770
+ React__default["default"].createElement("h3", { className: "m-0" }, "Matching Logs:"),
3771
+ React__default["default"].createElement("div", { className: "alert alert-warning text-center" },
3772
+ React__default["default"].createElement("h4", { className: "m-1" }, "No Logs to Show"),
3773
+ React__default["default"].createElement("div", null, "Either your filters are too strict or no matching logs have been created yet."))))
3774
+ : (React__default["default"].createElement(IntelliTable, { title: "Matching Logs:", id: "logs", data: logs, columns: columns })));
3775
+ // Main body
3776
+ body = (React__default["default"].createElement(React__default["default"].Fragment, null,
3777
+ filters,
3778
+ dataTable));
3779
+ }
3780
+ /* ---------- Wrap in Modal --------- */
3781
+ return (React__default["default"].createElement("div", { className: "LogReviewer-outer-container" },
3782
+ React__default["default"].createElement("style", null, style),
3783
+ React__default["default"].createElement("div", { className: "LogReviewer-inner-container" },
3784
+ React__default["default"].createElement("div", { className: "LogReviewer-header" },
3785
+ React__default["default"].createElement("div", { className: "LogReviewer-header-title" },
3786
+ React__default["default"].createElement("h3", { className: "text-center m-0" }, "Log Review Dashboard")),
3787
+ React__default["default"].createElement("button", { type: "button", className: "LogReviewer-header-close-button btn btn-dark btn-lg", "aria-label": "close log reviewer panel", onClick: onClose },
3788
+ React__default["default"].createElement(reactFontawesome.FontAwesomeIcon, { icon: freeSolidSvgIcons.faTimes }))),
3789
+ React__default["default"].createElement("div", { className: "LogReviewer-contents" }, body))));
3790
+ };
3791
+
3792
+ /**
3793
+ * One minute in ms
3794
+ * @author Gabe Abrams
3795
+ */
3796
+ const MINUTE_IN_MS = 60000;
3797
+
3798
+ /**
3799
+ * One hour in ms
3800
+ * @author Gabe Abrams
3801
+ */
3802
+ const HOUR_IN_MS = 3600000;
3803
+
3804
+ /**
1866
3805
  * One day in ms
1867
3806
  * @author Gabe Abrams
1868
3807
  */
@@ -1910,252 +3849,90 @@ const sum = (nums) => {
1910
3849
  const avg = (nums) => {
1911
3850
  // Handle empty array case
1912
3851
  if (nums.length === 0) {
1913
- return 0;
1914
- }
1915
- // Get the total value
1916
- const total = sum(nums);
1917
- // Get average
1918
- return (total / nums.length);
1919
- };
1920
-
1921
- /**
1922
- * Round a number (ceiling) to a certain number of decimals
1923
- * @author Gabe Abrams
1924
- * @param num the number to round
1925
- * @param numDecimals the number of decimals to round to
1926
- * @returns rounded number
1927
- */
1928
- const ceilToNumDecimals = (num, numDecimals) => {
1929
- const rounder = 10 ** numDecimals;
1930
- return (Math.ceil(num * rounder) / rounder);
1931
- };
1932
-
1933
- /**
1934
- * Round a number (floor) to a certain number of decimals
1935
- * @author Gabe Abrams
1936
- * @param num the number to round
1937
- * @param numDecimals the number of decimals to round to
1938
- * @returns rounded number
1939
- */
1940
- const floorToNumDecimals = (num, numDecimals) => {
1941
- const rounder = 10 ** numDecimals;
1942
- return (Math.floor(num * rounder) / rounder);
1943
- };
1944
-
1945
- /**
1946
- * Force a number to stay within specific bounds
1947
- * @author Gabe Abrams
1948
- * @param num the number to move into the bounds
1949
- * @param min the minimum number in the bound
1950
- * @param max the maximum number in the bound
1951
- * @returns bounded number
1952
- */
1953
- const forceNumIntoBounds = (num, min, max) => {
1954
- return Math.max(min, Math.min(max, num));
1955
- };
1956
-
1957
- /**
1958
- * Pad a number's decimal with zeros on the right
1959
- * (e.g. 5.2 becomes 5.20 with 2 digit padding)
1960
- * @author Gabe Abrams
1961
- * @param num the number to pad
1962
- * @param numDigits the minimum number of digits after the decimal
1963
- * @returns padded number
1964
- */
1965
- const padDecimalZeros = (num, numDigits) => {
1966
- // Skip if nothing to do
1967
- if (numDigits < 1) {
1968
- return String(num);
1969
- }
1970
- // Convert to string
1971
- let out = String(num);
1972
- // Add a decimal point if there isn't one
1973
- if (!out.includes('.')) {
1974
- out += '.';
1975
- }
1976
- // Add zeros
1977
- while (out.split('.')[1].length < numDigits) {
1978
- out = `${out}0`;
1979
- }
1980
- // Return
1981
- return out;
1982
- };
1983
-
1984
- /**
1985
- * Pad a number with zeros on the left (e.g. 5 becomes 05 with 2 digit padding)
1986
- * @author Gabe Abrams
1987
- * @param num the number to pad
1988
- * @param numDigits the minimum number of digits before the decimal
1989
- * @returns padded number
1990
- */
1991
- const padZerosLeft = (num, numDigits) => {
1992
- // Convert to string
1993
- let out = String(num);
1994
- // Add zeros
1995
- while (out.split('.')[0].length < numDigits) {
1996
- out = `0${out}`;
1997
- }
1998
- // Return
1999
- return out;
2000
- };
2001
-
2002
- /**
2003
- * Round a number to a certain number of decimals
2004
- * @author Gabe Abrams
2005
- * @param num the number to round
2006
- * @param numDecimals the number of decimals to round to
2007
- * @returns rounded number
2008
- */
2009
- const roundToNumDecimals = (num, numDecimals) => {
2010
- const rounder = 10 ** numDecimals;
2011
- return (Math.round(num * rounder) / rounder);
2012
- };
2013
-
2014
- // Keep track of whether or not session expiry has already been handled
2015
- let sessionAlreadyExpired = false;
2016
- /*------------------------------------------------------------------------*/
2017
- /* Stub Logic */
2018
- /*------------------------------------------------------------------------*/
2019
- // Stored stub responses
2020
- const stubResponses = {};
2021
- /**
2022
- * Add a stub response
2023
- * @author Gabe Abrams
2024
- * @param opts object containing all arguments
2025
- * @param [opts.method=GET] http request method
2026
- * @param opts.path pathname of the request
2027
- * @param [opts.body] body of the response if successful
2028
- * @param [opts.errorMessage] error message if not successful
2029
- * @param [opts.errorCode] error code if not successful
2030
- */
2031
- const _setStubResponse = (opts) => {
2032
- var _a, _b, _c;
2033
- const { path, body, } = opts;
2034
- const method = ((_a = opts.method) !== null && _a !== void 0 ? _a : 'GET').toUpperCase();
2035
- const errorMessage = ((_b = opts.errorMessage) !== null && _b !== void 0 ? _b : 'An unknown error has occurred.');
2036
- const errorCode = ((_c = opts.errorCode) !== null && _c !== void 0 ? _c : ReactKitErrorCode$1.NoCode);
2037
- // Store to stub responses
2038
- if (!stubResponses[method]) {
2039
- stubResponses[method] = {};
2040
- }
2041
- stubResponses[method][path] = ((opts.errorMessage || opts.errorCode)
2042
- ? {
2043
- success: false,
2044
- errorMessage,
2045
- errorCode,
2046
- }
2047
- : {
2048
- success: true,
2049
- body: body !== null && body !== void 0 ? body : undefined,
2050
- });
2051
- };
2052
- /*------------------------------------------------------------------------*/
2053
- /* Main */
2054
- /*------------------------------------------------------------------------*/
2055
- /**
2056
- * Visit an endpoint on the server [for client only]
2057
- * @author Gabe Abrams
2058
- * @param opts object containing all arguments
2059
- * @param opts.path - the path of the server endpoint
2060
- * @param [opts.method=GET] - the method of the endpoint
2061
- * @param [opts.params] - query/body parameters to include
2062
- * @returns response from server
2063
- */
2064
- const visitServerEndpoint = (opts) => __awaiter(void 0, void 0, void 0, function* () {
2065
- var _a, _b, _c;
2066
- const method = ((_a = opts.method) !== null && _a !== void 0 ? _a : 'GET');
2067
- // Handle stubs
2068
- const stubResponse = (_b = stubResponses[method]) === null || _b === void 0 ? void 0 : _b[opts.path];
2069
- if (stubResponse) {
2070
- // Remove from list
2071
- try {
2072
- stubResponses[method][opts.path] = undefined;
2073
- }
2074
- catch (err) {
2075
- // Ignore
2076
- }
2077
- // Success
2078
- if (stubResponse.success) {
2079
- return stubResponse.body;
2080
- }
2081
- // Error
2082
- throw new ErrorWithCode(stubResponse.errorMessage, stubResponse.errorCode);
2083
- }
2084
- // Send the request
2085
- const response = yield cacclSendRequest({
2086
- path: opts.path,
2087
- method: (_c = opts.method) !== null && _c !== void 0 ? _c : 'GET',
2088
- params: opts.params,
2089
- });
2090
- // Check for failure
2091
- if (!response || !response.body) {
2092
- throw new ErrorWithCode('We didn\'t get a response from the server. Please check your internet connection.', ReactKitErrorCode$1.NoResponse);
2093
- }
2094
- if (!response.body.success) {
2095
- // Session expired
2096
- if (response.body.code === ReactKitErrorCode$1.SessionExpired) {
2097
- // Skip notice if session was already expired
2098
- if (sessionAlreadyExpired) {
2099
- // Never return (browser is already reloading)
2100
- yield new Promise(() => {
2101
- // Promise that never returns
2102
- });
2103
- }
2104
- sessionAlreadyExpired = true;
2105
- // Show session expiration message
2106
- {
2107
- // Fallback to alert
2108
- // eslint-disable-next-line no-alert
2109
- alert('Your session has expired. Please start over.');
2110
- }
2111
- // Never return (don't continue execution)
2112
- yield new Promise(() => {
2113
- // Promise that never returns
2114
- });
2115
- }
2116
- // Other errors
2117
- throw new ErrorWithCode((response.body.message
2118
- || 'An unknown error occurred. Please contact an admin.'), (response.body.code
2119
- || ReactKitErrorCode$1.NoCode));
3852
+ return 0;
2120
3853
  }
2121
- // Success! Extract the body
2122
- const { body } = response.body;
2123
- // Return
2124
- return body;
2125
- });
3854
+ // Get the total value
3855
+ const total = sum(nums);
3856
+ // Get average
3857
+ return (total / nums.length);
3858
+ };
2126
3859
 
2127
3860
  /**
2128
- * Path that all routes start with
3861
+ * Round a number (ceiling) to a certain number of decimals
2129
3862
  * @author Gabe Abrams
3863
+ * @param num the number to round
3864
+ * @param numDecimals the number of decimals to round to
3865
+ * @returns rounded number
2130
3866
  */
2131
- const ROUTE_PATH_PREFIX = '/dce-reactkit';
3867
+ const ceilToNumDecimals = (num, numDecimals) => {
3868
+ const rounder = 10 ** numDecimals;
3869
+ return (Math.ceil(num * rounder) / rounder);
3870
+ };
2132
3871
 
2133
3872
  /**
2134
- * Path of the route for storing client-side logs
3873
+ * Round a number (floor) to a certain number of decimals
2135
3874
  * @author Gabe Abrams
3875
+ * @param num the number to round
3876
+ * @param numDecimals the number of decimals to round to
3877
+ * @returns rounded number
2136
3878
  */
2137
- const LOG_ROUTE_PATH = `${ROUTE_PATH_PREFIX}/log`;
3879
+ const floorToNumDecimals = (num, numDecimals) => {
3880
+ const rounder = 10 ** numDecimals;
3881
+ return (Math.floor(num * rounder) / rounder);
3882
+ };
2138
3883
 
2139
3884
  /**
2140
- * Server-side API param types
3885
+ * Pad a number's decimal with zeros on the right
3886
+ * (e.g. 5.2 becomes 5.20 with 2 digit padding)
2141
3887
  * @author Gabe Abrams
3888
+ * @param num the number to pad
3889
+ * @param numDigits the minimum number of digits after the decimal
3890
+ * @returns padded number
2142
3891
  */
2143
- var ParamType;
2144
- (function (ParamType) {
2145
- ParamType["Boolean"] = "boolean";
2146
- ParamType["BooleanOptional"] = "boolean-optional";
2147
- ParamType["Float"] = "float";
2148
- ParamType["FloatOptional"] = "float-optional";
2149
- ParamType["Int"] = "int";
2150
- ParamType["IntOptional"] = "int-optional";
2151
- ParamType["JSON"] = "json";
2152
- ParamType["JSONOptional"] = "json-optional";
2153
- ParamType["String"] = "string";
2154
- ParamType["StringOptional"] = "string-optional";
2155
- })(ParamType || (ParamType = {}));
2156
- var ParamType$1 = ParamType;
3892
+ const padDecimalZeros = (num, numDigits) => {
3893
+ // Skip if nothing to do
3894
+ if (numDigits < 1) {
3895
+ return String(num);
3896
+ }
3897
+ // Convert to string
3898
+ let out = String(num);
3899
+ // Add a decimal point if there isn't one
3900
+ if (!out.includes('.')) {
3901
+ out += '.';
3902
+ }
3903
+ // Add zeros
3904
+ while (out.split('.')[1].length < numDigits) {
3905
+ out = `${out}0`;
3906
+ }
3907
+ // Return
3908
+ return out;
3909
+ };
3910
+
3911
+ /**
3912
+ * Pad a number with zeros on the left (e.g. 5 becomes 05 with 2 digit padding)
3913
+ * @author Gabe Abrams
3914
+ * @param num the number to pad
3915
+ * @param numDigits the minimum number of digits before the decimal
3916
+ * @returns padded number
3917
+ */
3918
+ const padZerosLeft = (num, numDigits) => {
3919
+ // Convert to string
3920
+ let out = String(num);
3921
+ // Add zeros
3922
+ while (out.split('.')[0].length < numDigits) {
3923
+ out = `0${out}`;
3924
+ }
3925
+ // Return
3926
+ return out;
3927
+ };
3928
+
3929
+ /**
3930
+ * Route for checking the status of the current user's
3931
+ * access to log review
3932
+ * @author Gabe Abrams
3933
+ */
3934
+ const LOG_REVIEW_STATUS_ROUTE = `${ROUTE_PATH_PREFIX}/logs/access_allowed`;
2157
3935
 
2158
- // Import custom error
2159
3936
  // Stored copy of caccl functions
2160
3937
  let _cacclGetLaunchInfo;
2161
3938
  // Stored copy of dce-mango log collection
@@ -2196,10 +3973,20 @@ const internalGetLogCollection = () => {
2196
3973
  * @param opts.getLaunchInfo CACCL LTI's get launch info function
2197
3974
  * @param [opts.logCollection] mongo collection from dce-mango to use for
2198
3975
  * storing logs. If none is included, logs are written to the console
3976
+ * @param [opts.logReviewAdmins=all] info on which admins can review
3977
+ * logs from the client. If not included, all Canvas admins are allowed to
3978
+ * review logs. If null, no Canvas admins are allowed to review logs.
3979
+ * If an array of Canvas userIds (numbers), only Canvas admins with those
3980
+ * userIds are allowed to review logs. If a dce-mango collection, only
3981
+ * Canvas admins with entries in that collection ({ userId, ...}) are allowed
3982
+ * to review logs
2199
3983
  */
2200
3984
  const initServer = (opts) => {
2201
3985
  _cacclGetLaunchInfo = opts.getLaunchInfo;
2202
3986
  _logCollection = opts.logCollection;
3987
+ /*----------------------------------------*/
3988
+ /* Logging */
3989
+ /*----------------------------------------*/
2203
3990
  /**
2204
3991
  * Log an event
2205
3992
  * @author Gabe Abrams
@@ -2266,6 +4053,84 @@ const initServer = (opts) => {
2266
4053
  return log;
2267
4054
  },
2268
4055
  }));
4056
+ /*----------------------------------------*/
4057
+ /* Log Reviewer */
4058
+ /*----------------------------------------*/
4059
+ /**
4060
+ * Check if a given user is allowed to review logs
4061
+ * @author Gabe Abrams
4062
+ * @param userId the id of the user
4063
+ * @param isAdmin if true, the user is an admin
4064
+ * @returns true if the user can review logs
4065
+ */
4066
+ const canReviewLogs = (userId, isAdmin) => __awaiter(void 0, void 0, void 0, function* () {
4067
+ // Immediately deny access if user is not an admin
4068
+ if (!isAdmin) {
4069
+ return false;
4070
+ }
4071
+ // If all admins are allowed, we're done
4072
+ if (!opts.logReviewAdmins) {
4073
+ return true;
4074
+ }
4075
+ // Do a dynamic check
4076
+ try {
4077
+ // Array of userIds
4078
+ if (Array.isArray(opts.logReviewAdmins)) {
4079
+ return opts.logReviewAdmins.some((allowedId) => {
4080
+ return (userId === allowedId);
4081
+ });
4082
+ }
4083
+ // Must be a collection
4084
+ const matches = yield opts.logReviewAdmins.find({ userId });
4085
+ // Make sure at least one entry matches
4086
+ return matches.length > 0;
4087
+ }
4088
+ catch (err) {
4089
+ // If an error occurred, simply return false
4090
+ return false;
4091
+ }
4092
+ });
4093
+ /**
4094
+ * Check if the current user has access to logs
4095
+ * @author Gabe Abrams
4096
+ * @returns {boolean} true if user has access
4097
+ */
4098
+ opts.app.get(LOG_REVIEW_STATUS_ROUTE, genRouteHandler({
4099
+ handler: ({ params }) => __awaiter(void 0, void 0, void 0, function* () {
4100
+ const { userId, isAdmin } = params;
4101
+ const canReview = yield canReviewLogs(userId, isAdmin);
4102
+ return canReview;
4103
+ }),
4104
+ }));
4105
+ /**
4106
+ * Get all logs for a certain month
4107
+ * @author Gabe Abrams
4108
+ * @param {number} year the year to query (e.g. 2022)
4109
+ * @param {number} month the month to query (e.g. 1 = January)
4110
+ * @returns {Log[]} list of logs from the given month
4111
+ */
4112
+ opts.app.get(`${LOG_REVIEW_ROUTE_PATH_PREFIX}/years/:year/months/:month`, genRouteHandler({
4113
+ paramTypes: {
4114
+ year: ParamType$1.Int,
4115
+ month: ParamType$1.Int,
4116
+ },
4117
+ handler: ({ params }) => __awaiter(void 0, void 0, void 0, function* () {
4118
+ // Get user info
4119
+ const { year, month, userId, isAdmin, } = params;
4120
+ // Validate user
4121
+ const canReview = yield canReviewLogs(userId, isAdmin);
4122
+ if (!canReview) {
4123
+ throw new ErrorWithCode('You cannot access this resource because you do not have the appropriate permissions.', ReactKitErrorCode$1.NotAllowedToReviewLogs);
4124
+ }
4125
+ // Query for logs
4126
+ const logs = yield _logCollection.find({
4127
+ year,
4128
+ month,
4129
+ });
4130
+ // Return logs
4131
+ return logs;
4132
+ }),
4133
+ }));
2269
4134
  };
2270
4135
 
2271
4136
  // Import shared types
@@ -2565,102 +4430,6 @@ const parseUserAgent = (userAgent) => {
2565
4430
  };
2566
4431
  };
2567
4432
 
2568
- /**
2569
- * Type of a log event
2570
- * @author Gabe Abrams
2571
- */
2572
- var LogType;
2573
- (function (LogType) {
2574
- // User action
2575
- LogType["Action"] = "action";
2576
- // Error
2577
- LogType["Error"] = "error";
2578
- })(LogType || (LogType = {}));
2579
- var LogType$1 = LogType;
2580
-
2581
- /**
2582
- * Source of a log event
2583
- * @author Gabe Abrams
2584
- */
2585
- var LogSource;
2586
- (function (LogSource) {
2587
- // Client
2588
- LogSource["Client"] = "client";
2589
- // Server
2590
- LogSource["Server"] = "server";
2591
- })(LogSource || (LogSource = {}));
2592
- var LogSource$1 = LogSource;
2593
-
2594
- /**
2595
- * Built-in metadata for logs
2596
- * @author Gabe Abrams
2597
- */
2598
- const LogBuiltInMetadata = {
2599
- // Contexts
2600
- Context: {
2601
- Uncategorized: 'n/a',
2602
- ServerRenderedErrorPage: '_server-rendered-error-page',
2603
- ServerEndpointError: '_server-endpoint-error',
2604
- },
2605
- // Targets
2606
- Target: {
2607
- NoSpecificTarget: 'n/a',
2608
- },
2609
- };
2610
-
2611
- /**
2612
- * Types of actions
2613
- * @author Gabe Abrams
2614
- */
2615
- var LogAction;
2616
- (function (LogAction) {
2617
- // Target was opened by the user (it was not on screen, but now it is)
2618
- LogAction["Open"] = "open";
2619
- // Target was closed by the user (it was on screen, but now it is not)
2620
- LogAction["Close"] = "close";
2621
- // Target was cancelled by the user (it was on closed without saving)
2622
- LogAction["Cancel"] = "cancel";
2623
- // Target was expanded by the user (it always remains on screen, but size was changed)
2624
- LogAction["Expand"] = "expand";
2625
- // Target was collapsed by the user (it always remains on screen, but size was changed)
2626
- LogAction["Collapse"] = "collapse";
2627
- // Target was viewed by the user (only for items that are not opened or closed, those must use Open/Close actions)
2628
- LogAction["View"] = "view";
2629
- // Target interrupted the user (popup, dialog, validation message, etc. appeared without user prompting)
2630
- LogAction["Interrupt"] = "interrupt";
2631
- // Target was created by the user (it did not exist before)
2632
- LogAction["Create"] = "create";
2633
- // Target was edited by the user (it existed and was changed)
2634
- LogAction["Edit"] = "edit";
2635
- // Target was deleted by the user (it existed and now it doesn't)
2636
- LogAction["Delete"] = "delete";
2637
- // Target was added by the user (it already existed and was added to another place)
2638
- LogAction["Add"] = "add";
2639
- // Target was removed by the user (it was removed from something but still exists)
2640
- LogAction["Remove"] = "remove";
2641
- // Target was activated by the user (click, check, tap, keypress, etc.)
2642
- LogAction["Activate"] = "activate";
2643
- // Target was deactivated by the user (click away, uncheck, tap outside of, tab away, etc.)
2644
- LogAction["Deactivate"] = "deactivate";
2645
- // User showed interest in a target (hover, peek, etc.)
2646
- LogAction["Peek"] = "peek";
2647
- // Unknown action
2648
- LogAction["Unknown"] = "unknown";
2649
- })(LogAction || (LogAction = {}));
2650
- var LogAction$1 = LogAction;
2651
-
2652
- /**
2653
- * Allowed log levels
2654
- * @author Gabe Abrams
2655
- */
2656
- var LogLevel;
2657
- (function (LogLevel) {
2658
- LogLevel["Warn"] = "Warn";
2659
- LogLevel["Info"] = "Info";
2660
- LogLevel["Debug"] = "Debug";
2661
- })(LogLevel || (LogLevel = {}));
2662
- var LogLevel$1 = LogLevel;
2663
-
2664
4433
  /**
2665
4434
  * Generate an express API route handler
2666
4435
  * @author Gabe Abrams
@@ -3246,21 +5015,7 @@ const startMinWait = (minWaitMs) => {
3246
5015
  });
3247
5016
  };
3248
5017
 
3249
- // Map of month to three letter description
3250
- const monthMap = {
3251
- 1: 'Jan',
3252
- 2: 'Feb',
3253
- 3: 'Mar',
3254
- 4: 'Apr',
3255
- 5: 'May',
3256
- 6: 'Jun',
3257
- 7: 'Jul',
3258
- 8: 'Aug',
3259
- 9: 'Sep',
3260
- 10: 'Oct',
3261
- 11: 'Nov',
3262
- 12: 'Dec',
3263
- };
5018
+ // Import shared helpers
3264
5019
  /**
3265
5020
  * Get a human-readable description of a date (all in ET)
3266
5021
  * @author Gabe Abrams
@@ -3271,8 +5026,10 @@ const getHumanReadableDate = (dateOrTimestamp) => {
3271
5026
  // Get the date info
3272
5027
  const { month, day, year, } = getTimeInfoInET(dateOrTimestamp);
3273
5028
  const currYear = getTimeInfoInET().year;
5029
+ // Get the short month description
5030
+ const monthName = getMonthName(month).short;
3274
5031
  // Create start of description
3275
- let description = `${monthMap[month]} ${day}${getOrdinal(day)}`;
5032
+ let description = `${monthName} ${day}${getOrdinal(day)}`;
3276
5033
  // Add on year if it's different
3277
5034
  if (year !== currYear) {
3278
5035
  description += ` ${year}`;
@@ -3394,41 +5151,6 @@ const parallelLimit = (taskFunctions, limit) => __awaiter(void 0, void 0, void 0
3394
5151
  return results;
3395
5152
  });
3396
5153
 
3397
- /**
3398
- * Log a user action on the client (cannot be used on the server)
3399
- * @author Gabe Abrams
3400
- */
3401
- const logClientEvent = (opts) => __awaiter(void 0, void 0, void 0, function* () {
3402
- var _a, _b, _c, _d, _e, _f;
3403
- return visitServerEndpoint({
3404
- path: LOG_ROUTE_PATH,
3405
- method: 'POST',
3406
- params: {
3407
- context: (typeof opts.context === 'string'
3408
- ? opts.context
3409
- : ((_b = ((_a = opts.context) !== null && _a !== void 0 ? _a : {})._) !== null && _b !== void 0 ? _b : LogBuiltInMetadata.Context.Uncategorized)),
3410
- subcontext: ((_c = opts.subcontext) !== null && _c !== void 0 ? _c : LogBuiltInMetadata.Context.Uncategorized),
3411
- tags: JSON.stringify((_d = opts.tags) !== null && _d !== void 0 ? _d : []),
3412
- metadata: JSON.stringify((_e = opts.metadata) !== null && _e !== void 0 ? _e : {}),
3413
- errorMessage: (opts.error
3414
- ? opts.error.message
3415
- : undefined),
3416
- errorCode: (opts.error
3417
- ? opts.error.code
3418
- : undefined),
3419
- errorStack: (opts.error
3420
- ? opts.error.stack
3421
- : undefined),
3422
- target: (opts.action
3423
- ? ((_f = opts.target) !== null && _f !== void 0 ? _f : LogBuiltInMetadata.Target.NoSpecificTarget)
3424
- : undefined),
3425
- action: (opts.action
3426
- ? opts.action
3427
- : undefined),
3428
- },
3429
- });
3430
- });
3431
-
3432
5154
  /**
3433
5155
  * Initialize a log collection given the dce-mango Collection class
3434
5156
  * @author Gabe Abrams
@@ -3447,6 +5169,31 @@ const initLogCollection = (Collection) => {
3447
5169
  });
3448
5170
  };
3449
5171
 
5172
+ // Cache user's ability
5173
+ let canReview = undefined;
5174
+ /**
5175
+ * Check if the current user can review logs
5176
+ * @author Gabe Abrams
5177
+ * @returns true if current user can review logs
5178
+ */
5179
+ const canReviewLogs = () => __awaiter(void 0, void 0, void 0, function* () {
5180
+ // If cached, use that value
5181
+ if (canReview !== undefined) {
5182
+ return canReview;
5183
+ }
5184
+ // Ask on server
5185
+ try {
5186
+ canReview = !!(yield visitServerEndpoint({
5187
+ path: LOG_REVIEW_STATUS_ROUTE,
5188
+ method: 'GET',
5189
+ }));
5190
+ }
5191
+ catch (err) {
5192
+ canReview = false;
5193
+ }
5194
+ return canReview;
5195
+ });
5196
+
3450
5197
  /**
3451
5198
  * Days of the week
3452
5199
  * @author Gabe Abrams
@@ -3465,6 +5212,7 @@ var DayOfWeek$1 = DayOfWeek;
3465
5212
 
3466
5213
  exports.AppWrapper = AppWrapper;
3467
5214
  exports.ButtonInputGroup = ButtonInputGroup;
5215
+ exports.CSVDownloadButton = CSVDownloadButton;
3468
5216
  exports.CheckboxButton = CheckboxButton;
3469
5217
  exports.CopiableBox = CopiableBox;
3470
5218
  exports.DAY_IN_MS = DAY_IN_MS;
@@ -3473,10 +5221,12 @@ exports.Drawer = Drawer;
3473
5221
  exports.ErrorBox = ErrorBox;
3474
5222
  exports.ErrorWithCode = ErrorWithCode;
3475
5223
  exports.HOUR_IN_MS = HOUR_IN_MS;
5224
+ exports.IntelliTable = IntelliTable;
3476
5225
  exports.ItemPicker = ItemPicker;
3477
5226
  exports.LoadingSpinner = LoadingSpinner;
3478
5227
  exports.LogAction = LogAction$1;
3479
5228
  exports.LogBuiltInMetadata = LogBuiltInMetadata;
5229
+ exports.LogReviewer = LogReviewer;
3480
5230
  exports.LogSource = LogSource$1;
3481
5231
  exports.LogType = LogType$1;
3482
5232
  exports.MINUTE_IN_MS = MINUTE_IN_MS;
@@ -3496,12 +5246,15 @@ exports.Variant = Variant$1;
3496
5246
  exports.abbreviate = abbreviate;
3497
5247
  exports.alert = alert$1;
3498
5248
  exports.avg = avg;
5249
+ exports.canReviewLogs = canReviewLogs;
3499
5250
  exports.ceilToNumDecimals = ceilToNumDecimals;
3500
5251
  exports.confirm = confirm;
3501
5252
  exports.floorToNumDecimals = floorToNumDecimals;
3502
5253
  exports.forceNumIntoBounds = forceNumIntoBounds;
5254
+ exports.genCSV = genCSV;
3503
5255
  exports.genRouteHandler = genRouteHandler;
3504
5256
  exports.getHumanReadableDate = getHumanReadableDate;
5257
+ exports.getMonthName = getMonthName;
3505
5258
  exports.getOrdinal = getOrdinal;
3506
5259
  exports.getPartOfDay = getPartOfDay;
3507
5260
  exports.getTimeInfoInET = getTimeInfoInET;