Passed
Push — task/improve-find-skills-modal... ( 114b93 )
by Yonathan
10:33 queued 02:21
created

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

Complexity

Total Complexity 14
Complexity/F 0

Size

Lines of Code 402
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

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