resources/assets/js/components/Application/Skills/Skills.tsx   A
last analyzed

Complexity

Total Complexity 32
Complexity/F 0

Size

Lines of Code 1038
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 32
eloc 818
mnd 32
bc 32
fnc 0
dl 0
loc 1038
rs 9.622
bpm 0
cpm 0
noi 0
c 0
b 0
f 0
1
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
2
/* eslint camelcase: "off" */
3
import React, {
4
  useState,
5
  useReducer,
6
  useRef,
7
  createRef,
8
  RefObject,
9
} from "react";
10
import { FormattedMessage, useIntl, IntlShape } from "react-intl";
11
import { Formik, Form, FastField, FormikProps, FormikValues } from "formik";
12
import * as Yup from "yup";
13
import Swal, { SweetAlertResult } from "sweetalert2";
14
import {
15
  ExperienceSkill,
16
  Skill,
17
  Criteria,
18
  Experience,
19
} from "../../../models/types";
20
import { slugify, applicantFaq } from "../../../helpers/routes";
21
import { getLocale, localizeFieldNonNull } from "../../../helpers/localize";
22
import { validationMessages } from "../../Form/Messages";
23
import {
24
  getExperienceHeading,
25
  getExperienceSubheading,
26
  getExperienceJustificationLabel,
27
} from "../../../models/localizedConstants";
28
import { getSkillLevelName } from "../../../models/jobUtil";
29
import StatusIcon, { IconStatus } from "../../StatusIcon";
30
import AlertWhenUnsaved from "../../Form/AlertWhenUnsaved";
31
import TextAreaInput from "../../Form/TextAreaInput";
32
import WordCounter from "../../WordCounter/WordCounter";
33
import { countNumberOfWords } from "../../WordCounter/helpers";
34
import {
35
  experienceMessages,
36
  navigationMessages,
37
  skillMessages,
38
} from "../applicationMessages";
39
import displayMessages from "./skillsMessages";
40
import {
41
  getSkillOfCriteria,
42
  getExperiencesOfSkill,
43
  getExperienceOfExperienceSkill,
44
} from "../helpers";
45
import {
46
  statusReducer,
47
  initialStatus,
48
  computeParentStatus,
49
  SkillStatus,
50
  computeExperienceStatus,
51
} from "./skillsHelpers";
52
import Modal from "../../Modal";
53
import { validateAllForms, getValidForms } from "../../../helpers/forms";
54
import { focusOnElement, getFocusableElements } from "../../../helpers/focus";
55
import {
56
  find,
57
  getId,
58
  mapToObjectTrans,
59
  notEmpty,
60
  removeDuplicatesById,
61
} from "../../../helpers/queries";
62
import { batchUpdateExperienceSkills } from "../../../store/Experience/experienceActions";
63
import { SkillTypeId } from "../../../models/lookupConstants";
64
65
export const JUSTIFICATION_WORD_LIMIT = 100;
66
67
interface SidebarProps {
68
  menuSkills: { id: number; name: string }[];
69
  intl: IntlShape;
70
  status: SkillStatus;
71
}
72
73
const Sidebar: React.FC<SidebarProps> = ({ menuSkills, intl, status }) => {
74
  return (
75
    <div data-c-padding="top(3)" className="application-skill-navigation">
76
      <p
77
        data-c-font-size="h3"
78
        data-c-font-weight="bold"
79
        data-c-margin="bottom(1)"
80
      >
81
        <FormattedMessage
82
          id="application.skills.sidebarHeading"
83
          defaultMessage="On this page:"
84
          description="Heading for the sidebar on the Skills page."
85
        />
86
      </p>
87
      <ul>
88
        {menuSkills.map((skill) => (
89
          <li key={skill.id}>
90
            <StatusIcon
91
              status={computeParentStatus(status, Number(skill.id))}
92
              size=""
93
            />
94
            <a
95
              href={`#${slugify(skill.name)}`}
96
              title={intl.formatMessage(displayMessages.sidebarLinkTitle)}
97
            >
98
              {skill.name}
99
            </a>
100
          </li>
101
        ))}
102
      </ul>
103
    </div>
104
  );
105
};
106
107
const SkillTitleButton: React.FunctionComponent<{
108
  modalId: string;
109
  handleClick: (ref: React.RefObject<HTMLButtonElement>) => void;
110
  skillName: string;
111
}> = ({ modalId, handleClick, skillName }) => {
112
  const ref = useRef<HTMLButtonElement>(null);
113
  return (
114
    <button
115
      ref={ref}
116
      data-c-font-size="h3"
117
      data-c-dialog-id={modalId}
118
      type="button"
119
      onClick={() => handleClick(ref)}
120
    >
121
      {skillName}
122
    </button>
123
  );
124
};
125
126
interface ExperienceSkillAccordionProps {
127
  experience: Experience;
128
  experienceSkill: ExperienceSkill;
129
  intl: IntlShape;
130
  status: IconStatus;
131
  skillName: string;
132
  isExpanded: boolean;
133
  setIsExpanded: (value: boolean) => void;
134
  formRef: RefObject<FormikProps<ExperienceSkillFormValues>>;
135
  handleUpdateExperienceJustification: (
136
    experience: ExperienceSkill,
137
  ) => Promise<ExperienceSkill>;
138
  handleUpdateStatus: (action: {
139
    payload: {
140
      skillId: number;
141
      experienceId: number;
142
      experienceType: string;
143
      status: IconStatus;
144
    };
145
  }) => void;
146
  handleRemoveExperience: (experience: ExperienceSkill) => Promise<void>;
147
}
148
149
interface ExperienceSkillFormValues {
150
  justification: string;
151
}
152
153
const ExperienceSkillAccordion: React.FC<ExperienceSkillAccordionProps> = ({
154
  experience,
155
  experienceSkill,
156
  intl,
157
  status,
158
  skillName,
159
  isExpanded,
160
  setIsExpanded,
161
  handleUpdateExperienceJustification,
162
  handleUpdateStatus,
163
  handleRemoveExperience,
164
  formRef,
165
}) => {
166
  let heading = "";
167
  let subHeading = "";
168
  let label = "";
169
170
  const initialValues: ExperienceSkillFormValues = {
171
    justification: experienceSkill.justification || "",
172
  };
173
174
  const experienceSkillSchema = Yup.object().shape({
175
    justification: Yup.string()
176
      .test(
177
        "wordCount",
178
        intl.formatMessage(validationMessages.overMaxWords, {
179
          numberOfWords: JUSTIFICATION_WORD_LIMIT,
180
        }),
181
        (value: string) =>
182
          countNumberOfWords(value) <= JUSTIFICATION_WORD_LIMIT,
183
      )
184
      .required(intl.formatMessage(validationMessages.required)),
185
  });
186
187
  if (experience !== null) {
188
    heading = getExperienceHeading(experience, intl);
189
    subHeading = getExperienceSubheading(experience, intl);
190
    label = getExperienceJustificationLabel(experience, intl, skillName);
191
  }
192
193
  const handleExpandClick = (): void => {
194
    setIsExpanded(!isExpanded);
195
  };
196
197
  const handleRemoveButtonClick = (): void => {
198
    Swal.fire({
199
      title: intl.formatMessage(displayMessages.modalConfirmHeading),
200
      text: intl.formatMessage(displayMessages.modalConfirmBody),
201
      icon: "warning",
202
      showCancelButton: true,
203
      confirmButtonColor: "#0A6CBC",
204
      cancelButtonColor: "#F94D4D",
205
      cancelButtonText: intl.formatMessage(displayMessages.cancel),
206
      confirmButtonText: intl.formatMessage(displayMessages.confirm),
207
    }).then((result: SweetAlertResult) => {
208
      if (result && result.value !== undefined) {
209
        handleRemoveExperience(experienceSkill);
210
      }
211
    });
212
  };
213
214
  const updateExperienceSkill = (
215
    oldExperienceSkill: ExperienceSkill,
216
    values: ExperienceSkillFormValues,
217
  ): ExperienceSkill => {
218
    const experienceJustification: ExperienceSkill = {
219
      ...oldExperienceSkill,
220
      justification: values.justification || "",
221
    };
222
    return experienceJustification;
223
  };
224
225
  return (
226
    <div
227
      data-c-accordion
228
      data-c-background="white(100)"
229
      data-c-card=""
230
      data-c-margin="bottom(.5)"
231
      className={`application-skill-explanation${isExpanded ? " active" : ""}`}
232
    >
233
      <button
234
        aria-expanded={isExpanded ? "true" : "false"}
235
        data-c-accordion-trigger
236
        tabIndex={0}
237
        type="button"
238
        onClick={handleExpandClick}
239
      >
240
        <div data-c-grid="">
241
          <div data-c-grid-item="base(1of4) tl(1of6) equal-col">
242
            <div className="skill-status-indicator">
243
              <StatusIcon status={status} size="h4" />
244
            </div>
245
          </div>
246
          <div data-c-grid-item="base(3of4) tl(5of6)">
247
            <div data-c-padding="all(1)">
248
              <div data-c-grid="middle">
249
                <div data-c-grid-item="tl(3of4) ds(2of4)">
250
                  <p>{heading}</p>
251
                  <p
252
                    data-c-margin="top(quarter)"
253
                    data-c-colour="c1"
254
                    data-c-font-size="small"
255
                  >
256
                    {subHeading}
257
                  </p>
258
                </div>
259
                <div
260
                  data-c-grid-item="tl(1of4) ds(2of4)"
261
                  data-c-align="base(left) tl(center)"
262
                >
263
                  {(experienceSkill.justification === null ||
264
                    experienceSkill.justification.length === 0) && (
265
                    <span data-c-color="stop" className="missing-info">
266
                      <FormattedMessage
267
                        id="application.skills.justificationMissing"
268
                        defaultMessage="Missing Information"
269
                        description="Accordion heading error that displays when the justification is empty."
270
                      />
271
                    </span>
272
                  )}
273
                </div>
274
              </div>
275
            </div>
276
          </div>
277
        </div>
278
        <span data-c-visibility="invisible">
279
          {intl.formatMessage(displayMessages.accessibleAccordionButtonText)}
280
        </span>
281
        {isExpanded ? (
282
          <i
283
            aria-hidden="true"
284
            className="fas fa-angle-up"
285
            data-c-colour="black"
286
            data-c-accordion-remove=""
287
          />
288
        ) : (
289
          <i
290
            aria-hidden="true"
291
            className="fas fa-angle-down"
292
            data-c-colour="black"
293
            data-c-accordion-add=""
294
          />
295
        )}
296
      </button>
297
      <Formik
298
        enableReinitialize
299
        innerRef={formRef}
300
        initialValues={initialValues}
301
        validationSchema={experienceSkillSchema}
302
        onSubmit={(values, { setSubmitting, resetForm }): Promise<void> => {
303
          if (initialValues.justification === values.justification) {
304
            return Promise.resolve();
305
          }
306
          const experienceSkillJustification = updateExperienceSkill(
307
            experienceSkill,
308
            values,
309
          );
310
          return handleUpdateExperienceJustification(
311
            experienceSkillJustification,
312
          )
313
            .then(() => {
314
              handleUpdateStatus({
315
                payload: {
316
                  skillId: experienceSkill.skill_id,
317
                  experienceId: experienceSkill.experience_id,
318
                  experienceType: experienceSkill.experience_type,
319
                  status: IconStatus.COMPLETE,
320
                },
321
              });
322
              setSubmitting(false);
323
              resetForm();
324
            })
325
            .catch(() => {
326
              setSubmitting(false);
327
            });
328
        }}
329
      >
330
        {({ dirty, isSubmitting, isValid, submitForm }): React.ReactElement => (
331
          <div
332
            aria-hidden={isExpanded ? "false" : "true"}
333
            data-c-accordion-content=""
334
            data-c-background="gray(10)"
335
          >
336
            <Form>
337
              <AlertWhenUnsaved />
338
              <hr data-c-hr="thin(gray)" data-c-margin="bottom(1)" />
339
              <div data-c-padding="lr(1)">
340
                <FastField
341
                  id={`experience-skill-textarea-${experienceSkill.id}`}
342
                  name="justification"
343
                  label={label}
344
                  component={TextAreaInput}
345
                  placeholder={intl.formatMessage(
346
                    skillMessages.experienceSkillPlaceholder,
347
                  )}
348
                  required
349
                />
350
              </div>
351
              <div data-c-padding="all(1)">
352
                <div data-c-grid="gutter(all, 1) middle">
353
                  <div
354
                    data-c-grid-item="tp(1of2)"
355
                    data-c-align="base(center) tp(left)"
356
                  >
357
                    <button
358
                      data-c-button="outline(c1)"
359
                      data-c-radius="rounded"
360
                      type="button"
361
                      onClick={handleRemoveButtonClick}
362
                    >
363
                      <span>
364
                        <FormattedMessage
365
                          id="application.skills.deleteExperienceButtonText"
366
                          defaultMessage="Remove Experience From Skill"
367
                          description="Text for the delete experience button."
368
                        />
369
                      </span>
370
                    </button>
371
                  </div>
372
                  <div
373
                    data-c-grid-item="tp(1of2)"
374
                    data-c-align="base(center) tp(right)"
375
                  >
376
                    <WordCounter
377
                      elementId={`experience-skill-textarea-${experienceSkill.id}`}
378
                      maxWords={JUSTIFICATION_WORD_LIMIT}
379
                      minWords={0}
380
                      absoluteValue
381
                      dataAttributes={{ "data-c-margin": "right(1)" }}
382
                      underMaxMessage={intl.formatMessage(
383
                        displayMessages.wordCountUnderMax,
384
                      )}
385
                      overMaxMessage={intl.formatMessage(
386
                        displayMessages.wordCountOverMax,
387
                      )}
388
                    />
389
                    <button
390
                      data-c-button="solid(c1)"
391
                      data-c-radius="rounded"
392
                      type="button"
393
                      disabled={!dirty || isSubmitting}
394
                      onClick={(): void => {
395
                        if (!isValid) {
396
                          handleUpdateStatus({
397
                            payload: {
398
                              skillId: experienceSkill.skill_id,
399
                              experienceId: experienceSkill.experience_id,
400
                              experienceType: experienceSkill.experience_type,
401
                              status: IconStatus.ERROR,
402
                            },
403
                          });
404
                        } else {
405
                          submitForm();
406
                        }
407
                      }}
408
                    >
409
                      <span>
410
                        {dirty
411
                          ? intl.formatMessage(displayMessages.save)
412
                          : intl.formatMessage(displayMessages.saved)}
413
                      </span>
414
                    </button>
415
                  </div>
416
                </div>
417
              </div>
418
            </Form>
419
          </div>
420
        )}
421
      </Formik>
422
    </div>
423
  );
424
};
425
426
interface SkillsProps {
427
  criteria: Criteria[];
428
  experiences: Experience[];
429
  experienceSkills: ExperienceSkill[];
430
  skills: Skill[];
431
  handleUpdateExperienceJustification: (
432
    experience: ExperienceSkill,
433
  ) => Promise<ExperienceSkill>;
434
  handleBatchUpdateExperienceSkills: (
435
    experienceSkillsToUpdate: ExperienceSkill[],
436
  ) => Promise<void>;
437
  handleRemoveExperienceJustification: (
438
    experience: ExperienceSkill,
439
  ) => Promise<void>;
440
  handleContinue: () => void;
441
  handleQuit: () => void;
442
  handleReturn: () => void;
443
}
444
445
const Skills: React.FC<SkillsProps> = ({
446
  criteria,
447
  experiences,
448
  experienceSkills,
449
  skills,
450
  handleUpdateExperienceJustification,
451
  handleBatchUpdateExperienceSkills,
452
  handleRemoveExperienceJustification,
453
  handleContinue,
454
  handleQuit,
455
  handleReturn,
456
}) => {
457
  const intl = useIntl();
458
  const locale = getLocale(intl.locale);
459
  const initial = initialStatus(experienceSkills, JUSTIFICATION_WORD_LIMIT);
460
461
  const [status, dispatchStatus] = useReducer(statusReducer, initial);
462
463
  const modalId = "skill-description";
464
  const [visible, setVisible] = useState(false);
465
  const [modalContent, setModalContent] = useState<{
466
    heading: string;
467
    body: string;
468
  }>({ heading: "", body: "" });
469
  const [
470
    modalTriggerRef,
471
    setModalTriggerRef,
472
  ] = useState<RefObject<HTMLButtonElement> | null>(null);
473
  const modalParentRef = useRef<HTMLDivElement>(null);
474
475
  const closeModal = () => {
476
    setVisible(false);
477
    if (modalTriggerRef?.current) {
478
      modalTriggerRef.current.focus();
479
    } else {
480
      const focusableElements = getFocusableElements();
481
      if (focusableElements.length > 0) {
482
        focusableElements[0].focus();
483
      }
484
    }
485
    setModalTriggerRef(null);
486
  };
487
488
  // This page should only list Hard Skills.
489
  const hardCriteria = criteria
490
    .map((criterion) => {
491
      const skill = getSkillOfCriteria(criterion, skills);
492
      return { hardCriterion: criterion, skill };
493
    })
494
    .filter((criterionWithSkill) => {
495
      return criterionWithSkill.skill?.skill_type_id === SkillTypeId.Hard;
496
    })
497
    .sort(
498
      (
499
        a: { hardCriterion: Criteria; skill: Skill },
500
        b: { hardCriterion: Criteria; skill: Skill },
501
      ) =>
502
        localizeFieldNonNull(locale, a.skill, "name")
503
          .toUpperCase()
504
          .localeCompare(
505
            localizeFieldNonNull(locale, b.skill, "name").toUpperCase(),
506
          ),
507
    );
508
509
  const softSkills = removeDuplicatesById(
510
    criteria
511
      .map((criterion) => getSkillOfCriteria(criterion, skills))
512
      .filter(notEmpty)
513
      .filter((skill) => skill.skill_type_id === SkillTypeId.Soft),
514
  );
515
516
  // Maps ExperienceSkill ids to their accordion expansion state.
517
  const [accordionExpansions, setAccordionExpansions] = useState<{
518
    [experienceSkillId: number]: boolean;
519
  }>(mapToObjectTrans(experienceSkills, getId, () => false));
520
521
  const menuSkills = hardCriteria.reduce(
522
    (
523
      collection: { id: number; name: string }[],
524
      criterion: { hardCriterion: Criteria; skill: Skill },
525
    ) => {
526
      if (!collection.find((a) => criterion.hardCriterion.skill_id === a.id)) {
527
        // Do not include duplicate skills.
528
        collection.push({
529
          id: criterion.hardCriterion.skill_id,
530
          name: localizeFieldNonNull(locale, criterion.skill, "name"),
531
        });
532
      }
533
      return collection;
534
    },
535
    [],
536
  );
537
538
  const [isSubmitting, setIsSubmitting] = useState(false);
539
  /**
540
   * Returns a list of experience skills with updated justifications received from the formik form refs.
541
   * @param refMap A map of experienceSkill id to formik Form ref
542
   * @returns List of experience skills
543
   */
544
  const getExperienceSkillsToUpdate = (
545
    refMap: Map<
546
      number,
547
      React.RefObject<FormikProps<ExperienceSkillFormValues>>
548
    >,
549
  ): ExperienceSkill[] =>
550
    experienceSkills.reduce(
551
      (
552
        accumulator: ExperienceSkill[],
553
        currentValue: ExperienceSkill,
554
      ): ExperienceSkill[] => {
555
        const formRefEntry = Array.from(refMap.entries()).find(
556
          ([experienceSkillId, formRef]) =>
557
            experienceSkillId === currentValue.id,
558
        );
559
560
        if (formRefEntry !== null && formRefEntry !== undefined) {
561
          const [experienceSkillId, formRef] = formRefEntry;
562
          if (
563
            formRef.current !== null &&
564
            currentValue.justification !== formRef.current.values.justification
565
          ) {
566
            accumulator.push({
567
              ...currentValue,
568
              justification: formRef.current.values.justification,
569
            });
570
            return accumulator;
571
          }
572
        }
573
574
        return accumulator;
575
      },
576
      [],
577
    );
578
  /**
579
   * Validate many Formik forms, and submit all of them if all are valid.
580
   * @param refMap A map of experienceSkill id to formik Form ref
581
   * @returns A promise that resolves if submission succeeds, and rejects if validation or submission fails.
582
   */
583
  const validateAndSubmitMany = async (
584
    refMap: Map<
585
      number,
586
      React.RefObject<FormikProps<ExperienceSkillFormValues>>
587
    >,
588
  ): Promise<void> => {
589
    setIsSubmitting(true);
590
    const refs = Array.from(refMap.values()).filter(notEmpty);
591
    const formsAreValid = await validateAllForms(refs);
592
    if (formsAreValid) {
593
      const experienceSkillsToUpdate: ExperienceSkill[] = getExperienceSkillsToUpdate(
594
        refMap,
595
      );
596
      if (experienceSkillsToUpdate.length > 0) {
597
        try {
598
          await handleBatchUpdateExperienceSkills(experienceSkillsToUpdate);
599
          setIsSubmitting(false);
600
          return Promise.resolve();
601
        } catch {
602
          setIsSubmitting(false);
603
          return Promise.reject();
604
        }
605
      }
606
    } else {
607
      // Set icon status for all invalid form elements.
608
      Array.from(refMap.entries()).forEach(([experienceSkillId, formRef]) => {
609
        if (formRef.current !== null && !formRef.current.isValid) {
610
          const experienceSkill = find(experienceSkills, experienceSkillId);
611
          if (experienceSkill !== null) {
612
            dispatchStatus({
613
              payload: {
614
                skillId: experienceSkill.skill_id,
615
                experienceId: experienceSkill.experience_id,
616
                experienceType: experienceSkill.experience_type,
617
                status: IconStatus.ERROR,
618
              },
619
            });
620
          }
621
        }
622
      });
623
624
      // Expand and focus on the first invalid accordion form element.
625
      Array.from(refMap.entries()).some(([experienceSkillId, formRef]) => {
626
        if (formRef.current !== null && !formRef.current.isValid) {
627
          // Ensure the accordion is expanded before focussing on it.
628
          setAccordionExpansions({
629
            ...accordionExpansions,
630
            [experienceSkillId]: true,
631
          });
632
          focusOnElement(`#experience-skill-textarea-${experienceSkillId}`);
633
          return true;
634
        }
635
        return false;
636
      });
637
      setIsSubmitting(false);
638
      return Promise.reject();
639
    }
640
  };
641
642
  /**
643
   * Validate many Formik forms, and submit only valid ones.
644
   * @param refMap A map of experienceSkill id to formik Form ref
645
   * @returns A promise that resolves if submission succeeds, and rejects if validation or submission fails.
646
   */
647
  const saveManyAndQuit = async (
648
    refMap: Map<
649
      number,
650
      React.RefObject<FormikProps<ExperienceSkillFormValues>>
651
    >,
652
  ): Promise<void> => {
653
    setIsSubmitting(true);
654
    const refs = Array.from(refMap.values()).filter(notEmpty);
655
    const validFormsRefs: React.RefObject<
656
      FormikProps<FormikValues>
657
    >[] = await getValidForms(refs);
658
    const validFormsRefMap: Map<
659
      number,
660
      React.RefObject<FormikProps<ExperienceSkillFormValues>>
661
    > = new Map();
662
663
    refMap.forEach((ref, experienceSkillId) => {
664
      if (validFormsRefs.includes(ref)) {
665
        validFormsRefMap.set(experienceSkillId, ref);
666
      }
667
    });
668
669
    if (validFormsRefMap.size > 0) {
670
      const experienceSkillsToUpdate: ExperienceSkill[] = getExperienceSkillsToUpdate(
671
        validFormsRefMap,
672
      );
673
674
      if (experienceSkillsToUpdate.length > 0) {
675
        try {
676
          await handleBatchUpdateExperienceSkills(experienceSkillsToUpdate);
677
          setIsSubmitting(false);
678
          return Promise.resolve();
679
        } catch {
680
          setIsSubmitting(false);
681
          return Promise.reject();
682
        }
683
      }
684
    }
685
  };
686
687
  // formRefs holds a dictionary of experienceSkill.id to refs to Formik forms.
688
  const formRefs = React.useRef<
689
    Map<number, React.RefObject<FormikProps<ExperienceSkillFormValues>>>
690
  >(new Map());
691
692
  // Ensure each experienceSkill has a corresponding form ref
693
  experienceSkills.forEach((expSkill) => {
694
    if (
695
      hardCriteria.find(
696
        (criterion) => criterion.hardCriterion.skill_id === expSkill.skill_id,
697
      ) &&
698
      !formRefs.current.has(expSkill.id)
699
    ) {
700
      const ref = createRef<FormikProps<ExperienceSkillFormValues>>();
701
      formRefs.current.set(expSkill.id, ref);
702
    }
703
  });
704
705
  return (
706
    <div data-c-container="large" ref={modalParentRef}>
707
      <div data-c-grid="gutter(all, 1)">
708
        <div data-c-grid-item="tl(1of4)">
709
          <Sidebar menuSkills={menuSkills} intl={intl} status={status} />
710
        </div>
711
        <div data-c-grid-item="tl(3of4)">
712
          <h2 data-c-heading="h2" data-c-margin="top(3) bottom(1)">
713
            <FormattedMessage
714
              id="application.skills.heading"
715
              defaultMessage="How You Used Each Skill"
716
              description="Heading text on the Skills step."
717
            />
718
          </h2>
719
          <p data-c-margin="bottom(1)">
720
            <FormattedMessage
721
              id="application.skills.instructionHeading"
722
              defaultMessage="<bold>This is the most important part of your application.</bold> Each box only needs a couple of sentences, but make them good ones!"
723
              description="Heading for the instruction section on the Skills step."
724
              values={{
725
                bold: (chunks) => (
726
                  <span data-c-font-weight="bold">{chunks}</span>
727
                ),
728
              }}
729
            />
730
          </p>
731
          <p data-c-margin="bottom(.5)">
732
            <FormattedMessage
733
              id="application.skills.instructionListStart"
734
              defaultMessage="Try answering one or two of the following questions:"
735
              description="Paragraph before the instruction list on the Skills step."
736
            />
737
          </p>
738
          <ul data-c-margin="bottom(1)">
739
            <FormattedMessage
740
              id="application.skills.instructionList"
741
              defaultMessage="<bullet>What did you accomplish, create, or deliver using this skill?</bullet><bullet>What tasks or activities did you do that relate to this skill?</bullet><bullet>Were there any special techniques or approaches that you used?</bullet><bullet>How much responsibility did you have in this role?</bullet>"
742
              description="List of potential justification helpers on the Skills step."
743
              values={{
744
                bullet: (chunks) => <li>{chunks}</li>,
745
              }}
746
            />
747
          </ul>
748
          <p data-c-margin="bottom(1)">
749
            <FormattedMessage
750
              id="application.skills.instructionListEnd"
751
              defaultMessage="If a skill is only loosely connected to an experience, consider removing it. This can help the manager focus on your best examples."
752
              description="Paragraph after the instruction list on the Skills step."
753
            />
754
          </p>
755
          <p data-c-color="gray">
756
            {intl.formatMessage(experienceMessages.softSkillsList, {
757
              skill: (
758
                <>
759
                  {softSkills.map((skill, index) => {
760
                    const and = " and ";
761
                    const lastElement = index === softSkills.length - 1;
762
                    return (
763
                      <React.Fragment key={skill.id}>
764
                        {lastElement && softSkills.length > 1 && and}
765
                        <span key={skill.id} data-c-font-weight="bold">
766
                          {localizeFieldNonNull(locale, skill, "name")}
767
                        </span>
768
                        {!lastElement && softSkills.length > 2 && ", "}
769
                      </React.Fragment>
770
                    );
771
                  })}
772
                </>
773
              ),
774
            })}
775
          </p>
776
          <div className="skills-list">
777
            {hardCriteria.map((criterion) => {
778
              const { hardCriterion, skill } = criterion;
779
              if (skill === null) {
780
                return null;
781
              }
782
              const skillName = localizeFieldNonNull(locale, skill, "name");
783
              const skillDescription = localizeFieldNonNull(
784
                locale,
785
                skill,
786
                "description",
787
              );
788
              const skillHtmlId = slugify(skillName);
789
790
              return (
791
                <div key={hardCriterion.id}>
792
                  <h3
793
                    className="application-skill-title"
794
                    data-c-heading="h3"
795
                    data-c-padding="top(3) bottom(1)"
796
                    data-c-margin="bottom(1)"
797
                    id={skillHtmlId}
798
                  >
799
                    <SkillTitleButton
800
                      modalId={modalId}
801
                      skillName={skillName}
802
                      handleClick={(ref): void => {
803
                        setModalContent({
804
                          heading: skillName,
805
                          body: skillDescription,
806
                        });
807
                        setModalTriggerRef(ref);
808
                        setVisible(true);
809
                      }}
810
                    />
811
                    <br />
812
                    <a
813
                      data-c-font-size="normal"
814
                      data-c-font-weight="bold"
815
                      href={applicantFaq(locale, "levels")}
816
                    >
817
                      {intl.formatMessage(
818
                        getSkillLevelName(hardCriterion, skill),
819
                      )}
820
                    </a>
821
                  </h3>
822
                  {getExperiencesOfSkill(skill, experienceSkills).length ===
823
                  0 ? (
824
                    <div
825
                      data-c-background="gray(10)"
826
                      data-c-radius="rounded"
827
                      data-c-border="all(thin, solid, gray)"
828
                      data-c-padding="all(1)"
829
                    >
830
                      <div data-c-align="base(center)">
831
                        <p data-c-color="gray">
832
                          <FormattedMessage
833
                            id="application.skills.noLinkedExperiences"
834
                            defaultMessage="Looks like you don't have any experiences linked to this skill. You can link experiences to skills in the previous step."
835
                            description="Text displayed under a skill section with no experiences."
836
                          />
837
                        </p>
838
                      </div>
839
                    </div>
840
                  ) : (
841
                    <div data-c-accordion-group="">
842
                      {getExperiencesOfSkill(skill, experienceSkills).map(
843
                        (experienceSkill) => {
844
                          const experienceStatus = computeExperienceStatus(
845
                            status,
846
                            experienceSkill,
847
                          );
848
                          const relevantExperience = getExperienceOfExperienceSkill(
849
                            experienceSkill,
850
                            experiences,
851
                          );
852
                          const elementKey = `experience-skill-textarea-${experienceSkill.id}`;
853
                          if (relevantExperience === null) {
854
                            return (
855
                              <div
856
                                key={elementKey}
857
                                data-c-background="gray(10)"
858
                                data-c-radius="rounded"
859
                                data-c-border="all(thin, solid, gray)"
860
                                data-c-padding="all(1)"
861
                              >
862
                                <div data-c-align="base(center)">
863
                                  <p data-c-color="gray">
864
                                    <FormattedMessage
865
                                      id="application.skills.missingExperience"
866
                                      defaultMessage="Looks like something went wrong on our end and your experience can't be displayed. Please try again later."
867
                                      description="Text displayed under a skill section with a missing linked experience."
868
                                    />
869
                                  </p>
870
                                </div>
871
                              </div>
872
                            );
873
                          }
874
875
                          if (!formRefs.current.has(experienceSkill.id)) {
876
                            const ref = createRef<
877
                              FormikProps<ExperienceSkillFormValues>
878
                            >();
879
                            formRefs.current.set(experienceSkill.id, ref);
880
                          }
881
                          return (
882
                            <ExperienceSkillAccordion
883
                              key={elementKey}
884
                              experience={relevantExperience}
885
                              experienceSkill={experienceSkill}
886
                              intl={intl}
887
                              status={experienceStatus}
888
                              handleUpdateStatus={dispatchStatus}
889
                              skillName={skillName}
890
                              isExpanded={
891
                                accordionExpansions[experienceSkill.id]
892
                              }
893
                              setIsExpanded={(value: boolean): void =>
894
                                setAccordionExpansions({
895
                                  ...accordionExpansions,
896
                                  [experienceSkill.id]: value,
897
                                })
898
                              }
899
                              formRef={
900
                                formRefs.current.get(experienceSkill.id)! // Can assert this is not null, because if it was we just added it to map.
901
                              }
902
                              handleUpdateExperienceJustification={
903
                                handleUpdateExperienceJustification
904
                              }
905
                              handleRemoveExperience={
906
                                handleRemoveExperienceJustification
907
                              }
908
                            />
909
                          );
910
                        },
911
                      )}
912
                    </div>
913
                  )}
914
                </div>
915
              );
916
            })}
917
          </div>
918
          <div data-c-container="medium" data-c-padding="tb(2)">
919
            <hr data-c-hr="thin(c1)" data-c-margin="bottom(2)" />
920
            <div data-c-grid="gutter">
921
              <div
922
                data-c-alignment="base(centre) tp(left)"
923
                data-c-grid-item="tp(1of2)"
924
              >
925
                <button
926
                  data-c-button="outline(c2)"
927
                  data-c-radius="rounded"
928
                  type="button"
929
                  disabled={isSubmitting}
930
                  onClick={(): Promise<void> =>
931
                    validateAndSubmitMany(formRefs.current)
932
                      .then(handleReturn)
933
                      .catch(() => {
934
                        // Validation failed, do nothing.
935
                      })
936
                  }
937
                >
938
                  {intl.formatMessage(navigationMessages.return)}
939
                </button>
940
              </div>
941
              <div
942
                data-c-alignment="base(centre) tp(right)"
943
                data-c-grid-item="tp(1of2)"
944
              >
945
                <button
946
                  data-c-button="outline(c2)"
947
                  data-c-radius="rounded"
948
                  type="button"
949
                  disabled={isSubmitting}
950
                  onClick={(): Promise<void> =>
951
                    saveManyAndQuit(formRefs.current).then(handleQuit)
952
                  }
953
                >
954
                  {intl.formatMessage(navigationMessages.quit)}
955
                </button>
956
                <button
957
                  data-c-button="solid(c1)"
958
                  data-c-radius="rounded"
959
                  data-c-margin="left(1)"
960
                  disabled={isSubmitting}
961
                  type="button"
962
                  onClick={(): Promise<void> =>
963
                    validateAndSubmitMany(formRefs.current)
964
                      .then(handleContinue)
965
                      .catch(() => {
966
                        // Validation failed, do nothing.
967
                      })
968
                  }
969
                >
970
                  {intl.formatMessage(navigationMessages.continue)}
971
                </button>
972
              </div>
973
            </div>
974
          </div>
975
        </div>
976
      </div>
977
      <div data-c-dialog-overlay={visible ? "active" : ""} />
978
      <Modal
979
        id={modalId}
980
        parentElement={modalParentRef.current}
981
        visible={visible}
982
        onModalConfirm={closeModal}
983
        onModalCancel={closeModal}
984
      >
985
        <Modal.Header>
986
          <div
987
            data-c-padding="tb(1)"
988
            data-c-border="bottom(thin, solid, black)"
989
            data-c-background="c1(100)"
990
            className="dialog-header"
991
          >
992
            <div data-c-container="medium">
993
              <h5
994
                data-c-font-size="h3"
995
                data-c-font-weight="bold"
996
                id={`${modalId}-title`}
997
                data-c-dialog-focus=""
998
                data-c-color="white"
999
                tabIndex={0}
1000
              >
1001
                {modalContent.heading}
1002
              </h5>
1003
              <button
1004
                data-c-dialog-action="close"
1005
                data-c-dialog-id={`${modalId}`}
1006
                type="button"
1007
                data-c-color="white"
1008
                onClick={closeModal}
1009
              >
1010
                <i className="fas fa-times" />
1011
              </button>
1012
            </div>
1013
          </div>
1014
        </Modal.Header>
1015
        <Modal.Body>
1016
          <div data-c-border="bottom(thin, solid, black)">
1017
            <div id={`${modalId}-description`}>
1018
              <div data-c-container="medium" data-c-padding="tb(1)">
1019
                <p>{modalContent.body}</p>
1020
              </div>
1021
            </div>
1022
          </div>
1023
        </Modal.Body>
1024
        <Modal.Footer>
1025
          <Modal.FooterConfirmBtn>
1026
            <FormattedMessage
1027
              id="application.skills.modal.confirmButton"
1028
              defaultMessage="Okay"
1029
            />
1030
          </Modal.FooterConfirmBtn>
1031
        </Modal.Footer>
1032
      </Modal>
1033
    </div>
1034
  );
1035
};
1036
1037
export default Skills;
1038