Passed
Push — dev ( 55f36e...3ef13f )
by Tristan
05:38 queued 12s
created

resources/assets/js/components/FindSkillsDialog/FindSkillsDialog.tsx   A

Complexity

Total Complexity 16
Complexity/F 0

Size

Lines of Code 454
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 16
eloc 329
dl 0
loc 454
rs 10
c 0
b 0
f 0
mnd 16
bc 16
fnc 0
bpm 0
cpm 0
noi 0
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