@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,406 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { useIntl, FormattedMessage } from 'react-intl';
3
+ import { useStripes } from '@folio/stripes/core';
4
+ import {
5
+ Button,
6
+ Icon,
7
+ LoadingPane,
8
+ Modal,
9
+ ModalFooter,
10
+ MultiColumnList,
11
+ Pane,
12
+ Paneset,
13
+ PaneHeader,
14
+ SearchField,
15
+ } from '@folio/stripes/components';
16
+ import css from './ModalStyle.css';
17
+ import { useIncidents } from '../../contexts/IncidentContext';
18
+ import SearchCustomerOrWitness from './SearchCustomerOrWitness';
19
+ import ProfilePicture from './helpers/ProfilePicture/ProfilePicture.js';
20
+ import GetPatronGroups from './GetPatronGroups';
21
+
22
+ const ModalSelectWitness = ({
23
+ context,
24
+ setFormData,
25
+ formData,
26
+ setRemovedWitnessIds,
27
+ removedWitnessIds}) => {
28
+ const stripes = useStripes();
29
+ const intl = useIntl();
30
+ const [patronGroups, setPatronGroups] = useState([]);
31
+ const [searchTerm, setSearchTerm] = useState('');
32
+ const [search, setSearch] = useState('');
33
+
34
+ const {
35
+ isModalSelectWitness,
36
+ closeModalSelectWitness,
37
+ isLoadingSearch,
38
+ selectedWitnesses,
39
+ setSelectedWitnesses,
40
+ setCustomers,
41
+ customers, // response array
42
+ } = useIncidents();
43
+
44
+ const hasViewProfilePicturePerm = stripes.hasPerm('ui-users.profile-pictures.view');
45
+
46
+ let endOflistTotal = 0;
47
+
48
+ // set end of list value
49
+ if (customers) {
50
+ endOflistTotal = customers.length;
51
+ };
52
+
53
+ const handleChange = (event) => {
54
+ const term = event.target.value;
55
+ setSearchTerm(term);
56
+ };
57
+
58
+ const handleSearchSubmit = () => {
59
+ setSearch(searchTerm.trim());
60
+ };
61
+
62
+ // toggle handler for context of edit
63
+ const handleToggleWitness = (data) => {
64
+ const isInstanceWitness = formData.incidentWitnesses.some(wit => wit.id === data.id);
65
+ const isRemovedWitness = removedWitnessIds.includes(data.id);
66
+ if (isInstanceWitness && !isRemovedWitness) {
67
+ setFormData(prevFormData => ({
68
+ ...prevFormData,
69
+ incidentWitnesses: prevFormData.incidentWitnesses.filter(wit => wit.id !== data.id)
70
+ }));
71
+ setRemovedWitnessIds(prev => [...prev, data.id]); //mark as removed
72
+ return;
73
+ };
74
+ // handle adding / removing from selectedWitnesses
75
+ setSelectedWitnesses((prevState) => {
76
+ const index = prevState.findIndex((cust) => cust.id === data.id);
77
+ if (index > -1) {
78
+ // remove witness from selectedWitnesses
79
+ return prevState.filter((cust) => cust.id !== data.id);
80
+ } else {
81
+ // add witness to selectedWitnesses
82
+ return [...prevState, data];
83
+ }
84
+ });
85
+ // handle if instance wit removed, remove from removedWitnessIds
86
+ // so can be treated as a newly selected witness
87
+ if(isRemovedWitness) {
88
+ setRemovedWitnessIds(prev => prev.filter(id => id !== data.id))
89
+ };
90
+ // allow keyboard only users immediate access to the close button on select
91
+ document.getElementById('close-continue-button').focus();
92
+ };
93
+
94
+ const handleSave = () => {
95
+ closeModalSelectWitness();
96
+ setCustomers([]);
97
+ setSearch('');
98
+ };
99
+
100
+ const handleDismissClose = () => {
101
+ closeModalSelectWitness();
102
+ setCustomers([]);
103
+ setSearch('');
104
+ };
105
+
106
+ const handleKeyDown = (event) => {
107
+ if (event.key === 'Enter') {
108
+ handleSearchSubmit();
109
+ }
110
+ };
111
+
112
+ // formatter for context of edit
113
+ const resultsFormatter = {
114
+ active: (item) => {
115
+ return <p>{item.active ?
116
+ (<FormattedMessage id="modal-select-customer.resultsFormatter-active"/>)
117
+ :
118
+ (<FormattedMessage id="modal-select-customer.resultsFormatter-inactive"/>)}
119
+ </p>;
120
+ },
121
+ name: (item) => {
122
+ if (item.middleName && item.middleName !== '') {
123
+ return `${item.lastName}, ${item.firstName} ${item.middleName}`;
124
+ } else {
125
+ return `${item.lastName}, ${item.firstName}`;
126
+ }
127
+ },
128
+ patronGroup: (item) => {
129
+ const patronGroupName = patronGroups.find((pg) => pg.id === item.patronGroup);
130
+ return patronGroupName ? patronGroupName.group : null;
131
+ },
132
+ id: (item) => {
133
+ const isWitnessSelected = selectedWitnesses.some(
134
+ (cust) => cust.id === item.id
135
+ );
136
+ const isInstanceWitness = formData.incidentWitnesses.some(
137
+ (wit) => wit.id === item.id
138
+ );
139
+ const isRemovedWitness = removedWitnessIds.includes(item.id);
140
+ const showCheckMark = (isWitnessSelected || isInstanceWitness) && !isRemovedWitness;
141
+ const buttonStyle = showCheckMark ? 'success' : 'primary';
142
+ const buttonText = showCheckMark ? <Icon icon="check-circle" /> : 'Add';
143
+ const custData = {
144
+ id: item.id,
145
+ firstName: item.firstName,
146
+ lastName: item.lastName,
147
+ barcode: item.barcode,
148
+ };
149
+ return (
150
+ <Button
151
+ onClick={() => handleToggleWitness(custData)}
152
+ buttonStyle={buttonStyle}
153
+ >
154
+ {buttonText}
155
+ </Button>
156
+ );
157
+ },
158
+ profilePicLinkOrUUID: (item) => {
159
+ return (
160
+ <div style={{
161
+ height: '100%',
162
+ width: '100%',
163
+ display: 'flex',
164
+ justifyContent: 'center', // horizontal
165
+ alignItems: 'center' // vertical
166
+ }}>
167
+ {/* fixed-size box to prevent overflow/shift */}
168
+ <div
169
+ style={{
170
+ width: 100,
171
+ height: 100,
172
+ display: 'flex',
173
+ alignItems: 'center',
174
+ justifyContent: 'center'
175
+ }}>
176
+ <ProfilePicture profilePictureLink={item.profilePicLinkOrUUID} />
177
+ </div>
178
+ </div>
179
+ )
180
+ }
181
+ };
182
+
183
+ const handleToggleWitnessCreate = (data) => {
184
+ setSelectedWitnesses((prevState) => {
185
+ const index = prevState.findIndex((cust) => cust.id === data.id);
186
+ if (index > -1) {
187
+ // remove witness from selectedWitnesses
188
+ return prevState.filter((cust) => cust.id !== data.id);
189
+ } else {
190
+ // add witness to selectedWitnesses
191
+ return [...prevState, data];
192
+ }
193
+ });
194
+ // allow keyboard only users immediate access to the close button on select
195
+ document.getElementById('close-continue-button').focus();
196
+ };
197
+
198
+
199
+ // formatter for context of create
200
+ const resultsFormatterCreate = {
201
+ active: (item) => {
202
+ return <p>{item.active === true ?
203
+ (<FormattedMessage id="modal-select-customer.resultsFormatter-active"/>)
204
+ :
205
+ (<FormattedMessage id="modal-select-customer.resultsFormatter-inactive"/>)}
206
+ </p>;
207
+ },
208
+ name: (item) => {
209
+ if (item.middleName && item.middleName !== '') {
210
+ return `${item.lastName}, ${item.firstName} ${item.middleName}`;
211
+ } else {
212
+ return `${item.lastName}, ${item.firstName}`;
213
+ }
214
+ },
215
+ patronGroup: (item) => {
216
+ const patronGroupName = patronGroups.find((pg) => pg.id === item.patronGroup);
217
+ return patronGroupName ? patronGroupName.group : null;
218
+ },
219
+ id: (item) => {
220
+ const isWitnessSelected = selectedWitnesses.some(
221
+ (cust) => cust.id === item.id
222
+ );
223
+ const buttonStyle = isWitnessSelected ? 'success' : 'primary';
224
+ const buttonText = isWitnessSelected ? (
225
+ <Icon icon="check-circle" />
226
+ ) : (
227
+ 'Add'
228
+ );
229
+ const custData = {
230
+ id: item.id,
231
+ firstName: item.firstName,
232
+ lastName: item.lastName,
233
+ barcode: item.barcode,
234
+ };
235
+ return (
236
+ <Button
237
+ onClick={() => handleToggleWitnessCreate(custData)}
238
+ buttonStyle={buttonStyle}
239
+ >
240
+ {buttonText}
241
+ </Button>
242
+ );
243
+ },
244
+ profilePicLinkOrUUID: (item) => {
245
+ return (
246
+ <div style={{
247
+ height: '100%',
248
+ width: '100%',
249
+ display: 'flex',
250
+ justifyContent: 'center', // horizontal
251
+ alignItems: 'center' // vertical
252
+ }}>
253
+ {/* fixed-size box to prevent overflow/shift */}
254
+ <div
255
+ style={{
256
+ width: 100,
257
+ height: 100,
258
+ display: 'flex',
259
+ alignItems: 'center',
260
+ justifyContent: 'center'
261
+ }}>
262
+ <ProfilePicture profilePictureLink={item.profilePicLinkOrUUID} />
263
+ </div>
264
+ </div>
265
+ )
266
+ }
267
+ };
268
+
269
+ const isFormDataPresent = () => {
270
+ const isTermValid = searchTerm && searchTerm.trim() !== '';
271
+ return isTermValid;
272
+ };
273
+
274
+ const resultCount = intl.formatMessage(
275
+ { id: `modal-select-witness.results-pane.paneSubTitle` },
276
+ { count: customers.length }
277
+ );
278
+
279
+ if (!isModalSelectWitness) {
280
+ return null;
281
+ };
282
+
283
+ const renderHeader = (renderProps) => (
284
+ <PaneHeader {...renderProps} paneSub={resultCount} />
285
+ );
286
+
287
+ const footer = (
288
+ <ModalFooter>
289
+ <Button
290
+ id="close-continue-button"
291
+ onClick={handleSave}
292
+ buttonStyle="primary"
293
+ marginBottom0
294
+ >
295
+ <FormattedMessage id="close-continue-button" />
296
+ </Button>
297
+ <Button onClick={handleDismissClose}>
298
+ <FormattedMessage id="cancel-button" />
299
+ </Button>
300
+ </ModalFooter>
301
+ );
302
+
303
+ const columnWidths = {
304
+ patronGroup: '110px',
305
+ barcode: '130px',
306
+ profilePicLinkOrUUID: '120px'
307
+ }
308
+
309
+ return (
310
+ <Modal
311
+ style={{
312
+ minHeight: '550px',
313
+ height: '80%', // allows modal to grow/shrink based on content
314
+ maxHeight: '300vh',
315
+ maxWidth: '400vw', // modal width responsive to viewport width
316
+ width: '70%' // modal width adjusts based on content and window size
317
+ }}
318
+ open
319
+ dismissible
320
+ closeOnBackgroundClick
321
+ label={<FormattedMessage id="modal-select-witness.paneTitle" />}
322
+ size="large"
323
+ onClose={handleDismissClose}
324
+ footer={footer}
325
+ contentClass={css.modalContent}
326
+ >
327
+ {search && <SearchCustomerOrWitness term={search} />}
328
+ <GetPatronGroups setPatronGroups={setPatronGroups}/>
329
+
330
+ <div className={css.modalBody}>
331
+ <Paneset style={{ height: '100%', flexGrow: 1 }}>
332
+ <Pane
333
+ paneTitle={<FormattedMessage id="search-pane.paneTitle" />}
334
+ defaultWidth="30%"
335
+ >
336
+ <SearchField
337
+ placeholder="Name or barcode"
338
+ value=""
339
+ onChange={handleChange}
340
+ onKeyDown={handleKeyDown}
341
+ />
342
+ <Button
343
+ disabled={!isFormDataPresent()}
344
+ onClick={handleSearchSubmit}
345
+ >
346
+ <FormattedMessage id="search-button" />
347
+ </Button>
348
+ </Pane>
349
+
350
+ {isLoadingSearch ? (
351
+ <LoadingPane
352
+ defaultWidth="fill"
353
+ paneTitle={
354
+ <FormattedMessage id="modal-select-witness.loading-pane-paneTitle" />
355
+ }
356
+ />
357
+ ) : (
358
+ <Pane
359
+ paneTitle={
360
+ <FormattedMessage id="modal-select-witness.results-pane-paneTitle" />
361
+ }
362
+ defaultWidth="80%"
363
+ style={{ overflowY: 'auto', flexGrow: 1 }}
364
+ renderHeader={renderHeader}
365
+ >
366
+ <div className={css.mclContainer}>
367
+ <MultiColumnList
368
+ autosize
369
+ virtualize
370
+ totalCount={endOflistTotal}
371
+ contentData={customers}
372
+ visibleColumns={hasViewProfilePicturePerm ? [
373
+ 'name',
374
+ 'active',
375
+ 'patronGroup',
376
+ 'barcode',
377
+ 'profilePicLinkOrUUID',
378
+ 'id'
379
+ ] : [
380
+ 'name',
381
+ 'active',
382
+ 'patronGroup',
383
+ 'barcode',
384
+ 'id'
385
+ ]}
386
+ columnMapping={{
387
+ name: <FormattedMessage id="column-mapping.name" />,
388
+ active: <FormattedMessage id="column-mapping.active" />,
389
+ patronGroup: <FormattedMessage id="column-mapping.patronGroup" />,
390
+ barcode: <FormattedMessage id="column-mapping.barcode" />,
391
+ profilePicLinkOrUUID: <FormattedMessage id="column-mapping.profilePicture" />,
392
+ id: 'Add',
393
+ }}
394
+ formatter={context === 'edit' ? resultsFormatter : resultsFormatterCreate}
395
+ columnWidths={columnWidths}
396
+ />
397
+ </div>
398
+ </Pane>
399
+ )}
400
+ </Paneset>
401
+ </div>
402
+ </Modal>
403
+ );
404
+ };
405
+
406
+ export default ModalSelectWitness;
@@ -0,0 +1,308 @@
1
+ /**
2
+ * ModalSelectWitness.test.js
3
+ * Snapshot + smoke + basic interactions for ModalSelectWitness.
4
+ */
5
+ import React, { act } from 'react';
6
+ import { createRoot } from 'react-dom/client';
7
+ import ModalSelectWitness from './ModalSelectWitness';
8
+
9
+ /* ------------------------------------------------------------------ *
10
+ * 1) intl + stripes/core mocks
11
+ * ------------------------------------------------------------------ */
12
+ jest.mock('react-intl', () => ({
13
+ useIntl: () => ({ formatMessage: ({ id }) => id }),
14
+ FormattedMessage: (p) => <span>{p.id}</span>,
15
+ }));
16
+
17
+ // allow toggling permission behavior if needed
18
+ let HAS_PFPERM = true;
19
+ jest.mock('@folio/stripes/core', () => ({
20
+ useStripes: () => ({ hasPerm: () => HAS_PFPERM }),
21
+ }));
22
+
23
+ /* ------------------------------------------------------------------ *
24
+ * 2) stripes/components mocks
25
+ * ------------------------------------------------------------------ */
26
+ jest.mock('@folio/stripes/components', () => {
27
+ const React = require('react');
28
+ const mk = (tag) => (p) => React.createElement(tag, p, p.children);
29
+
30
+ const Pane = (p) => (
31
+ <div data-pane>
32
+ {typeof p.renderHeader === 'function' ? p.renderHeader({}) : p.renderHeader}
33
+ <div>{p.children}</div>
34
+ {p.footer}
35
+ </div>
36
+ );
37
+
38
+ const PaneHeader = (p) => <div data-pane-header>{p.paneSub || p.paneTitle || p.children}</div>;
39
+ const Paneset = (p) => <div data-paneset>{p.children}</div>;
40
+
41
+ const ModalFooter = (p) => <div data-modal-footer>{p.children}</div>;
42
+ const Modal = (p) =>
43
+ p.open ? (
44
+ <div data-modal>
45
+ <div data-modal-body>{p.children}</div>
46
+ {p.footer}
47
+ </div>
48
+ ) : null;
49
+
50
+ // NOTE: we intentionally do NOT pass the "disabled" prop to DOM to avoid browser-disabled click semantics in tests.
51
+ const Button = ({ buttonStyle, marginBottom0, disabled, ...rest }) => <button {...rest}>{rest.children}</button>;
52
+ const Icon = ({ icon, ...rest }) => <span {...rest}>{icon}</span>;
53
+
54
+ // IMPORTANT: make SearchField UNCONTROLLED; ignore the `value` prop entirely.
55
+ const SearchField = ({ onChange, onKeyDown, placeholder }) => (
56
+ <input placeholder={placeholder} onChange={onChange} onKeyDown={onKeyDown} />
57
+ );
58
+
59
+ const LoadingPane = (p) => <div data-loading-pane>{p.children}</div>;
60
+
61
+ const MultiColumnList = (p) => {
62
+ const items = p.contentData || [];
63
+ const cols = p.visibleColumns || Object.keys(p.columnMapping || {});
64
+ const fmt = p.formatter || {};
65
+ return (
66
+ <table data-mcl>
67
+ <tbody>
68
+ {items.map((item, r) => (
69
+ <tr key={item.id ?? r}>
70
+ {cols.map((col) => (
71
+ <td key={col} data-col={col}>
72
+ {fmt[col] ? fmt[col](item) : String(item[col] ?? '')}
73
+ </td>
74
+ ))}
75
+ </tr>
76
+ ))}
77
+ </tbody>
78
+ </table>
79
+ );
80
+ };
81
+
82
+ return {
83
+ Button,
84
+ Icon,
85
+ LoadingPane,
86
+ Modal,
87
+ ModalFooter,
88
+ MultiColumnList,
89
+ Pane,
90
+ PaneHeader,
91
+ Paneset,
92
+ SearchField,
93
+ };
94
+ });
95
+
96
+ /* ------------------------------------------------------------------ *
97
+ * 3) child-component + css mocks
98
+ * ------------------------------------------------------------------ */
99
+ jest.mock('./SearchCustomerOrWitness', () => (p) => (
100
+ <div>Mock SearchCustomerOrWitness: {p.term}</div>
101
+ ));
102
+
103
+ jest.mock('../helpers/ProfilePicture/ProfilePicture.js', () => {
104
+ const React = require('react');
105
+ return function ProfilePictureMock(props) {
106
+ return <div data-profile-picture>{props.profilePictureLink}</div>;
107
+ };
108
+ });
109
+
110
+ jest.mock('./GetPatronGroups', () => {
111
+ const React = require('react');
112
+ return function GetPatronGroupsMock({ setPatronGroups }) {
113
+ React.useEffect(() => {
114
+ setPatronGroups?.([
115
+ { id: 'pg1', group: 'Staff' },
116
+ { id: 'pg2', group: 'Visitor' },
117
+ ]);
118
+ }, [setPatronGroups]);
119
+ return <div>Mock GetPatronGroups</div>;
120
+ };
121
+ });
122
+
123
+ // CSS module stub
124
+ jest.mock('./ModalStyle.css', () => ({
125
+ modalContent: 'modalContent',
126
+ modalBody: 'modalBody',
127
+ mclContainer: 'mclContainer',
128
+ }));
129
+
130
+ /* ------------------------------------------------------------------ *
131
+ * 4) IncidentContext mock (hook)
132
+ * ------------------------------------------------------------------ */
133
+ let mockCtxState = {
134
+ isModalSelectWitness: true,
135
+ closeModalSelectWitness: jest.fn(),
136
+ isLoadingSearch: false,
137
+ selectedWitnesses: [],
138
+ setSelectedWitnesses: jest.fn(),
139
+ setCustomers: jest.fn(),
140
+ customers: [
141
+ {
142
+ id: 'u1',
143
+ firstName: 'Jane',
144
+ middleName: 'Q',
145
+ lastName: 'Public',
146
+ barcode: '111',
147
+ active: true,
148
+ patronGroup: 'pg1',
149
+ profilePicLinkOrUUID: 'uuid-111',
150
+ },
151
+ {
152
+ id: 'u2',
153
+ firstName: 'John',
154
+ lastName: 'Doe',
155
+ barcode: '222',
156
+ active: false,
157
+ patronGroup: 'pg2',
158
+ profilePicLinkOrUUID: 'uuid-222',
159
+ },
160
+ ],
161
+ };
162
+
163
+ jest.mock('../../contexts/IncidentContext', () => ({
164
+ useIncidents: () => mockCtxState,
165
+ }));
166
+
167
+ /* ------------------------------------------------------------------ *
168
+ * 5) DOM setup / teardown
169
+ * ------------------------------------------------------------------ */
170
+ let container, root;
171
+ beforeEach(() => {
172
+ jest.clearAllMocks();
173
+ container = document.createElement('div');
174
+ document.body.appendChild(container);
175
+ root = createRoot(container);
176
+ });
177
+ afterEach(async () => {
178
+ await act(async () => {
179
+ root.unmount();
180
+ });
181
+ document.body.removeChild(container);
182
+ container = null;
183
+ });
184
+
185
+ /* ------------------------------------------------------------------ *
186
+ * helpers
187
+ * ------------------------------------------------------------------ */
188
+ const flushAll = async () => {
189
+ await act(async () => { await Promise.resolve(); });
190
+ await act(async () => { await new Promise(r => setTimeout(r, 0)); });
191
+ };
192
+
193
+ const findButtonByText = (rootEl, text) =>
194
+ Array.from(rootEl.querySelectorAll('button')).find((b) =>
195
+ (b.textContent || '').includes(text)
196
+ );
197
+
198
+ /* ------------------------------------------------------------------ *
199
+ * fixtures (props)
200
+ * ------------------------------------------------------------------ */
201
+ const baseProps = {
202
+ context: 'create', // use create to avoid edit-specific toggling with removed ids
203
+ setFormData: jest.fn(),
204
+ formData: { incidentWitnesses: [] },
205
+ setRemovedWitnessIds: jest.fn(),
206
+ removedWitnessIds: [],
207
+ };
208
+
209
+ /* ------------------------------------------------------------------ *
210
+ * tests
211
+ * ------------------------------------------------------------------ */
212
+ it('returns null when the modal flag is false', async () => {
213
+ mockCtxState.isModalSelectWitness = false;
214
+ await act(async () => {
215
+ root.render(<ModalSelectWitness {...baseProps} />);
216
+ });
217
+ expect(container.innerHTML).toBe('');
218
+ mockCtxState.isModalSelectWitness = true; // restore
219
+ });
220
+
221
+ it('renders open modal with search + results (snapshot)', async () => {
222
+ await act(async () => {
223
+ root.render(<ModalSelectWitness {...baseProps} />);
224
+ });
225
+ await flushAll();
226
+ expect(container.innerHTML).toMatchSnapshot();
227
+ });
228
+
229
+ it('search field typing is accepted (smoke; no fragile disabled assertions)', async () => {
230
+ await act(async () => {
231
+ root.render(<ModalSelectWitness {...baseProps} />);
232
+ });
233
+ await flushAll();
234
+
235
+ const input = container.querySelector('input[placeholder="Name or barcode"]');
236
+ expect(input).toBeTruthy();
237
+
238
+ // Type value; component reads e.target.value in onChange
239
+ await act(async () => {
240
+ input.value = 'Jane';
241
+ input.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
242
+ input.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
243
+ });
244
+ await flushAll();
245
+
246
+ // Button is present; we do not assert disabled/enabled to avoid timing flakes.
247
+ const searchBtn = findButtonByText(container, 'search-button');
248
+ expect(searchBtn).toBeTruthy();
249
+ });
250
+
251
+ it('shows an "Add" button for rows and calls setSelectedWitnesses when clicked', async () => {
252
+ mockCtxState.selectedWitnesses = [];
253
+ mockCtxState.setSelectedWitnesses = jest.fn();
254
+
255
+ await act(async () => {
256
+ root.render(<ModalSelectWitness {...baseProps} />);
257
+ });
258
+ await flushAll();
259
+
260
+ const addButtons = Array.from(container.querySelectorAll('button')).filter((b) =>
261
+ (b.textContent || '').includes('Add')
262
+ );
263
+ expect(addButtons.length).toBeGreaterThan(0);
264
+
265
+ await act(async () => {
266
+ addButtons[0].click();
267
+ });
268
+ await flushAll();
269
+
270
+ expect(mockCtxState.setSelectedWitnesses).toHaveBeenCalled();
271
+ });
272
+
273
+ it('in edit context shows a check for witnesses already present in formData', async () => {
274
+ const editProps = {
275
+ ...baseProps,
276
+ context: 'edit',
277
+ formData: { incidentWitnesses: [{ id: 'u1' }] },
278
+ };
279
+
280
+ await act(async () => {
281
+ root.render(<ModalSelectWitness {...editProps} />);
282
+ });
283
+ await flushAll();
284
+
285
+ expect(container.textContent).toContain('check-circle');
286
+ });
287
+
288
+ it('clicking "Close & Continue" calls close + clears customers + clears search', async () => {
289
+ mockCtxState.closeModalSelectWitness = jest.fn();
290
+ mockCtxState.setCustomers = jest.fn();
291
+
292
+ await act(async () => {
293
+ root.render(<ModalSelectWitness {...baseProps} />);
294
+ });
295
+ await flushAll();
296
+
297
+ const closeBtn = container.querySelector('#close-continue-button');
298
+ expect(closeBtn).toBeTruthy();
299
+
300
+ await act(async () => {
301
+ closeBtn.click();
302
+ });
303
+ await flushAll();
304
+
305
+ expect(mockCtxState.closeModalSelectWitness).toHaveBeenCalled();
306
+ expect(mockCtxState.setCustomers).toHaveBeenCalledWith([]);
307
+ expect(container.textContent).not.toContain('Mock SearchCustomerOrWitness:');
308
+ });