1
|
|
|
import React, { useMemo, 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/forms"; |
12
|
|
|
import { dialogMessages as messages } from "./messages"; |
13
|
|
|
import SkillCategories from "./SkillCategories"; |
14
|
|
|
import SearchResults from "./SearchResults"; |
15
|
|
|
|
16
|
|
|
const FIND_SKILLS_DIALOG_ID = "dialog-id-find-skills"; |
17
|
|
|
|
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[]>([]); |
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
|
|
|
const [firstVisit, setFirstVisit] = useState(true); |
81
|
|
|
// Stores a list of skills category keys of which accordions are expanded, for styling purposes. |
82
|
|
|
const [expandedAccordions, setExpandedAccordions] = useState<string[]>([]); |
83
|
|
|
// Used to set the button color of an active skill category. |
84
|
|
|
const [activeCategory, setActiveCategory] = useState<SkillCategory["key"]>( |
85
|
|
|
"", |
86
|
|
|
); |
87
|
|
|
|
88
|
|
|
const parentSkillCategories: SkillCategory[] = skillCategories.filter( |
89
|
|
|
(skillCategory) => !skillCategory.parent_id, |
90
|
|
|
); |
91
|
|
|
|
92
|
|
|
/** |
93
|
|
|
* Returns a map of skills where the key represents the category id, and the value is an array of skills in that category. |
94
|
|
|
*/ |
95
|
|
|
const categoryIdToSkillsMap: Map<number, Skill[]> = useMemo( |
96
|
|
|
() => |
97
|
|
|
skills.reduce((map: Map<number, Skill[]>, skill): Map< |
98
|
|
|
number, |
99
|
|
|
Skill[] |
100
|
|
|
> => { |
101
|
|
|
skill.skill_category_ids.forEach((categoryId) => { |
102
|
|
|
if (!map.has(categoryId)) { |
103
|
|
|
map.set(categoryId, []); |
104
|
|
|
} |
105
|
|
|
map.get(categoryId)?.push(skill); |
106
|
|
|
}); |
107
|
|
|
return map; |
108
|
|
|
}, new Map()), |
109
|
|
|
[skills], |
110
|
|
|
); |
111
|
|
|
|
112
|
|
|
/** |
113
|
|
|
* Filters through the skills list for the any skills that match the search query. |
114
|
|
|
* @param searchQuery The search query entered by the user. |
115
|
|
|
*/ |
116
|
|
|
const handleSkillSearch = (searchQuery: string): Promise<void> => { |
117
|
|
|
if (searchQuery.length === 0) { |
118
|
|
|
setResults([]); |
119
|
|
|
setFirstVisit(true); |
120
|
|
|
return Promise.resolve(); |
121
|
|
|
} |
122
|
|
|
|
123
|
|
|
const skillNames: string[] = skills.map((skill) => |
124
|
|
|
localizeFieldNonNull(locale, skill, "name"), |
125
|
|
|
); |
126
|
|
|
const skillStrings: string[] = matchStringsCaseDiacriticInsensitive( |
127
|
|
|
searchQuery, |
128
|
|
|
skillNames, |
129
|
|
|
); |
130
|
|
|
const skillMatches = skills.filter((skill) => |
131
|
|
|
skillStrings.includes(localizeFieldNonNull(locale, skill, "name")), |
132
|
|
|
); |
133
|
|
|
|
134
|
|
|
// Set the skillResults state with the matches from the query. |
135
|
|
|
setFirstVisit(false); |
136
|
|
|
setResultsSectionText({ |
137
|
|
|
title: intl.formatMessage(messages.searchResultsTitle, { |
138
|
|
|
numOfSkills: skillMatches.length, |
139
|
|
|
searchQuery, |
140
|
|
|
}), |
141
|
|
|
description: "", |
142
|
|
|
}); |
143
|
|
|
setResults(skillMatches); |
144
|
|
|
return Promise.resolve(); |
145
|
|
|
}; |
146
|
|
|
|
147
|
|
|
/** |
148
|
|
|
* Sets up the state of the skill results pane when a skill category is clicked. |
149
|
|
|
* @param childSkillCategory Skill Category object. |
150
|
|
|
*/ |
151
|
|
|
const onSkillCategoryClick = (childSkillCategory: SkillCategory) => { |
152
|
|
|
setFirstVisit(false); |
153
|
|
|
setActiveCategory(childSkillCategory.key); |
154
|
|
|
setResultsSectionText({ |
155
|
|
|
title: intl.formatMessage(messages.skills, { |
156
|
|
|
category: localizeFieldNonNull(locale, childSkillCategory, "name"), |
157
|
|
|
}), |
158
|
|
|
description: "", |
159
|
|
|
// TODO: Restore this when discriptions are added to Skill Categories on backend. |
160
|
|
|
// localizeFieldNonNull( |
161
|
|
|
// locale, |
162
|
|
|
// childSkillCategory, |
163
|
|
|
// "description", |
164
|
|
|
// ), |
165
|
|
|
}); |
166
|
|
|
setResults(categoryIdToSkillsMap.get(childSkillCategory.id) ?? []); |
167
|
|
|
}; |
168
|
|
|
|
169
|
|
|
/** |
170
|
|
|
* Resets the search results pane to its default state, and puts focus back on subcategory. |
171
|
|
|
*/ |
172
|
|
|
const resetResults = () => { |
173
|
|
|
setFirstVisit(true); |
174
|
|
|
focusOnElement(`#${activeCategory}-skill-category`); |
175
|
|
|
setActiveCategory(""); |
176
|
|
|
setResults([]); |
177
|
|
|
}; |
178
|
|
|
|
179
|
|
|
/** |
180
|
|
|
* We need to create a ref of all the tabable elements in the dialog. |
181
|
|
|
* Then we need a useEffect hook to store the last element in the list that had focus. |
182
|
|
|
* Using this we can determine where the next and previous tab will be. |
183
|
|
|
*/ |
184
|
|
|
const dialogRef = React.useRef<HTMLElement | null>(null); |
185
|
|
|
const [ |
186
|
|
|
prevTabbedElement, |
187
|
|
|
setPrevTabbedElement, |
188
|
|
|
] = React.useState<HTMLElement | null>(null); |
189
|
|
|
const handleTabableElements = React.useCallback( |
190
|
|
|
(event) => { |
191
|
|
|
if (event.key === "Tab" && dialogRef.current) { |
192
|
|
|
const { activeElement } = document; |
193
|
|
|
const tabableElements = Array.from( |
194
|
|
|
dialogRef.current.querySelectorAll("[data-tabable='true']"), |
195
|
|
|
) as HTMLElement[]; |
196
|
|
|
|
197
|
|
|
if (tabableElements.length === 0) { |
198
|
|
|
event.preventDefault(); // TODO: should this throw an error? |
199
|
|
|
return; |
200
|
|
|
} |
201
|
|
|
|
202
|
|
|
const firstElement = tabableElements[0] as HTMLElement; |
203
|
|
|
const lastElement = tabableElements[ |
204
|
|
|
tabableElements.length - 1 |
205
|
|
|
] as HTMLElement; |
206
|
|
|
|
207
|
|
|
if (tabableElements.length === 1) { |
208
|
|
|
// This check to avoid strange behavior if firstElement == lastElement. |
209
|
|
|
firstElement.focus(); |
210
|
|
|
event.preventDefault(); |
211
|
|
|
return; |
212
|
|
|
} |
213
|
|
|
|
214
|
|
|
const prevElement = tabableElements.find((element) => { |
215
|
|
|
return element === activeElement; |
216
|
|
|
}); |
217
|
|
|
if (prevElement) { |
218
|
|
|
setPrevTabbedElement(prevElement); |
219
|
|
|
} |
220
|
|
|
|
221
|
|
|
if ( |
222
|
|
|
activeElement && |
223
|
|
|
!tabableElements.includes(activeElement as HTMLElement) && |
224
|
|
|
prevTabbedElement !== null |
225
|
|
|
) { |
226
|
|
|
const tabForward = |
227
|
|
|
tabableElements.findIndex((element) => element === activeElement) + |
228
|
|
|
1; |
229
|
|
|
const tabBackward = |
230
|
|
|
tabableElements.findIndex((element) => element === activeElement) - |
231
|
|
|
1; |
232
|
|
|
console.log(tabForward); |
233
|
|
|
console.log(tabBackward); |
234
|
|
|
if (!event.shiftKey && tabForward) { |
235
|
|
|
console.log("test1"); |
236
|
|
|
tabableElements[tabForward].focus(); |
237
|
|
|
event.preventDefault(); |
238
|
|
|
return; |
239
|
|
|
} |
240
|
|
|
if (event.shiftKey && tabBackward) { |
241
|
|
|
console.log("test1"); |
242
|
|
|
tabableElements[tabBackward].focus(); |
243
|
|
|
event.preventDefault(); |
244
|
|
|
} |
245
|
|
|
} |
246
|
|
|
} |
247
|
|
|
}, |
248
|
|
|
[prevTabbedElement], |
249
|
|
|
); |
250
|
|
|
React.useEffect(() => { |
251
|
|
|
if (isDialogVisible) { |
252
|
|
|
document.addEventListener("keydown", handleTabableElements); |
253
|
|
|
} |
254
|
|
|
|
255
|
|
|
return () => document.removeEventListener("keydown", handleTabableElements); |
256
|
|
|
}, [isDialogVisible, handleTabableElements]); |
257
|
|
|
|
258
|
|
|
const skillsAccordionData = skills.reduce( |
259
|
|
|
(collection: { [key: string]: boolean }, skill: Skill) => { |
260
|
|
|
collection[`skill-${skill.id}`] = false; |
261
|
|
|
return collection; |
262
|
|
|
}, |
263
|
|
|
{}, |
264
|
|
|
); |
265
|
|
|
const categoriesAccordionData = parentSkillCategories.reduce( |
266
|
|
|
( |
267
|
|
|
collection: { [key: string]: boolean }, |
268
|
|
|
parentSkillCategory: SkillCategory, |
269
|
|
|
) => { |
270
|
|
|
collection[parentSkillCategory.key] = false; |
271
|
|
|
return collection; |
272
|
|
|
}, |
273
|
|
|
{}, |
274
|
|
|
); |
275
|
|
|
|
276
|
|
|
const [accordionData, setAccordionData] = React.useState<{ |
277
|
|
|
[key: string]: boolean; |
278
|
|
|
}>({ ...skillsAccordionData, ...categoriesAccordionData }); |
279
|
|
|
|
280
|
|
|
/** |
281
|
|
|
* By default, this method will toggle the accordion from expanded to collapsed (or vice versa) with the given key. |
282
|
|
|
* There is also a second parameter to set the state. |
283
|
|
|
* @param key Accordion key. |
284
|
|
|
* @param value Optional value to set the accordions state (expanded or collapsed). |
285
|
|
|
*/ |
286
|
|
|
const toggleAccordion = (key: string, isExpanded: boolean | null = null) => { |
287
|
|
|
setAccordionData({ |
288
|
|
|
...accordionData, |
289
|
|
|
[key]: isExpanded !== null ? isExpanded : !accordionData[key], |
290
|
|
|
}); |
291
|
|
|
}; |
292
|
|
|
|
293
|
|
|
/** |
294
|
|
|
* Closes the dialog and puts the focus back onto the dialog trigger. |
295
|
|
|
*/ |
296
|
|
|
const handleCloseDialog = () => { |
297
|
|
|
closeDialog(); |
298
|
|
|
focusOnElement(`[data-h2-dialog-trigger=${FIND_SKILLS_DIALOG_ID}]`); |
299
|
|
|
resetResults(); |
300
|
|
|
setAccordionData( |
301
|
|
|
Object.keys(accordionData).reduce((acc, key) => { |
302
|
|
|
acc[key] = false; |
303
|
|
|
return acc; |
304
|
|
|
}, {}), |
305
|
|
|
); |
306
|
|
|
}; |
307
|
|
|
|
308
|
|
|
return ( |
309
|
|
|
<section ref={dialogRef}> |
310
|
|
|
<Dialog |
311
|
|
|
id={FIND_SKILLS_DIALOG_ID} |
312
|
|
|
isVisible={isDialogVisible} |
313
|
|
|
closeDialog={closeDialog} |
314
|
|
|
data-h2-radius="b(round)" |
315
|
|
|
> |
316
|
|
|
<Dialog.Header className="gradient-left-right"> |
317
|
|
|
<Dialog.Title |
318
|
|
|
data-h2-padding="b(all, 1)" |
319
|
|
|
data-h2-font-color="b(white)" |
320
|
|
|
data-h2-font-size="b(h4)" |
321
|
|
|
> |
322
|
|
|
{intl.formatMessage(messages.modalHeading)} |
323
|
|
|
</Dialog.Title> |
324
|
|
|
</Dialog.Header> |
325
|
|
|
<Dialog.Content |
326
|
|
|
data-h2-grid="b(top, expanded, flush, 0)" |
327
|
|
|
style={{ height: "35rem", overflow: "auto", alignItems: "stretch" }} |
328
|
|
|
> |
329
|
|
|
{/* Parent Skill Category Accordions Section */} |
330
|
|
|
<div data-h2-grid-item="s(2of5) b(1of1)"> |
331
|
|
|
<SearchBar |
332
|
|
|
buttonLabel={intl.formatMessage(messages.searchBarButtonLabel)} |
333
|
|
|
searchLabel={intl.formatMessage(messages.searchBarInputLabel)} |
334
|
|
|
searchPlaceholder={intl.formatMessage( |
335
|
|
|
messages.searchBarInputPlaceholder, |
336
|
|
|
)} |
337
|
|
|
handleSubmit={handleSkillSearch} |
338
|
|
|
/> |
339
|
|
|
<SkillCategories |
340
|
|
|
activeCategory={activeCategory} |
341
|
|
|
accordionData={accordionData} |
342
|
|
|
onSkillCategoryClick={onSkillCategoryClick} |
343
|
|
|
skillCategories={skillCategories} |
344
|
|
|
skills={skills} |
345
|
|
|
toggleAccordion={toggleAccordion} |
346
|
|
|
/> |
347
|
|
|
</div> |
348
|
|
|
<SearchResults |
349
|
|
|
accordionData={accordionData} |
350
|
|
|
description={resultsSectionText.description} |
351
|
|
|
firstVisit={firstVisit} |
352
|
|
|
newSkills={newSkills} |
353
|
|
|
previousSkills={previousSkills} |
354
|
|
|
results={results} |
355
|
|
|
resetResults={resetResults} |
356
|
|
|
setNewSkills={setNewSkills} |
357
|
|
|
title={resultsSectionText.title} |
358
|
|
|
toggleAccordion={toggleAccordion} |
359
|
|
|
/> |
360
|
|
|
</Dialog.Content> |
361
|
|
|
<Dialog.Actions |
362
|
|
|
data-h2-grid="b(middle, expanded, padded, .5)" |
363
|
|
|
data-h2-margin="b(all, 0)" |
364
|
|
|
data-h2-bg-color="b(gray-1, 1)" |
365
|
|
|
> |
366
|
|
|
<div data-h2-align="b(left)" data-h2-grid-item="b(1of2)"> |
367
|
|
|
<Dialog.ActionBtn |
368
|
|
|
data-tabable |
369
|
|
|
buttonStyling="stop, round, solid" |
370
|
|
|
data-h2-padding="b(rl, 2) b(tb, .5)" |
371
|
|
|
data-h2-bg-color="b(white, 1)" |
372
|
|
|
onClick={handleCloseDialog} |
373
|
|
|
> |
374
|
|
|
<p>{intl.formatMessage(messages.cancelButton)}</p> |
375
|
|
|
</Dialog.ActionBtn> |
376
|
|
|
</div> |
377
|
|
|
<div data-h2-align="b(right)" data-h2-grid-item="b(1of2)"> |
378
|
|
|
<Dialog.ActionBtn |
379
|
|
|
data-tabable |
380
|
|
|
buttonStyling="theme-1, round, solid" |
381
|
|
|
data-h2-padding="b(rl, 2) b(tb, .5)" |
382
|
|
|
onClick={() => |
383
|
|
|
handleSubmit(newSkills).then(() => { |
384
|
|
|
setNewSkills([]); |
385
|
|
|
setFirstVisit(true); |
386
|
|
|
setResults([]); |
387
|
|
|
handleCloseDialog(); |
388
|
|
|
}) |
389
|
|
|
} |
390
|
|
|
disabled={newSkills.length === 0} |
391
|
|
|
> |
392
|
|
|
<p>{intl.formatMessage(messages.saveButton)}</p> |
393
|
|
|
</Dialog.ActionBtn> |
394
|
|
|
</div> |
395
|
|
|
</Dialog.Actions> |
396
|
|
|
</Dialog> |
397
|
|
|
</section> |
398
|
|
|
); |
399
|
|
|
}; |
400
|
|
|
|
401
|
|
|
export default FindSkillsDialog; |
402
|
|
|
|