1
|
|
|
import React, { useMemo, useRef, useState } from "react"; |
2
|
|
|
import { useIntl } from "react-intl"; |
3
|
|
|
import { |
4
|
|
|
getLocale, |
5
|
|
|
localizeFieldNonNull, |
6
|
|
|
matchStringsCaseDiacriticInsensitive, |
7
|
|
|
} from "../../helpers/localize"; |
8
|
|
|
import { Skill, SkillCategory } from "../../models/types"; |
9
|
|
|
import SearchBar from "../SearchBar"; |
10
|
|
|
import Dialog from "../H2Components/Dialog"; |
11
|
|
|
import { focusOnElement } from "../../helpers/focus"; |
12
|
|
|
import { dialogMessages as messages } from "./messages"; |
13
|
|
|
import SkillCategories from "./SkillCategories"; |
14
|
|
|
import SearchResults from "./SearchResults"; |
15
|
|
|
import { mapToObjectTrans } from "../../helpers/queries"; |
16
|
|
|
|
17
|
|
|
const FIND_SKILLS_DIALOG_ID = "dialog-id-find-skills"; |
18
|
|
|
interface FindSkillsDialogTriggerProps { |
19
|
|
|
openDialog: () => void; |
20
|
|
|
} |
21
|
|
|
export const FindSkillsDialogTrigger: React.FC<FindSkillsDialogTriggerProps> = ({ |
22
|
|
|
openDialog, |
23
|
|
|
}) => { |
24
|
|
|
const intl = useIntl(); |
25
|
|
|
return ( |
26
|
|
|
<Dialog.Trigger |
27
|
|
|
id={FIND_SKILLS_DIALOG_ID} |
28
|
|
|
data-h2-shadow="b(medium)" |
29
|
|
|
buttonStyling="theme-2, round, medium, outline" |
30
|
|
|
data-h2-grid="b(top, expanded, flush, 0)" |
31
|
|
|
onClick={openDialog} |
32
|
|
|
> |
33
|
|
|
<div data-h2-grid-item="b(1of1)"> |
34
|
|
|
<div data-h2-grid-content> |
35
|
|
|
<span data-h2-font-color="b(theme-2)" data-h2-font-size="b(h2)"> |
36
|
|
|
<i className="fa fa-binoculars" /> |
37
|
|
|
</span> |
38
|
|
|
</div> |
39
|
|
|
</div> |
40
|
|
|
<div data-h2-grid-item="b(1of1)"> |
41
|
|
|
<div data-h2-grid-content> |
42
|
|
|
<p data-h2-button-label> |
43
|
|
|
{intl.formatMessage(messages.triggerLabel)} |
44
|
|
|
</p> |
45
|
|
|
</div> |
46
|
|
|
</div> |
47
|
|
|
</Dialog.Trigger> |
48
|
|
|
); |
49
|
|
|
}; |
50
|
|
|
interface FindSkillsDialogProps { |
51
|
|
|
previousSkills: Skill[]; |
52
|
|
|
portal: "applicant" | "manager"; |
53
|
|
|
skills: Skill[]; |
54
|
|
|
skillCategories: SkillCategory[]; |
55
|
|
|
isDialogVisible: boolean; |
56
|
|
|
closeDialog: () => void; |
57
|
|
|
handleSubmit: (values: Skill[]) => Promise<void>; |
58
|
|
|
} |
59
|
|
|
|
60
|
|
|
const FindSkillsDialog: React.FunctionComponent<FindSkillsDialogProps> = ({ |
61
|
|
|
previousSkills, |
62
|
|
|
portal, |
63
|
|
|
skills, |
64
|
|
|
skillCategories, |
65
|
|
|
isDialogVisible, |
66
|
|
|
closeDialog, |
67
|
|
|
handleSubmit, |
68
|
|
|
}) => { |
69
|
|
|
const intl = useIntl(); |
70
|
|
|
const locale = getLocale(intl.locale); |
71
|
|
|
// List of new Skills that will be saved to the user on submit. |
72
|
|
|
const [newSkills, setNewSkills] = useState<Skill[]>([]); |
73
|
|
|
// List of skills that displayed in the results section of the modal. |
74
|
|
|
const [results, setResults] = useState<Skill[] | null>(null); |
75
|
|
|
// Stores the skill category's name and description for the results section. |
76
|
|
|
const [resultsSectionText, setResultsSectionText] = useState<{ |
77
|
|
|
title: string; |
78
|
|
|
description: string; |
79
|
|
|
}>({ title: "", description: "" }); |
80
|
|
|
// This holds the active skill category's key, which is used for styling purposes. |
81
|
|
|
const [activeCategory, setActiveCategory] = useState<SkillCategory["key"]>( |
82
|
|
|
"", |
83
|
|
|
); |
84
|
|
|
|
85
|
|
|
// NOTE: The following two hooks are useRef instead of useState because they are only used by callback functions; |
86
|
|
|
// We don't want the component to re-render in response to their value changing. |
87
|
|
|
// This holds the html selector of the element that triggered the search. |
88
|
|
|
const searchResultsSource = useRef<{ |
89
|
|
|
elementSelector: string; |
90
|
|
|
}>({ elementSelector: "" }); |
91
|
|
|
// This is used when manually adjusting tab focus to only focus on top level "menu" items. |
92
|
|
|
const prevTabbedElement = React.useRef<HTMLElement | null>(null); |
93
|
|
|
|
94
|
|
|
/** |
95
|
|
|
* This state is an object which holds a list of all the accordions. |
96
|
|
|
* The value represents if the accordion is expanded or not. |
97
|
|
|
*/ |
98
|
|
|
const [accordionData, setAccordionData] = React.useState<{ |
99
|
|
|
skills: { [id: string]: boolean }; |
100
|
|
|
parentSkillCategories: { [key: string]: boolean }; |
101
|
|
|
}>({ |
102
|
|
|
skills: { |
103
|
|
|
...mapToObjectTrans( |
104
|
|
|
skills, |
105
|
|
|
(skill) => skill.id, |
106
|
|
|
() => false, |
107
|
|
|
), |
108
|
|
|
}, |
109
|
|
|
parentSkillCategories: { |
110
|
|
|
...mapToObjectTrans( |
111
|
|
|
skillCategories.filter((skillCategory) => !skillCategory.parent_id), // Only want parent categories. |
112
|
|
|
(parentSkillCategory) => parentSkillCategory.id, |
113
|
|
|
() => false, |
114
|
|
|
), |
115
|
|
|
}, |
116
|
|
|
}); |
117
|
|
|
|
118
|
|
|
/** |
119
|
|
|
* By default, this method will toggle the accordion from expanded to collapsed (or vice versa) with the given key. |
120
|
|
|
* There is also a second parameter to set the state. |
121
|
|
|
* @param key Accordion key. |
122
|
|
|
* @param value Optional value to set the accordions state (expanded or collapsed). |
123
|
|
|
*/ |
124
|
|
|
const toggleAccordion = ( |
125
|
|
|
id: number, |
126
|
|
|
key: "skills" | "parentSkillCategories", |
127
|
|
|
isExpanded: boolean | null = null, |
128
|
|
|
): void => { |
129
|
|
|
setAccordionData({ |
130
|
|
|
...accordionData, |
131
|
|
|
[key]: { |
132
|
|
|
...accordionData[key], |
133
|
|
|
[id]: isExpanded !== null ? isExpanded : !accordionData[key][id], |
134
|
|
|
}, |
135
|
|
|
}); |
136
|
|
|
}; |
137
|
|
|
|
138
|
|
|
/** |
139
|
|
|
* Closes all skill category and skill accordions in the dialog. |
140
|
|
|
*/ |
141
|
|
|
const closeAllAccordions = React.useCallback(() => { |
142
|
|
|
setAccordionData({ |
143
|
|
|
skills: { |
144
|
|
|
...mapToObjectTrans( |
145
|
|
|
skills, |
146
|
|
|
(skill) => skill.id, |
147
|
|
|
() => false, |
148
|
|
|
), |
149
|
|
|
}, |
150
|
|
|
parentSkillCategories: { |
151
|
|
|
...mapToObjectTrans( |
152
|
|
|
skillCategories.filter((skillCategory) => !skillCategory.parent_id), // Only want parent categories. |
153
|
|
|
(parentSkillCategory) => parentSkillCategory.id, |
154
|
|
|
() => false, |
155
|
|
|
), |
156
|
|
|
}, |
157
|
|
|
}); |
158
|
|
|
}, [skillCategories, skills]); |
159
|
|
|
|
160
|
|
|
/** |
161
|
|
|
* Returns a map of skills where the key represents the category id, and the value is an array of skills in that category. |
162
|
|
|
*/ |
163
|
|
|
const categoryIdToSkillsMap: Map<number, Skill[]> = useMemo( |
164
|
|
|
() => |
165
|
|
|
skills.reduce((map: Map<number, Skill[]>, skill): Map< |
166
|
|
|
number, |
167
|
|
|
Skill[] |
168
|
|
|
> => { |
169
|
|
|
skill.skill_category_ids.forEach((categoryId) => { |
170
|
|
|
if (!map.has(categoryId)) { |
171
|
|
|
map.set(categoryId, []); |
172
|
|
|
} |
173
|
|
|
map.get(categoryId)?.push(skill); |
174
|
|
|
}); |
175
|
|
|
return map; |
176
|
|
|
}, new Map()), |
177
|
|
|
[skills], |
178
|
|
|
); |
179
|
|
|
|
180
|
|
|
/** |
181
|
|
|
* Filters through the skills list for the any skills that match the search query. |
182
|
|
|
* @param searchQuery The search query entered by the user. |
183
|
|
|
*/ |
184
|
|
|
const handleSkillSearch = (searchQuery: string): Promise<void> => { |
185
|
|
|
if (searchQuery.length === 0) { |
186
|
|
|
setResults(null); |
187
|
|
|
return Promise.resolve(); |
188
|
|
|
} |
189
|
|
|
|
190
|
|
|
const skillNames: string[] = skills.map((skill) => |
191
|
|
|
localizeFieldNonNull(locale, skill, "name"), |
192
|
|
|
); |
193
|
|
|
const skillStrings: string[] = matchStringsCaseDiacriticInsensitive( |
194
|
|
|
searchQuery, |
195
|
|
|
skillNames, |
196
|
|
|
); |
197
|
|
|
const skillMatches = skills.filter((skill) => |
198
|
|
|
skillStrings.includes(localizeFieldNonNull(locale, skill, "name")), |
199
|
|
|
); |
200
|
|
|
|
201
|
|
|
// Set the skillResults state with the matches from the query. |
202
|
|
|
setResultsSectionText({ |
203
|
|
|
title: intl.formatMessage(messages.searchResultsTitle, { |
204
|
|
|
numOfSkills: skillMatches.length, |
205
|
|
|
searchQuery, |
206
|
|
|
}), |
207
|
|
|
description: "", |
208
|
|
|
}); |
209
|
|
|
setResults(skillMatches); |
210
|
|
|
searchResultsSource.current = { |
211
|
|
|
elementSelector: "#skill-search-button", |
212
|
|
|
}; |
213
|
|
|
// After doing a search, focus moves to the results pane, but for tabbing purposes we want to pretend these results |
214
|
|
|
// are between the search bar and the first Skill Category: tabbing backward should return to the search bar, tabbing |
215
|
|
|
// forward should move to the first Skill Category. Accordionly, we can pretend the previous tabbed element was the |
216
|
|
|
// search button, which is right between those two elements. |
217
|
|
|
prevTabbedElement.current = document.getElementById("skill-search-button"); |
218
|
|
|
return Promise.resolve(); |
219
|
|
|
}; |
220
|
|
|
|
221
|
|
|
/** |
222
|
|
|
* Sets up the state of the skill results pane when a skill category is clicked. |
223
|
|
|
* @param childSkillCategory Skill Category object. |
224
|
|
|
*/ |
225
|
|
|
const onSkillCategoryClick = (childSkillCategory: SkillCategory): void => { |
226
|
|
|
setResults(categoryIdToSkillsMap.get(childSkillCategory.id) ?? []); |
227
|
|
|
setActiveCategory(childSkillCategory.key); |
228
|
|
|
setResultsSectionText({ |
229
|
|
|
title: intl.formatMessage(messages.skills, { |
230
|
|
|
category: localizeFieldNonNull(locale, childSkillCategory, "name"), |
231
|
|
|
}), |
232
|
|
|
description: "", |
233
|
|
|
// TODO: Restore this when descriptions are added to Skill Categories on backend. |
234
|
|
|
// localizeFieldNonNull( |
235
|
|
|
// locale, |
236
|
|
|
// childSkillCategory, |
237
|
|
|
// "description", |
238
|
|
|
// ), |
239
|
|
|
}); |
240
|
|
|
searchResultsSource.current = { |
241
|
|
|
elementSelector: `#${childSkillCategory.key}-skill-category`, |
242
|
|
|
}; |
243
|
|
|
}; |
244
|
|
|
|
245
|
|
|
/** |
246
|
|
|
* Resets the search results pane to its default state, and puts focus back on the search results source. |
247
|
|
|
*/ |
248
|
|
|
const resetResults = (): void => { |
249
|
|
|
setResults(null); |
250
|
|
|
setActiveCategory(""); |
251
|
|
|
focusOnElement(searchResultsSource.current.elementSelector); |
252
|
|
|
searchResultsSource.current = { elementSelector: "" }; |
253
|
|
|
}; |
254
|
|
|
|
255
|
|
|
const dialogRef = React.useRef<HTMLElement | null>(null); |
256
|
|
|
const handleTabableElements: ( |
257
|
|
|
event: KeyboardEvent, |
258
|
|
|
) => void = React.useCallback( |
259
|
|
|
(event) => { |
260
|
|
|
if (event.key === "Tab" && dialogRef.current) { |
261
|
|
|
// In this dialog, not all focusable elements are meant to be reached via the tab key. |
262
|
|
|
// We manually keep track of tabable elements with a `data-tabable` data attribute. |
263
|
|
|
const tabableElements = Array.from( |
264
|
|
|
dialogRef.current.querySelectorAll( |
265
|
|
|
"[data-tabable='true']:not([disabled])", |
266
|
|
|
), |
267
|
|
|
) as HTMLElement[]; |
268
|
|
|
|
269
|
|
|
if (tabableElements.length === 0) { |
270
|
|
|
return; |
271
|
|
|
} |
272
|
|
|
|
273
|
|
|
const firstElement = tabableElements[0] as HTMLElement; |
274
|
|
|
|
275
|
|
|
if (tabableElements.length === 1) { |
276
|
|
|
// This check is to avoid strange behavior if firstElement == lastElement. |
277
|
|
|
firstElement.focus(); |
278
|
|
|
event.preventDefault(); |
279
|
|
|
return; |
280
|
|
|
} |
281
|
|
|
|
282
|
|
|
// Because "non-tabable" may recieve focus, we track the last "tabable" element we were at |
283
|
|
|
// with prevTabbedElement, and calculate the next and prev tabable elements in reference to that. |
284
|
|
|
|
285
|
|
|
// If prevTabbedElement is null, treat the current index as zero |
286
|
|
|
const currentTabIndex = |
287
|
|
|
prevTabbedElement.current !== null |
288
|
|
|
? tabableElements.findIndex( |
289
|
|
|
(element) => element === prevTabbedElement.current, |
290
|
|
|
) |
291
|
|
|
: 0; |
292
|
|
|
|
293
|
|
|
const forwardIndex = (currentTabIndex + 1) % tabableElements.length; |
294
|
|
|
// backwardIndex loops around to the last tabable element if currently focused on the first. |
295
|
|
|
const backwardIndex = |
296
|
|
|
currentTabIndex <= 0 |
297
|
|
|
? tabableElements.length - 1 |
298
|
|
|
: currentTabIndex - 1; |
299
|
|
|
|
300
|
|
|
const targetElement = event.shiftKey |
301
|
|
|
? tabableElements[backwardIndex] |
302
|
|
|
: tabableElements[forwardIndex]; |
303
|
|
|
|
304
|
|
|
targetElement.focus(); |
305
|
|
|
prevTabbedElement.current = targetElement; |
306
|
|
|
closeAllAccordions(); |
307
|
|
|
event.preventDefault(); |
308
|
|
|
} |
309
|
|
|
}, |
310
|
|
|
[closeAllAccordions], |
311
|
|
|
); |
312
|
|
|
|
313
|
|
|
React.useEffect(() => { |
314
|
|
|
if (isDialogVisible) { |
315
|
|
|
document.addEventListener("keydown", handleTabableElements); |
316
|
|
|
} |
317
|
|
|
|
318
|
|
|
return () => document.removeEventListener("keydown", handleTabableElements); |
319
|
|
|
}, [isDialogVisible, handleTabableElements]); |
320
|
|
|
|
321
|
|
|
React.useEffect(() => { |
322
|
|
|
// If user manually focuses on a "legally tabable" element (eg by clicking or other keyboard hotkeys) |
323
|
|
|
// update prevTabbedElement. |
324
|
|
|
const handleFocus = (e: FocusEvent) => { |
325
|
|
|
if (dialogRef.current) { |
326
|
|
|
const tabableElements = Array.from( |
327
|
|
|
dialogRef.current.querySelectorAll("[data-tabable='true']"), |
328
|
|
|
) as HTMLElement[]; |
329
|
|
|
const { activeElement } = document; |
330
|
|
|
if ( |
331
|
|
|
activeElement && |
332
|
|
|
tabableElements.includes(activeElement as HTMLElement) |
333
|
|
|
) { |
334
|
|
|
prevTabbedElement.current = activeElement as HTMLElement; |
335
|
|
|
} |
336
|
|
|
} |
337
|
|
|
}; |
338
|
|
|
if (isDialogVisible) { |
339
|
|
|
document.addEventListener("focus", handleFocus, true); |
340
|
|
|
} |
341
|
|
|
return () => document.removeEventListener("focus", handleFocus, true); |
342
|
|
|
}); |
343
|
|
|
|
344
|
|
|
/** |
345
|
|
|
* Closes the dialog and puts the focus back onto the dialog trigger. |
346
|
|
|
*/ |
347
|
|
|
const handleCloseDialog = (): void => { |
348
|
|
|
focusOnElement(`[data-h2-dialog-trigger=${FIND_SKILLS_DIALOG_ID}]`); |
349
|
|
|
resetResults(); |
350
|
|
|
closeAllAccordions(); |
351
|
|
|
closeDialog(); |
352
|
|
|
}; |
353
|
|
|
|
354
|
|
|
return ( |
355
|
|
|
<section ref={dialogRef}> |
356
|
|
|
<Dialog |
357
|
|
|
id={FIND_SKILLS_DIALOG_ID} |
358
|
|
|
isVisible={isDialogVisible} |
359
|
|
|
closeDialog={handleCloseDialog} |
360
|
|
|
data-h2-radius="b(round)" |
361
|
|
|
overrideFocusRules |
362
|
|
|
> |
363
|
|
|
<Dialog.Header |
364
|
|
|
buttonAttributes={{ "data-tabable": true }} |
365
|
|
|
className="gradient-left-right" |
366
|
|
|
> |
367
|
|
|
<Dialog.Title |
368
|
|
|
data-h2-padding="b(all, 1)" |
369
|
|
|
data-h2-font-color="b(white)" |
370
|
|
|
data-h2-font-size="b(h4)" |
371
|
|
|
data-tabable |
372
|
|
|
> |
373
|
|
|
{intl.formatMessage(messages.modalHeading)} |
374
|
|
|
</Dialog.Title> |
375
|
|
|
</Dialog.Header> |
376
|
|
|
<Dialog.Content |
377
|
|
|
data-h2-grid="b(top, expanded, flush, 0)" |
378
|
|
|
style={{ height: "35rem", overflow: "auto", alignItems: "stretch" }} |
379
|
|
|
> |
380
|
|
|
{/* Parent Skill Category Accordions Section */} |
381
|
|
|
<div data-h2-grid-item="s(2of5) b(1of1)"> |
382
|
|
|
<SearchBar |
383
|
|
|
buttonLabel={intl.formatMessage(messages.searchBarButtonLabel)} |
384
|
|
|
searchLabel={intl.formatMessage(messages.searchBarInputLabel)} |
385
|
|
|
searchPlaceholder={intl.formatMessage( |
386
|
|
|
messages.searchBarInputPlaceholder, |
387
|
|
|
)} |
388
|
|
|
handleSubmit={handleSkillSearch} |
389
|
|
|
buttonId="skill-search-button" |
390
|
|
|
buttonAttributes={{ "data-tabable": true }} |
391
|
|
|
inputAttributes={{ "data-tabable": true }} |
392
|
|
|
/> |
393
|
|
|
<SkillCategories |
394
|
|
|
activeCategory={activeCategory} |
395
|
|
|
accordionData={accordionData} |
396
|
|
|
onSkillCategoryClick={onSkillCategoryClick} |
397
|
|
|
skillCategories={skillCategories} |
398
|
|
|
skills={skills} |
399
|
|
|
toggleAccordion={toggleAccordion} |
400
|
|
|
/> |
401
|
|
|
</div> |
402
|
|
|
<SearchResults |
403
|
|
|
accordionData={accordionData} |
404
|
|
|
description={resultsSectionText.description} |
405
|
|
|
newSkills={newSkills} |
406
|
|
|
previousSkills={previousSkills} |
407
|
|
|
results={results} |
408
|
|
|
resetResults={resetResults} |
409
|
|
|
setNewSkills={setNewSkills} |
410
|
|
|
title={resultsSectionText.title} |
411
|
|
|
toggleAccordion={toggleAccordion} |
412
|
|
|
/> |
413
|
|
|
</Dialog.Content> |
414
|
|
|
<Dialog.Actions |
415
|
|
|
data-h2-grid="b(middle, expanded, padded, .5)" |
416
|
|
|
data-h2-margin="b(all, 0)" |
417
|
|
|
data-h2-bg-color="b(gray-1, 1)" |
418
|
|
|
> |
419
|
|
|
<div data-h2-align="b(left)" data-h2-grid-item="b(1of2)"> |
420
|
|
|
<Dialog.ActionBtn |
421
|
|
|
data-tabable |
422
|
|
|
buttonStyling="stop, round, solid" |
423
|
|
|
data-h2-padding="b(rl, 2) b(tb, .5)" |
424
|
|
|
data-h2-bg-color="b(white, 1)" |
425
|
|
|
onClick={handleCloseDialog} |
426
|
|
|
> |
427
|
|
|
<p>{intl.formatMessage(messages.cancelButton)}</p> |
428
|
|
|
</Dialog.ActionBtn> |
429
|
|
|
</div> |
430
|
|
|
<div data-h2-align="b(right)" data-h2-grid-item="b(1of2)"> |
431
|
|
|
<Dialog.ActionBtn |
432
|
|
|
data-tabable |
433
|
|
|
buttonStyling="theme-1, round, solid" |
434
|
|
|
data-h2-padding="b(rl, 2) b(tb, .5)" |
435
|
|
|
onClick={() => |
436
|
|
|
handleSubmit(newSkills).then(() => { |
437
|
|
|
setNewSkills([]); |
438
|
|
|
setResults(null); |
439
|
|
|
handleCloseDialog(); |
440
|
|
|
}) |
441
|
|
|
} |
442
|
|
|
disabled={newSkills.length === 0} |
443
|
|
|
> |
444
|
|
|
<p>{intl.formatMessage(messages.saveButton)}</p> |
445
|
|
|
</Dialog.ActionBtn> |
446
|
|
|
</div> |
447
|
|
|
</Dialog.Actions> |
448
|
|
|
</Dialog> |
449
|
|
|
</section> |
450
|
|
|
); |
451
|
|
|
}; |
452
|
|
|
|
453
|
|
|
export default FindSkillsDialog; |
454
|
|
|
|