@spokane-folio/security-incident 1.0.28

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 (188) hide show
  1. package/.eslintrc +32 -0
  2. package/.github/workflows/CODEOWNERS +8 -0
  3. package/.github/workflows/pr-validation.yml +44 -0
  4. package/.github/workflows/release.yml +64 -0
  5. package/.prettierrc +6 -0
  6. package/.stripesclirc +4 -0
  7. package/CHANGELOG.md +8 -0
  8. package/CONTRIBUTING.md +4 -0
  9. package/LICENSE +201 -0
  10. package/README.md +16 -0
  11. package/administrator-documentation/roles-and-permissions.md +65 -0
  12. package/administrator-documentation/track-settings-admin-guide-sketch.md +192 -0
  13. package/administrator-documentation/using-the-application.md +192 -0
  14. package/icons/app.png +0 -0
  15. package/icons/app.svg +1 -0
  16. package/icons/playButton.png +0 -0
  17. package/icons/profilePicThumbnail.png +0 -0
  18. package/jest.config.js +10 -0
  19. package/module-descriptor.json +75 -0
  20. package/output/service-worker.js +0 -0
  21. package/package.json +146 -0
  22. package/src/components/incidents/ColumnChooser.js +37 -0
  23. package/src/components/incidents/CreateMedia.js +132 -0
  24. package/src/components/incidents/CreatePane.js +1215 -0
  25. package/src/components/incidents/CreatePane.test.js +138 -0
  26. package/src/components/incidents/CreateReport.js +102 -0
  27. package/src/components/incidents/DetailsPane.js +1267 -0
  28. package/src/components/incidents/DetailsPane.test.js +150 -0
  29. package/src/components/incidents/EditPane.js +2334 -0
  30. package/src/components/incidents/EditPane.test.js +187 -0
  31. package/src/components/incidents/GetDetails.js +55 -0
  32. package/src/components/incidents/GetListDQLinkIncident.js +81 -0
  33. package/src/components/incidents/GetListDynamicQuery.js +66 -0
  34. package/src/components/incidents/GetLocations.js +57 -0
  35. package/src/components/incidents/GetMedia.js +98 -0
  36. package/src/components/incidents/GetName.js +111 -0
  37. package/src/components/incidents/GetNameCreatedBy.js +94 -0
  38. package/src/components/incidents/GetOrgLocaleSettings.js +61 -0
  39. package/src/components/incidents/GetPatronGroups.js +52 -0
  40. package/src/components/incidents/GetSelf.js +65 -0
  41. package/src/components/incidents/GetSummary.js +110 -0
  42. package/src/components/incidents/IncidentTypeCard.js +53 -0
  43. package/src/components/incidents/IncidentTypeCard.test.js +133 -0
  44. package/src/components/incidents/IncidentsPaneset.js +810 -0
  45. package/src/components/incidents/IncidentsPaneset.test.js +128 -0
  46. package/src/components/incidents/LinkedIncident.js +86 -0
  47. package/src/components/incidents/ModalAddMedia.js +262 -0
  48. package/src/components/incidents/ModalAddMedia.test.js +97 -0
  49. package/src/components/incidents/ModalAttentionDecOfService.js +111 -0
  50. package/src/components/incidents/ModalCustomWitness.js +469 -0
  51. package/src/components/incidents/ModalCustomWitness.test.js +147 -0
  52. package/src/components/incidents/ModalCustomerDetails.js +480 -0
  53. package/src/components/incidents/ModalCustomerDetails.test.js +116 -0
  54. package/src/components/incidents/ModalDescribeCustomer.js +361 -0
  55. package/src/components/incidents/ModalDescribeCustomer.test.js +156 -0
  56. package/src/components/incidents/ModalDirtyFormWarn.js +62 -0
  57. package/src/components/incidents/ModalLinkIncident.js +1213 -0
  58. package/src/components/incidents/ModalLinkIncidentStyle.css +32 -0
  59. package/src/components/incidents/ModalSelectIncidentTypes.js +178 -0
  60. package/src/components/incidents/ModalSelectIncidentTypes.test.js +273 -0
  61. package/src/components/incidents/ModalSelectKnownCustomer.js +395 -0
  62. package/src/components/incidents/ModalSelectWitness.js +406 -0
  63. package/src/components/incidents/ModalSelectWitness.test.js +308 -0
  64. package/src/components/incidents/ModalStyle.css +44 -0
  65. package/src/components/incidents/ModalTrespass.js +741 -0
  66. package/src/components/incidents/ModalViewCustomerDetails.js +241 -0
  67. package/src/components/incidents/ModalViewMedia.js +86 -0
  68. package/src/components/incidents/ModalViewTrespass.js +210 -0
  69. package/src/components/incidents/ResultsPane.js +437 -0
  70. package/src/components/incidents/ResultsPane.test.js +120 -0
  71. package/src/components/incidents/SearchCustomerOrWitness.js +108 -0
  72. package/src/components/incidents/Thumbnail.js +72 -0
  73. package/src/components/incidents/ThumbnailMarkRemoval.js +38 -0
  74. package/src/components/incidents/ThumbnailSkeleton.js +30 -0
  75. package/src/components/incidents/ThumbnailStyles.js +49 -0
  76. package/src/components/incidents/ThumbnailTempPreSave.js +71 -0
  77. package/src/components/incidents/UpdateReport.js +84 -0
  78. package/src/components/incidents/__snapshots__/CreatePane.test.js.snap +3 -0
  79. package/src/components/incidents/__snapshots__/DetailsPane.test.js.snap +3 -0
  80. package/src/components/incidents/__snapshots__/EditPane.test.js.snap +3 -0
  81. package/src/components/incidents/__snapshots__/IncidentTypeCard.test.js.snap +3 -0
  82. package/src/components/incidents/__snapshots__/IncidentsPaneset.test.js.snap +3 -0
  83. package/src/components/incidents/__snapshots__/ModalAddMedia.test.js.snap +3 -0
  84. package/src/components/incidents/__snapshots__/ModalCustomerDetails.test.js.snap +3 -0
  85. package/src/components/incidents/__snapshots__/ModalSelectWitness.test.js.snap +3 -0
  86. package/src/components/incidents/__snapshots__/ResultsPane.test.js.snap +3 -0
  87. package/src/components/incidents/helpers/ProfilePicture/ProfilePicture.css +5 -0
  88. package/src/components/incidents/helpers/ProfilePicture/ProfilePicture.js +51 -0
  89. package/src/components/incidents/helpers/ProfilePicture/isAValidURL.js +3 -0
  90. package/src/components/incidents/helpers/ProfilePicture/useProfilePicture.js +127 -0
  91. package/src/components/incidents/helpers/buildQueryString.js +28 -0
  92. package/src/components/incidents/helpers/cleanFormValues.js +53 -0
  93. package/src/components/incidents/helpers/computeEditedCustomers.js +124 -0
  94. package/src/components/incidents/helpers/convertDateIgnoringTZ.js +8 -0
  95. package/src/components/incidents/helpers/convertUTCISOToLocalePrettyTime.js +15 -0
  96. package/src/components/incidents/helpers/convertUTCISOToPrettyDate.js +19 -0
  97. package/src/components/incidents/helpers/decodeParamsToForm.js +20 -0
  98. package/src/components/incidents/helpers/deepNormalizeForComparison.js +39 -0
  99. package/src/components/incidents/helpers/extractFilterString.js +12 -0
  100. package/src/components/incidents/helpers/formatDateAndTimeToUTCISO.js +14 -0
  101. package/src/components/incidents/helpers/formatDateToUTCISO.js +14 -0
  102. package/src/components/incidents/helpers/formatTimeToUTCISO.js +28 -0
  103. package/src/components/incidents/helpers/getCurrentTime.js +20 -0
  104. package/src/components/incidents/helpers/getTodayDate.js +12 -0
  105. package/src/components/incidents/helpers/handlebarsHelpers.js +148 -0
  106. package/src/components/incidents/helpers/hasFormChangedAtCreate.js +50 -0
  107. package/src/components/incidents/helpers/hasTopLevelChangeAffectedDeclaration.js +90 -0
  108. package/src/components/incidents/helpers/hasTopLevelFormChanged.js +111 -0
  109. package/src/components/incidents/helpers/identifyCurrentTrespassDocs.js +109 -0
  110. package/src/components/incidents/helpers/isSameHtml.js +13 -0
  111. package/src/components/incidents/helpers/isValidDateFormat.js +14 -0
  112. package/src/components/incidents/helpers/isValidTimeInput.js +11 -0
  113. package/src/components/incidents/helpers/isValidUTCTimeFormat.js +14 -0
  114. package/src/components/incidents/helpers/parseMMDDYYYY.js +7 -0
  115. package/src/components/incidents/helpers/parseQueryString.js +16 -0
  116. package/src/components/incidents/helpers/sortTrespassDocuments.js +44 -0
  117. package/src/components/incidents/helpers/stripHTML.js +11 -0
  118. package/src/components/incidents/helpers/trespassDocUtils.js +197 -0
  119. package/src/components/incidents/helpers/validateTrespassDetails.js +37 -0
  120. package/src/components/incidents/usePersistedColModalLink.js +70 -0
  121. package/src/components/incidents/usePersistedColumns.js +70 -0
  122. package/src/components/incidents/usePersistedSort.js +23 -0
  123. package/src/components/incidents/usePersistedSortModalLink.js +23 -0
  124. package/src/contexts/IncidentContext.js +433 -0
  125. package/src/index.js +61 -0
  126. package/src/routes/Application.js +13 -0
  127. package/src/settings/GetIncidentCategories.js +56 -0
  128. package/src/settings/GetIncidentTypesDetails.js +88 -0
  129. package/src/settings/GetIncidentTypesIds.js +74 -0
  130. package/src/settings/GetLocationsInService.js +54 -0
  131. package/src/settings/GetSingleCustomLocationDetails.js +60 -0
  132. package/src/settings/GetSingleIncidentTypeDetails.js +60 -0
  133. package/src/settings/GetTrespassReasons.js +67 -0
  134. package/src/settings/GetTrespassTemplates.js +51 -0
  135. package/src/settings/IncidentCategoriesPane.js +285 -0
  136. package/src/settings/IncidentCategoriesPane.test.js +229 -0
  137. package/src/settings/IncidentTypeDetailsPane.js +215 -0
  138. package/src/settings/IncidentTypeDetailsPane.test.js +220 -0
  139. package/src/settings/IncidentTypeEditPane.js +211 -0
  140. package/src/settings/IncidentTypeEditPane.test.js +170 -0
  141. package/src/settings/IncidentTypesPaneset.js +167 -0
  142. package/src/settings/IncidentTypesPaneset.test.js +124 -0
  143. package/src/settings/LocationInServiceEditPane.js +320 -0
  144. package/src/settings/LocationsPaneset.js +415 -0
  145. package/src/settings/LocationsPaneset.test.js +106 -0
  146. package/src/settings/ModalDeleteCategory.js +47 -0
  147. package/src/settings/ModalDeleteIncidentType.js +49 -0
  148. package/src/settings/ModalDeleteLocationInService.js +49 -0
  149. package/src/settings/ModalDeleteTrespassReason.js +49 -0
  150. package/src/settings/ModalPreviewTrespassDoc.js +65 -0
  151. package/src/settings/ModalTrespassDocTokens.js +83 -0
  152. package/src/settings/NewIncidentTypePane.js +182 -0
  153. package/src/settings/PutIncidentType.js +60 -0
  154. package/src/settings/PutLocationsInService.js +52 -0
  155. package/src/settings/PutTrespassReasons.js +61 -0
  156. package/src/settings/PutTrespassTemplate.js +50 -0
  157. package/src/settings/TrespassDoc.css +17 -0
  158. package/src/settings/TrespassDocDetailsPane.js +215 -0
  159. package/src/settings/TrespassDocEditPane.js +538 -0
  160. package/src/settings/TrespassDocPaneset.js +581 -0
  161. package/src/settings/TrespassReasonDetailsPane.js +171 -0
  162. package/src/settings/TrespassReasonEditPane.js +221 -0
  163. package/src/settings/TrespassReasonsPaneset.js +282 -0
  164. package/src/settings/__snapshots__/IncidentCategoriesPane.test.js.snap +3 -0
  165. package/src/settings/__snapshots__/IncidentTypeDetailsPane.test.js.snap +3 -0
  166. package/src/settings/__snapshots__/IncidentTypeEditPane.test.js.snap +3 -0
  167. package/src/settings/__snapshots__/IncidentTypesPaneset.test.js.snap +3 -0
  168. package/src/settings/__snapshots__/LocationsPaneset.test.js.snap +3 -0
  169. package/src/settings/data/exampleJSON.json +92 -0
  170. package/src/settings/data/templateTokens.js +396 -0
  171. package/src/settings/helpers/alphabetize.js +18 -0
  172. package/src/settings/helpers/getCategoryTitleById.js +13 -0
  173. package/src/settings/helpers/makeId.js +15 -0
  174. package/src/settings/index.js +48 -0
  175. package/stripes.config.js +10 -0
  176. package/test/jest/__mock__/index.js +8 -0
  177. package/test/jest/__mock__/intl.mock.js +27 -0
  178. package/test/jest/__mock__/stripes.mock.js +26 -0
  179. package/test/jest/__mock__/stripesComponents.mock.js +151 -0
  180. package/test/jest/__mock__/stripesConfig.mock.js +1 -0
  181. package/test/jest/__mock__/stripesCore.mock.js +9 -0
  182. package/test/jest/__mock__/stripesIcon.mock.js +5 -0
  183. package/test/jest/__mock__/stripesSmartComponents.mock.js +7 -0
  184. package/test/jest/__mock__/stripesUtils.mock.js +3 -0
  185. package/test/jest/eslintrc.js +12 -0
  186. package/test/jest/setupFiles.js +5 -0
  187. package/translations/ui-security-incident/en_US.json +542 -0
  188. package/ui-module-acceptance-criteria.md +34 -0
@@ -0,0 +1,53 @@
1
+
2
+ // intlDTFResolvedOptionsTZ = Intl.DateTimeFormat().resolvedOptions().timeZone
3
+ const cleanFormValues = (formParamsObject, orgTZ, intlDTFResolvedOptionsTZ) => {
4
+ const trimmedTerm = formParamsObject.term?.trim();
5
+ const cleanedSearchParams = Object.keys(formParamsObject).reduce((acc, key) => {
6
+ const value = formParamsObject[key];
7
+
8
+ // includeSuppressed should only be sent when it is true
9
+ if (key === 'includeSuppressed' && value !== true) {
10
+ return acc; // skip when false or undefined
11
+ }
12
+
13
+ if ((key === 'currentTrespass' || key === 'expiredTrespass') && value !== true) {
14
+ return acc; //skip this key
15
+ };
16
+
17
+ // handle searchType when it is 'created-by' or 'witnessed-by'
18
+ // is case of use UI Select for those keys and move them to its own key outside of searchType
19
+ // so single source of truth (UI has options for searchType Select OR single text field filter)
20
+ if (key === 'searchType' && value === 'created-by' && trimmedTerm) {
21
+ acc['createdBy'] = trimmedTerm;
22
+ return acc; //skip adding 'searchType' and 'term'
23
+ } else if (key === 'searchType' && value === 'witnessed-by' && trimmedTerm) {
24
+ acc['witnessedBy'] = trimmedTerm;
25
+ return acc; //skip adding 'searchType' and 'term'
26
+ };
27
+
28
+ // check for searchType without term
29
+ if (key === 'searchType' && !trimmedTerm) {
30
+ return acc; //skip adding 'searchType' without 'term'
31
+ };
32
+
33
+ // if value is array and empty, skip it (locationValue, incidentTypeId)
34
+ if (Array.isArray(value) && value.length === 0) {
35
+ return acc;
36
+ };
37
+
38
+ // add only non-empty, non-null, non-undefined values
39
+ if (value !== '' && value !== null && value !== undefined) {
40
+ acc[key] = key === 'term' ? trimmedTerm : value;
41
+ };
42
+
43
+ return acc;
44
+ }, {});
45
+
46
+ if (cleanedSearchParams.startDate && cleanedSearchParams.endDate) {
47
+ cleanedSearchParams.timezone = orgTZ || intlDTFResolvedOptionsTZ;
48
+ };
49
+
50
+ return cleanedSearchParams;
51
+ };
52
+
53
+ export default cleanFormValues;
@@ -0,0 +1,124 @@
1
+ import { isSameHtml } from './isSameHtml.js';
2
+ import deepNormalizeForComparison from './deepNormalizeForComparison.js';
3
+
4
+ // ***** helpers *****
5
+ const stripUIOnlyKeys = (cust = {}) => {
6
+ const {
7
+ associatedFirstName,
8
+ associatedLastName,
9
+ ...rest
10
+ } = cust || {};
11
+ return rest;
12
+ };
13
+
14
+ const deepMergeWithFallback = (fallback, curr) => {
15
+ if (Array.isArray(fallback) || Array.isArray(curr)) {
16
+ return Array.isArray(curr) ? curr : fallback;
17
+ }
18
+ if (fallback && typeof fallback === 'object' && curr && typeof curr === 'object') {
19
+ const out = { ...fallback };
20
+ for (const k of Object.keys(curr)) {
21
+ out[k] = deepMergeWithFallback(fallback[k], curr[k]);
22
+ }
23
+ return out;
24
+ }
25
+ return curr !== undefined ? curr : fallback;
26
+ };
27
+
28
+ // remove empty/whitespace-only keys from details, and text-normalize description
29
+ const normalizeCustomerForComparison = (cust) => {
30
+ const {
31
+ details,
32
+ description = '',
33
+ ...rest
34
+ } = stripUIOnlyKeys(cust);
35
+
36
+ const canonTrespass = (() => {
37
+ const t = rest?.trespass;
38
+ if (!t) return undefined;
39
+
40
+ const raw = Array.isArray(t.exclusionOrTrespassBasedOn)
41
+ ? t.exclusionOrTrespassBasedOn
42
+ : [];
43
+
44
+ const ids = raw
45
+ .map(e =>
46
+ typeof e === 'string'
47
+ ? e
48
+ : (e?.id ?? e?.reasonId ?? e?.value ?? '')
49
+ )
50
+ .filter(Boolean);
51
+
52
+ // treat as set for meaningful change
53
+ const uniqSortedIds = Array.from(new Set(ids)).sort();
54
+
55
+ return {
56
+ ...t,
57
+ exclusionOrTrespassBasedOn: uniqSortedIds,
58
+ };
59
+ })();
60
+
61
+ // strip whitespace-only or empty-string keys from details
62
+ const cleanedDetails = Object.entries(details || {}).reduce((acc, [k, v]) => {
63
+ const trimmed = typeof v === 'string' ? v.trim() : v;
64
+ if (trimmed !== '' && trimmed !== null && trimmed !== undefined) {
65
+ acc[k] = trimmed;
66
+ }
67
+ return acc;
68
+ }, {});
69
+ const hasRealDetails = Object.keys(cleanedDetails).length > 0;
70
+
71
+ const cleaned = {
72
+ ...rest,
73
+ ...(hasRealDetails ? { details: cleanedDetails } : {}),
74
+ ...(canonTrespass ? { trespass: canonTrespass } : {}),
75
+ };
76
+
77
+ // IMPORTANT: we keep description for HTML semantic equality first;
78
+ // only if it’s not empty do we store a text-normalized version for deep compare.
79
+ if (description && !isSameHtml(description, '')) {
80
+ const text = typeof window !== 'undefined'
81
+ ? new DOMParser().parseFromString(description, 'text/html').body.textContent.trim()
82
+ : description; // SSR safety fallback
83
+ cleaned.description = text;
84
+ };
85
+
86
+ return deepNormalizeForComparison(cleaned);
87
+ };
88
+
89
+
90
+ // ***** main *****
91
+ const computeEditedCustomers = (initialFormData, allCustomers) => {
92
+ const edited = new Set();
93
+
94
+ for (const init of (initialFormData.customers || [])) {
95
+ const curr = (allCustomers || []).find(c => c.id === init.id);
96
+ if (!curr) {
97
+ // removed entirely => top-level change; not a per-customer edit here
98
+ continue;
99
+ }
100
+
101
+ // first, check HTML semantic equality for description (cheap early exit)
102
+ const initDesc = stripUIOnlyKeys(init).description;
103
+ const currDesc = stripUIOnlyKeys(curr).description;
104
+ if (!isSameHtml(initDesc, currDesc)) {
105
+ edited.add(init.id);
106
+ continue; // already edited; no need to do deeper compare
107
+ }
108
+
109
+ // merge current over initial so missing fields fall back
110
+ const merged = deepMergeWithFallback(init, curr);
111
+
112
+ // normalize both sides the same way (strip UI keys, clean details/description, normalize)
113
+ const normInit = normalizeCustomerForComparison(init);
114
+ const normCurr = normalizeCustomerForComparison(merged);
115
+
116
+ if (JSON.stringify(normInit) !== JSON.stringify(normCurr)) {
117
+ edited.add(init.id);
118
+ }
119
+ }
120
+
121
+ return edited;
122
+ };
123
+
124
+ export default computeEditedCustomers;
@@ -0,0 +1,8 @@
1
+
2
+ // used for rendering instance values where only the date is considered
3
+ function convertDateIgnoringTZ(isoString) {
4
+ const [yyyy, mm, dd] = isoString.split('T')[0].split('-');
5
+ return `${mm}/${dd}/${yyyy}`;
6
+ };
7
+
8
+ export default convertDateIgnoringTZ;
@@ -0,0 +1,15 @@
1
+
2
+
3
+ const convertUTCISOToLocalePrettyTime = (isoString) => {
4
+ const date = new Date(isoString);
5
+ if (isNaN(date)) {
6
+ return `Invalid ISO string`
7
+ }
8
+ return date.toLocaleTimeString('en-US', {
9
+ hour: 'numeric',
10
+ minute: '2-digit',
11
+ hour12: true
12
+ });
13
+ }
14
+
15
+ export default convertUTCISOToLocalePrettyTime;
@@ -0,0 +1,19 @@
1
+
2
+ const convertUTCISOToPrettyDate = (dateStr) => {
3
+ if (!dateStr || typeof dateStr !== 'string') return '';
4
+
5
+ try {
6
+ const date = new Date(dateStr);
7
+ if (isNaN(date.getTime())) return '';
8
+
9
+ return date.toLocaleDateString(undefined, {
10
+ year: 'numeric',
11
+ month: '2-digit',
12
+ day: '2-digit'
13
+ });
14
+ } catch {
15
+ return '';
16
+ }
17
+ };
18
+
19
+ export default convertUTCISOToPrettyDate;
@@ -0,0 +1,20 @@
1
+
2
+ const decodeParamsToForm = (obj) => ({
3
+ searchType: obj.searchType || 'keyword',
4
+ term: obj.term || '',
5
+ locationValue: obj.locationValue ?
6
+ obj.locationValue.split(',') : [],
7
+ incidentTypeId: obj.incidentTypeId ?
8
+ obj.incidentTypeId.split(',') : [],
9
+ witnessedBy: obj.witnessedBy || '',
10
+ createdBy: obj.createdBy || '',
11
+ startDate: obj.startDate || '',
12
+ endDate: obj.endDate || '',
13
+ currentTrespass: obj.currentTrespass === 'true',
14
+ expiredTrespass: obj.expiredTrespass === 'true',
15
+ staffSuppress: obj.staffSuppress || 'non',
16
+ sort: obj.sort || '',
17
+ dir: obj.dir || ''
18
+ });
19
+
20
+ export default decodeParamsToForm;
@@ -0,0 +1,39 @@
1
+
2
+
3
+ const deepNormalizeForComparison = (obj) => {
4
+ const DATE_KEYS = new Set(['dateOfOccurrence', 'endDateOfTrespass', 'date']);
5
+
6
+ const toLocalYMD = (d) => {
7
+ if (!d || typeof d !== 'string') return d;
8
+ const md = d.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
9
+ if (md) {
10
+ const [ , mm, dd, yyyy ] = md;
11
+ return `${yyyy}-${mm}-${dd}`;
12
+ }
13
+ const iso = d.match(/^\d{4}-\d{2}-\d{2}T/);
14
+ if (iso) {
15
+ const date = new Date(d);
16
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
17
+ }
18
+ return d.trim();
19
+ };
20
+
21
+ if (Array.isArray(obj)) {
22
+ return obj.map(deepNormalizeForComparison);
23
+ }
24
+
25
+ if (obj && typeof obj === 'object') {
26
+ return Object.entries(obj)
27
+ .filter(([_, v]) => v !== undefined && v !== null && v !== '')
28
+ .sort(([a], [b]) => a.localeCompare(b)) // sort object keys
29
+ .reduce((acc, [k, v]) => {
30
+ acc[k] = typeof v === 'string'
31
+ ? (DATE_KEYS.has(k) ? toLocalYMD(v.trim()) : v.trim())
32
+ : deepNormalizeForComparison(v);
33
+ return acc;
34
+ }, {});
35
+ }
36
+ return obj;
37
+ };
38
+
39
+ export default deepNormalizeForComparison;
@@ -0,0 +1,12 @@
1
+
2
+ import buildQueryString from "./buildQueryString";
3
+
4
+ const extractFilterString = (params = {}) => {
5
+ // return valid URL if no params
6
+ if (Object.keys(params).length === 0) return "";
7
+
8
+ const { limit, offset, ...filters } = params;
9
+ return buildQueryString(filters);
10
+ }
11
+
12
+ export default extractFilterString;
@@ -0,0 +1,14 @@
1
+ /*
2
+ - concatenate 'mm/dd/yyyy' string and 'HH:MM AM/PM' string and format
3
+ to UTC ISO format
4
+ @param {string} dateString - string set by DatePicker component
5
+ @param {string} timeString - string set by TimePicker component
6
+ @returns {string} ISO formatted date string, or null if input is not present
7
+ */
8
+ const formatDateAndTimeToUTCISO = (dateString, timeString) => {
9
+ if (!dateString || !timeString) return null;
10
+ const utcDateTime = new Date(`${dateString} ${timeString}`).toISOString();
11
+ return utcDateTime
12
+ };
13
+
14
+ export default formatDateAndTimeToUTCISO;
@@ -0,0 +1,14 @@
1
+ /*
2
+ - format 'mm/dd/yyyy' (local date) string to UTC ISO format date/time at start of day, with optional add offset in seconds
3
+ @param {string} dateString - string set by DatePicker component
4
+ @param {number} offsetInSeconds - Optional - defaults to 0
5
+ @returns {string|null} ISO formatted date string as local machine date, or null if input is not present (EX: If in Pacific Time and dateString = '01/02/2025', the return is '2025-01-02T08:00:00.000Z' or with optional offsetInSeconds ('01/02/2025', 1) the return is '2025-01-02T08:00:01.000Z')
6
+ */
7
+ const formatDateToUTCISO = (dateString, offsetInSeconds = 0) => {
8
+ if (!dateString) return null;
9
+ const dateObj = new Date(dateString); // interprets as midnight in local tz
10
+ dateObj.setHours(0, 0, offsetInSeconds, 0); // force local time to midnight, offset seconds
11
+ return dateObj.toISOString(); // convert internal epoch time to UTC ISO
12
+ };
13
+
14
+ export default formatDateToUTCISO;
@@ -0,0 +1,28 @@
1
+ /*
2
+ - format timeString to to UTC ISO w/ associated 'dateOfIncident'
3
+ as date value the string
4
+ @param {string} dateOfIncidentString - user inputted value from Datepicker
5
+ component fed via formData
6
+ (Ex: "10/30/2024")
7
+ @param {string} timeString - user inputted value from Timepicker component
8
+ (Ex: "9:33 AM")
9
+ @returns {string} UTC ISO formatted datetime string, or null if input is not present
10
+ (Ex: '2024-10-16T17:27:00.000Z')
11
+ */
12
+
13
+ const formatTimeToUTCISO = (dateOfIncidentString, timeString) => {
14
+ if (!dateOfIncidentString || !timeString) {
15
+ return null;
16
+ };
17
+
18
+ // check if timeString is already in ISO
19
+ if (!isNaN(Date.parse(timeString))) {
20
+ console.log("timeString: ", timeString)
21
+ return timeString;
22
+ }
23
+ const utcDateTime = new Date(`${dateOfIncidentString} ${timeString}`).toISOString();
24
+ // console.log("utcDateTime: ", utcDateTime)
25
+ return utcDateTime;
26
+ }
27
+
28
+ export default formatTimeToUTCISO;
@@ -0,0 +1,20 @@
1
+ /*
2
+ - @returns {string} 'hh:mm AM/PM' to populate in TimePicker component on initial render
3
+ */
4
+
5
+ const getCurrentTime = () => {
6
+ const now = new Date();
7
+ const hours = now.getHours();
8
+ const minutes = now.getMinutes();
9
+ const ampm = hours >= 12 ? 'PM' : 'AM';
10
+ const formattedHours = hours % 12 || 12; // 12-hour format
11
+ // console.log(`hours before format: ${hours} - formattedHours: ${formattedHours}`)
12
+ const formattedMinutes = minutes.toString().padStart(2, '0');
13
+ const currentTime = `${formattedHours}:${formattedMinutes} ${ampm}`
14
+ // return `${formattedHours}:${formattedMinutes} ${ampm}`
15
+ // console.log("@getCurrentTime - currentTime: ", currentTime)
16
+ return currentTime;
17
+ }
18
+
19
+ // console.log(getCurrentTime());
20
+ export default getCurrentTime;
@@ -0,0 +1,12 @@
1
+ /*
2
+ - @returns {string} 'mm/dd/yyyy' to populate in DatePicker component on initial render
3
+ */
4
+ const getTodayDate = () => {
5
+ const today = new Date();
6
+ const day = String(today.getDate()).padStart(2, '0');
7
+ const month = String(today.getMonth() + 1).padStart(2, '0');
8
+ const year = today.getFullYear();
9
+ return `${month}/${day}/${year}`;
10
+ };
11
+
12
+ export default getTodayDate;
@@ -0,0 +1,148 @@
1
+ import DOMPurify from 'dompurify';
2
+ import { decode } from 'html-entities';
3
+ import { FormattedMessage } from 'react-intl';
4
+ import Handlebars from 'handlebars';
5
+ import convertUTCISOToPrettyDate from './convertUTCISOToPrettyDate';
6
+
7
+ export function registerHandlebarsHelpers({
8
+ locationDataOptions,
9
+ trespassReasons,
10
+ self,
11
+ triggerDocumentError
12
+ }) {
13
+
14
+ const reasonById = new Map((trespassReasons ?? []).map(tr => [tr.id, tr.reason]));
15
+ if (!Handlebars.helpers.resolveTrespassReasons) {
16
+ // no 'path', Handlebars handles scope on this one
17
+ // token -> {{resolveTrespassReasons customer.trespass.exclusionOrTrespassBasedOn}}
18
+ Handlebars.registerHelper('resolveTrespassReasons', function(list) {
19
+ try {
20
+ if (!Array.isArray(list) || list.length === 0) return '';
21
+
22
+ const seen = new Set();
23
+ const reasons = [];
24
+
25
+ for (const entry of list) {
26
+ // handle for prev set up / strays after migration - entry is a string id
27
+ if (typeof entry === 'string') {
28
+ const r = reasonById.get(entry);
29
+ if (r && !seen.has(r)) { reasons.push(r); seen.add(r); }
30
+ continue;
31
+ }
32
+
33
+ // current - entry is an object
34
+ if (entry && typeof entry === 'object') {
35
+ const id = entry.id ?? entry.reasonId ?? entry.value;
36
+ const r = entry.reason ?? (id ? reasonById.get(id) : undefined);
37
+ if (r && !seen.has(r)) { reasons.push(r); seen.add(r); }
38
+ }
39
+ }
40
+
41
+ return reasons.join(', ');
42
+
43
+ } catch (error) {
44
+ triggerDocumentError?.(<FormattedMessage id="generate-trespass.error-doc-incTypes" values={{ error: error.message }} />);
45
+ return '';
46
+ }
47
+ });
48
+ }
49
+ if (!Handlebars.helpers.resolveIncTypes) {
50
+ // token -> {{resolveIncTypes "incident.incidentTypes.title"}}
51
+ Handlebars.registerHelper('resolveIncTypes', function(path) {
52
+ try {
53
+ const keys = path.split('.');
54
+ const propertyKey = keys.pop();
55
+ let arrayValue = this;
56
+ for (const key of keys) arrayValue = arrayValue?.[key];
57
+ if (!Array.isArray(arrayValue)) return '';
58
+ return arrayValue
59
+ .map(item => item?.[propertyKey] || '')
60
+ .filter(Boolean)
61
+ .join(', ');
62
+ } catch (error) {
63
+ triggerDocumentError?.(<FormattedMessage id="generate-trespass.error-doc-incTypes" values={{ error: error.message }} />);
64
+ return '';
65
+ }
66
+ });
67
+ }
68
+
69
+ if (!Handlebars.helpers.formatLocation) {
70
+ // tokens ->
71
+ // {{formatLocation incident.incidentLocation}}
72
+ // {{formatLocation customer.trespass.declarationOfService.placeSigned}}
73
+ Handlebars.registerHelper('formatLocation', function(locationIdString) {
74
+ try {
75
+ const matched = locationDataOptions.find(loc => loc.value === locationIdString);
76
+ return matched?.label || locationIdString || '';
77
+ } catch (error) {
78
+ triggerDocumentError?.(<FormattedMessage id="generate-trespass.error-doc-locationIdString" values={{ error: error.message }} />);
79
+ return locationIdString || '';
80
+ }
81
+ });
82
+ }
83
+
84
+ if (!Handlebars.helpers.formatDate) {
85
+ // tokens ->
86
+ // {{formatDate (resolve "customer.trespass.dateOfOccurrence")}}
87
+ // {{formatDate (resolve "customer.trespass.declarationOfService.date")}}
88
+ // {{formatDate (resolve "customer.trespass.endDateOfTrespass")}}
89
+ Handlebars.registerHelper('formatDate', function(dateString) {
90
+ try {
91
+ return convertUTCISOToPrettyDate(dateString);
92
+ } catch (error) {
93
+ triggerDocumentError(<FormattedMessage id="generate-trespass.error-doc-formatDate" values={{ dateString, error: error.message }} />);
94
+ return '';
95
+ }
96
+ });
97
+ }
98
+
99
+ if (!Handlebars.helpers.resolve) {
100
+ // runs on customer keys and nested keys
101
+ Handlebars.registerHelper('resolve', function(path, options) {
102
+ try {
103
+ const keys = path.split('.');
104
+ let value = this;
105
+ for (const key of keys) value = value?.[key];
106
+ if (Array.isArray(value)) {
107
+ const prop = options.hash?.property;
108
+ return prop
109
+ ? value.map(item => item?.[prop] || '').join(', ')
110
+ : value.map(item => JSON.stringify(item)).join(', ');
111
+ }
112
+ return value ?? '';
113
+ } catch (error) {
114
+ triggerDocumentError(<FormattedMessage id="generate-trespass.error-doc-resolve" values={{ path, error: error.message }} />);
115
+ return '';
116
+ }
117
+ });
118
+ }
119
+
120
+ if (!Handlebars.helpers.trespassDescriptionPlain) {
121
+ // token -> {{{trespassDescriptionPlain}}}
122
+ Handlebars.registerHelper('trespassDescriptionPlain', function () {
123
+ try {
124
+ const html = this?.customer?.trespass?.description?.trim() || this?.customer?.trespass?.descriptionOfOccurrence?.trim() || '';
125
+
126
+ // preserve HTML blocks
127
+ const raw = decode(DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }));
128
+ // return raw;
129
+ return new Handlebars.SafeString(raw);
130
+ } catch (error) {
131
+ triggerDocumentError(<FormattedMessage id="generate-trespass.error-doc-descriptionPlainFallback" values={{ error: error.message }} />);
132
+ return '';
133
+ }
134
+ });
135
+ }
136
+
137
+ if (!Handlebars.helpers.declarationOfServiceIssuedBy) {
138
+ // token -> {{declarationOfServiceIssuedBy}} (is the logged in FOLIO user 'self')
139
+ Handlebars.registerHelper('declarationOfServiceIssuedBy', function () {
140
+ try {
141
+ return `${self?.lastName || ''}, ${self?.firstName || ''}` || 'User not found';
142
+ } catch (error) {
143
+ triggerDocumentError(<FormattedMessage id="generate-trespass.error-doc-declarationOfServiceIssuedBy" values={{ error: error.message }} />);
144
+ return '';
145
+ }
146
+ });
147
+ }
148
+ };
@@ -0,0 +1,50 @@
1
+
2
+ const hasFormChangedAtCreate = ({
3
+ formData,
4
+ initialFormData,
5
+ selectedCustomers,
6
+ selectedWitnesses,
7
+ editorWasTouched,
8
+ }) => {
9
+ // ignore these keys as they are populated on render
10
+ const keysToIgnore = ['dateOfIncident', 'timeOfIncident', 'id'];
11
+
12
+ if (selectedCustomers.length > 0) return true;
13
+ if (selectedWitnesses.length > 0) return true;
14
+ if (editorWasTouched) {
15
+ // we check for was touched instead of formData was mutated b/c of our onBlur pattern to mitigate
16
+ // react-quill's cleanup <-> setState feedback loop.
17
+ // i.e. user inputs to Editor, doesn't make onBlur and clicks directly to 'cancel' the form
18
+ // would not be considered 'dirty' as no update to formData. This editorWasTouch ref handles that case.
19
+ return true;
20
+ }
21
+
22
+ for (const key in formData) {
23
+ if (keysToIgnore.includes(key)) continue;
24
+
25
+ const original = initialFormData[key] ?? null;
26
+ const current = formData[key] ?? null;
27
+
28
+ if (Array.isArray(original) && Array.isArray(current)) {
29
+ if (original.length !== current.length) {
30
+ return true;
31
+ }
32
+
33
+ if (JSON.stringify(original) !== JSON.stringify(current)) {
34
+ return true;
35
+ }
36
+ } else if (typeof original === 'string' || typeof current === 'string') {
37
+ if ((original || '').trim() !== (current || '').trim()) {
38
+ return true;
39
+ }
40
+ } else {
41
+ if (JSON.stringify(original) !== JSON.stringify(current)) {
42
+ return true;
43
+ }
44
+ }
45
+ };
46
+
47
+ return false;
48
+ };
49
+
50
+ export default hasFormChangedAtCreate;
@@ -0,0 +1,90 @@
1
+
2
+ import { isSameHtml } from './isSameHtml.js';
3
+ import deepNormalizeForComparison from './deepNormalizeForComparison.js';
4
+
5
+ // intentionally does not consider:
6
+ // selectedCustomers, customers add/remove, staffSuppressed,
7
+ // isApproximateTime, media add/remove -> these should not force global 'Update declaration'
8
+ const hasTopLevelChangeAffectedDeclaration = (
9
+ initial,
10
+ current,
11
+ selectedWitnesses
12
+ ) => {
13
+ const simpleKeys = [
14
+ 'customerNa',
15
+ 'incidentLocation',
16
+ 'subLocation',
17
+ 'dateTimeOfIncident',
18
+ 'timeOfIncident'
19
+ ];
20
+
21
+ // return true if any changes to simple keys
22
+ for (const key of simpleKeys) {
23
+ if (current[key] !== initial[key]) {
24
+ return true;
25
+ }
26
+ };
27
+
28
+ // return true if any changes to top-level detailedDescriptionOfIncident
29
+ if (!isSameHtml(current.detailedDescriptionOfIncident, initial.detailedDescriptionOfIncident)) {
30
+ return true;
31
+ };
32
+
33
+ // return true if any new witnesses selected for saving
34
+ if (selectedWitnesses.length > 0) {
35
+ return true;
36
+ };
37
+
38
+ const getSortedIds = arr => (arr || []).map(i => i.id).sort();
39
+
40
+ // return true if relevant item is removed in UI
41
+ if (
42
+ JSON.stringify(getSortedIds(current.incidentTypes)) !== JSON.stringify(getSortedIds(initial.incidentTypes)) ||
43
+ JSON.stringify(getSortedIds(current.incidentWitnesses)) !== JSON.stringify(getSortedIds(initial.incidentWitnesses)) ||
44
+ JSON.stringify(getSortedIds(current.attachments)) !== JSON.stringify(getSortedIds(initial.attachments))
45
+ ) {
46
+ return true;
47
+ };
48
+
49
+ const normalizedWitness = (wit) => {
50
+ const {
51
+ id,
52
+ isCustom,
53
+ firstName = '',
54
+ lastName = '',
55
+ role = '',
56
+ phone = '',
57
+ email = '',
58
+ } = wit;
59
+
60
+ return deepNormalizeForComparison({
61
+ id,
62
+ isCustom,
63
+ firstName: firstName.trim(),
64
+ lastName: lastName.trim(),
65
+ ...(role.trim() && { role: role.trim() }),
66
+ ...(phone.trim() && { phone: phone.trim() }),
67
+ ...(email.trim() && { email: email.trim() }),
68
+ });
69
+ };
70
+
71
+ const initialCustomWitnesses = (initial.incidentWitnesses || []).filter(w => w.isCustom);
72
+ const currentCustomWitnesses = (current.incidentWitnesses || []).filter(w => w.isCustom);
73
+
74
+ // check for any modified custom witness
75
+ for (const initWit of initialCustomWitnesses) {
76
+ const currWit = currentCustomWitnesses.find(w => w.id === initWit.id);
77
+ if (!currWit) return true;
78
+
79
+ const normInit = normalizedWitness(initWit);
80
+ const normCurr = normalizedWitness(currWit);
81
+
82
+ if (JSON.stringify(normInit) !== JSON.stringify(normCurr)) {
83
+ return true;
84
+ };
85
+ };
86
+
87
+ return false; // no changes
88
+ };
89
+
90
+ export default hasTopLevelChangeAffectedDeclaration;