Passed
Push — feature/post-covid-application... ( 4340d4...92a3aa )
by Yonathan
08:31 queued 01:05
created

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

Complexity

Total Complexity 13
Complexity/F 0

Size

Lines of Code 658
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 13
eloc 534
dl 0
loc 658
rs 10
c 0
b 0
f 0
mnd 13
bc 13
fnc 0
bpm 0
cpm 0
noi 0
1
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
2
/* eslint camelcase: "off", @typescript-eslint/camelcase: "off" */
3
import React, { useState, useReducer, useRef } from "react";
4
import { FormattedMessage, useIntl, IntlShape } from "react-intl";
5
import { Formik, Form, FastField } from "formik";
6
import * as Yup from "yup";
7
import Swal, { SweetAlertResult } from "sweetalert2";
8
import {
9
  ExperienceSkill,
10
  Skill,
11
  Criteria,
12
  Experience,
13
} from "../../../models/types";
14
import { slugify, applicantFaq } from "../../../helpers/routes";
15
import { getLocale, localizeFieldNonNull } from "../../../helpers/localize";
16
import { validationMessages } from "../../Form/Messages";
17
import {
18
  getExperienceHeading,
19
  getExperienceSubheading,
20
  getExperienceJustificationLabel,
21
} from "../../../models/localizedConstants";
22
import { getSkillLevelName } from "../../../models/jobUtil";
23
import StatusIcon, { IconStatus } from "../../StatusIcon";
24
import AlertWhenUnsaved from "../../Form/AlertWhenUnsaved";
25
import TextAreaInput from "../../Form/TextAreaInput";
26
import WordCounter from "../../WordCounter/WordCounter";
27
import { countNumberOfWords } from "../../WordCounter/helpers";
28
import displayMessages from "./SkillsMessages";
29
import {
30
  getSkillOfCriteria,
31
  getExperiencesOfSkill,
32
  statusReducer,
33
  initialStatus,
34
  computeParentStatus,
35
  SkillStatus,
36
  getExperienceOfExperienceSkills,
37
} from "./SkillsHelpers";
38
import Modal from "../../Modal";
39
40
const JUSTIFICATION_WORD_LIMIT = 100;
41
42
interface SidebarProps {
43
  menuSkills: { [skillId: number]: string };
44
  intl: IntlShape;
45
  status: SkillStatus;
46
}
47
48
const Sidebar: React.FC<SidebarProps> = ({ menuSkills, intl, status }) => {
49
  return (
50
    <div data-c-padding="top(3)" className="application-skill-navigation">
51
      <p
52
        data-c-font-size="h3"
53
        data-c-font-weight="bold"
54
        data-c-margin="bottom(1)"
55
      >
56
        <FormattedMessage
57
          id="application.skills.sidebarHeading"
58
          defaultMessage="On this page:"
59
          description="Heading for the sidebar on the Skills page."
60
        />
61
      </p>
62
      <ul>
63
        {Object.keys(menuSkills).map((skillId) => (
64
          <li key={skillId}>
65
            <StatusIcon
66
              status={computeParentStatus(status, Number(skillId))}
67
              size=""
68
            />
69
            <a
70
              href={`#${slugify(menuSkills[skillId])}`}
71
              title={intl.formatMessage(displayMessages.sidebarLinkTitle)}
72
            >
73
              {menuSkills[skillId]}
74
            </a>
75
          </li>
76
        ))}
77
      </ul>
78
    </div>
79
  );
80
};
81
82
interface ExperienceSkillAccordionProps {
83
  experience: Experience;
84
  experienceSkill: ExperienceSkill;
85
  intl: IntlShape;
86
  status: IconStatus;
87
  skillName: string;
88
  handleUpdateExperienceJustification: (
89
    experience: ExperienceSkill,
90
  ) => Promise<ExperienceSkill>;
91
  handleUpdateStatus: (action: {
92
    payload: {
93
      skillId: number;
94
      experienceId: number;
95
      experienceType: string;
96
      status: IconStatus;
97
    };
98
  }) => void;
99
  handleRemoveExperience: (
100
    experience: ExperienceSkill,
101
  ) => Promise<ExperienceSkill>;
102
}
103
104
interface ExperienceSkillFormValues {
105
  justification: string;
106
}
107
108
const ExperienceSkillAccordion: React.FC<ExperienceSkillAccordionProps> = ({
109
  experience,
110
  experienceSkill,
111
  intl,
112
  status,
113
  skillName,
114
  handleUpdateExperienceJustification,
115
  handleUpdateStatus,
116
  handleRemoveExperience,
117
}) => {
118
  const [isExpanded, setIsExpanded] = useState(false);
119
  let heading = "";
120
  let subHeading = "";
121
  let label = "";
122
123
  const initialValues: ExperienceSkillFormValues = {
124
    justification: experienceSkill.justification || "",
125
  };
126
127
  const experienceSkillSchema = Yup.object().shape({
128
    justification: Yup.string()
129
      .test(
130
        "wordCount",
131
        intl.formatMessage(validationMessages.overMaxWords, {
132
          numberOfWords: JUSTIFICATION_WORD_LIMIT,
133
        }),
134
        (value) => countNumberOfWords(value) <= JUSTIFICATION_WORD_LIMIT,
135
      )
136
      .required(intl.formatMessage(validationMessages.required)),
137
  });
138
139
  if (experience !== null) {
140
    heading = getExperienceHeading(experience, intl);
141
    subHeading = getExperienceSubheading(experience, intl);
142
    label = getExperienceJustificationLabel(experience, intl, skillName);
143
  }
144
145
  const handleExpandClick = (): void => {
146
    setIsExpanded(!isExpanded);
147
  };
148
149
  const handleRemoveButtonClick = (): void => {
150
    Swal.fire({
151
      title: intl.formatMessage(displayMessages.modalConfirmHeading),
152
      text: intl.formatMessage(displayMessages.modalConfirmBody),
153
      icon: "warning",
154
      showCancelButton: true,
155
      confirmButtonColor: "#0A6CBC",
156
      cancelButtonColor: "#F94D4D",
157
      cancelButtonText: intl.formatMessage(displayMessages.cancel),
158
      confirmButtonText: intl.formatMessage(displayMessages.confirm),
159
    }).then((result: SweetAlertResult) => {
160
      if (result && result.value !== undefined) {
161
        handleRemoveExperience(experienceSkill);
162
      }
163
    });
164
  };
165
166
  const updateExperienceSkill = (
167
    oldExperienceSkill: ExperienceSkill,
168
    values: ExperienceSkillFormValues,
169
  ): ExperienceSkill => {
170
    const experienceJustification: ExperienceSkill = {
171
      ...oldExperienceSkill,
172
      justification: values.justification || "",
173
    };
174
    return experienceJustification;
175
  };
176
177
  return (
178
    <div
179
      data-c-accordion=""
180
      data-c-background="white(100)"
181
      data-c-card=""
182
      data-c-margin="bottom(.5)"
183
      className={`application-skill-explanation${isExpanded ? " active" : ""}`}
184
    >
185
      <button
186
        aria-expanded={isExpanded ? "true" : "false"}
187
        data-c-accordion-trigger=""
188
        tabIndex={0}
189
        type="button"
190
        onClick={handleExpandClick}
191
      >
192
        <div data-c-grid="">
193
          <div data-c-grid-item="base(1of4) tl(1of6) equal-col">
194
            <div className="skill-status-indicator">
195
              <StatusIcon status={status} size="h4" />
196
            </div>
197
          </div>
198
          <div data-c-grid-item="base(3of4) tl(5of6)">
199
            <div data-c-padding="all(1)">
200
              <div data-c-grid="middle">
201
                <div data-c-grid-item="tl(3of4)">
202
                  <p>{heading}</p>
203
                  <p
204
                    data-c-margin="top(quarter)"
205
                    data-c-colour="c1"
206
                    data-c-font-size="small"
207
                  >
208
                    {subHeading}
209
                  </p>
210
                </div>
211
                <div
212
                  data-c-grid-item="tl(1of4)"
213
                  data-c-align="base(left) tl(center)"
214
                >
215
                  {experienceSkill.justification.length === 0 && (
216
                    <span data-c-color="stop" className="missing-info">
217
                      <FormattedMessage
218
                        id="application.skills.justificationMissing"
219
                        defaultMessage="Missing Information"
220
                        description="Accordion heading error that displays when the justification is empty."
221
                      />
222
                    </span>
223
                  )}
224
                </div>
225
              </div>
226
            </div>
227
          </div>
228
        </div>
229
        <span data-c-visibility="invisible">
230
          {intl.formatMessage(displayMessages.accessibleAccordionButtonText)}
231
        </span>
232
        {isExpanded ? (
233
          <i
234
            aria-hidden="true"
235
            className="fas fa-angle-up"
236
            data-c-colour="black"
237
            data-c-accordion-remove=""
238
          />
239
        ) : (
240
          <i
241
            aria-hidden="true"
242
            className="fas fa-angle-down"
243
            data-c-colour="black"
244
            data-c-accordion-add=""
245
          />
246
        )}
247
      </button>
248
      {isExpanded && (
249
        <Formik
250
          initialValues={initialValues}
251
          validationSchema={experienceSkillSchema}
252
          onSubmit={(values, { setSubmitting, resetForm }): void => {
253
            const experienceSkillJustification = updateExperienceSkill(
254
              experienceSkill,
255
              values,
256
            );
257
            handleUpdateExperienceJustification(experienceSkillJustification)
258
              .then(() => {
259
                handleUpdateStatus({
260
                  payload: {
261
                    skillId: experienceSkill.skill_id,
262
                    experienceId: experienceSkill.experience_id,
263
                    experienceType: experienceSkill.experience_type,
264
                    status: IconStatus.COMPLETE,
265
                  },
266
                });
267
                setSubmitting(false);
268
                resetForm();
269
              })
270
              .catch(() => {
271
                setSubmitting(false);
272
              });
273
          }}
274
        >
275
          {({
276
            dirty,
277
            isSubmitting,
278
            isValid,
279
            submitForm,
280
          }): React.ReactElement => (
281
            <div
282
              aria-hidden={isExpanded ? "false" : "true"}
283
              data-c-accordion-content=""
284
              data-c-background="gray(10)"
285
            >
286
              <Form>
287
                <AlertWhenUnsaved />
288
                <hr data-c-hr="thin(gray)" data-c-margin="bottom(1)" />
289
                <div data-c-padding="lr(1)">
290
                  <FastField
291
                    id={`experience-skill-textarea-${experienceSkill.experience_type}-${experienceSkill.skill_id}-${experienceSkill.experience_id}`}
292
                    name="justification"
293
                    label={label}
294
                    component={TextAreaInput}
295
                    placeholder="Start writing here..."
296
                    required
297
                  />
298
                </div>
299
                <div data-c-padding="all(1)">
300
                  <div data-c-grid="gutter(all, 1) middle">
301
                    <div
302
                      data-c-grid-item="tp(1of2)"
303
                      data-c-align="base(center) tp(left)"
304
                    >
305
                      <button
306
                        data-c-button="outline(c1)"
307
                        data-c-radius="rounded"
308
                        type="button"
309
                        onClick={handleRemoveButtonClick}
310
                      >
311
                        <span>
312
                          <FormattedMessage
313
                            id="application.skills.deleteExperienceButtonText"
314
                            defaultMessage="Remove Experience From Skill"
315
                            description="Text for the delete experience button."
316
                          />
317
                        </span>
318
                      </button>
319
                    </div>
320
                    <div
321
                      data-c-grid-item="tp(1of2)"
322
                      data-c-align="base(center) tp(right)"
323
                    >
324
                      <WordCounter
325
                        elementId={`experience-skill-textarea-${experienceSkill.experience_type}-${experienceSkill.skill_id}-${experienceSkill.experience_id}`}
326
                        maxWords={JUSTIFICATION_WORD_LIMIT}
327
                        minWords={0}
328
                        absoluteValue
329
                        dataAttributes={{ "data-c-margin": "right(1)" }}
330
                        underMaxMessage={intl.formatMessage(
331
                          displayMessages.wordCountUnderMax,
332
                        )}
333
                        overMaxMessage={intl.formatMessage(
334
                          displayMessages.wordCountOverMax,
335
                        )}
336
                      />
337
                      <button
338
                        data-c-button="solid(c1)"
339
                        data-c-radius="rounded"
340
                        type="button"
341
                        disabled={!dirty || isSubmitting}
342
                        onClick={() => {
343
                          if (!isValid) {
344
                            handleUpdateStatus({
345
                              payload: {
346
                                skillId: experienceSkill.skill_id,
347
                                experienceId: experienceSkill.experience_id,
348
                                experienceType: experienceSkill.experience_type,
349
                                status: IconStatus.ERROR,
350
                              },
351
                            });
352
                          } else {
353
                            submitForm();
354
                          }
355
                        }}
356
                      >
357
                        <span>
358
                          {dirty
359
                            ? intl.formatMessage(displayMessages.save)
360
                            : intl.formatMessage(displayMessages.saved)}
361
                        </span>
362
                      </button>
363
                    </div>
364
                  </div>
365
                </div>
366
              </Form>
367
            </div>
368
          )}
369
        </Formik>
370
      )}
371
    </div>
372
  );
373
};
374
375
interface SkillsProps {
376
  criteria: Criteria[];
377
  experiences: Experience[];
378
  experienceSkills: ExperienceSkill[];
379
  skills: Skill[];
380
  handleUpdateExperienceJustification: (
381
    experience: ExperienceSkill,
382
  ) => Promise<ExperienceSkill>;
383
  handleRemoveExperienceJustification: (
384
    experience: ExperienceSkill,
385
  ) => Promise<ExperienceSkill>;
386
}
387
388
const Skills: React.FC<SkillsProps> = ({
389
  criteria,
390
  experiences,
391
  experienceSkills,
392
  skills,
393
  handleUpdateExperienceJustification,
394
  handleRemoveExperienceJustification,
395
}) => {
396
  const intl = useIntl();
397
  const locale = getLocale(intl.locale);
398
  const initial = initialStatus(experienceSkills);
399
400
  const [status, dispatchStatus] = useReducer(statusReducer, initial);
401
402
  const modalId = "skill-description";
403
  const [visible, setVisible] = useState(false);
404
  const [modalHeading, setModalHeading] = useState("");
405
  const [modalBody, setModalBody] = useState("");
406
  const modalParentRef = useRef<HTMLDivElement>(null);
407
408
  const menuSkills = criteria.reduce(
409
    (collection: { [skillId: number]: string }, criterion: Criteria) => {
410
      const skill = getSkillOfCriteria(criterion, skills);
411
      if (skill && !collection[criterion.skill_id]) {
412
        // eslint-disable-next-line no-param-reassign
413
        collection[criterion.skill_id] = localizeFieldNonNull(
414
          locale,
415
          skill,
416
          "name",
417
        );
418
      }
419
      return collection;
420
    },
421
    [],
422
  );
423
424
  return (
425
    <div data-c-container="large" ref={modalParentRef}>
426
      <div data-c-grid="gutter(all, 1)">
427
        <div data-c-grid-item="tl(1of4)">
428
          <Sidebar menuSkills={menuSkills} intl={intl} status={status} />
429
        </div>
430
        <div data-c-grid-item="tl(3of4)">
431
          <h2 data-c-heading="h2" data-c-margin="top(3) bottom(1)">
432
            <FormattedMessage
433
              id="application.skills.heading"
434
              defaultMessage="How You Used Each Skill"
435
              description="Heading text on the Skills step."
436
            />
437
          </h2>
438
          <p data-c-margin="bottom(1)">
439
            <FormattedMessage
440
              id="application.skills.instructionHeading"
441
              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!"
442
              description="Heading for the instruction section on the Skills step."
443
              values={{
444
                bold: (chunks) => (
445
                  <span data-c-font-weight="bold">{chunks}</span>
446
                ),
447
              }}
448
            />
449
          </p>
450
          <p data-c-margin="bottom(.5)">
451
            <FormattedMessage
452
              id="application.skills.instructionListStart"
453
              defaultMessage="Try answering one or two of the following questions:"
454
              description="Paragraph before the instruction list on the Skills step."
455
            />
456
          </p>
457
          <ul data-c-margin="bottom(1)">
458
            <FormattedMessage
459
              id="application.skills.instructionList"
460
              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>"
461
              description="List of potential justification helpers on the Skills step."
462
              values={{
463
                bullet: (chunks) => <li>{chunks}</li>,
464
              }}
465
            />
466
          </ul>
467
          <p>
468
            <FormattedMessage
469
              id="application.skills.instructionListEnd"
470
              defaultMessage="If a skill is only loosely connected to an experience, consider removing it. This can help the manager focus on your best examples."
471
              description="Paragraph after the instruction list on the Skills step."
472
            />
473
          </p>
474
          <div className="skills-list">
475
            {criteria.map((criterion) => {
476
              const skill = getSkillOfCriteria(criterion, skills);
477
              if (skill === null) {
478
                return null;
479
              }
480
              const skillName = localizeFieldNonNull(locale, skill, "name");
481
              const skillDescription = localizeFieldNonNull(
482
                locale,
483
                skill,
484
                "description",
485
              );
486
              const skillHtmlId = slugify(skillName);
487
488
              return (
489
                <div key={skill.id}>
490
                  <h3
491
                    className="application-skill-title"
492
                    data-c-heading="h3"
493
                    data-c-padding="top(3) bottom(1)"
494
                    data-c-margin="bottom(1)"
495
                    id={skillHtmlId}
496
                  >
497
                    <button
498
                      data-c-font-size="h3"
499
                      data-c-dialog-id={modalId}
500
                      type="button"
501
                      onClick={(e): void => {
502
                        setModalHeading(skillName);
503
                        setModalBody(skillDescription);
504
                        setVisible(true);
505
                      }}
506
                    >
507
                      {skillName}
508
                    </button>
509
                    <br />
510
                    <a
511
                      data-c-font-size="normal"
512
                      data-c-font-weight="bold"
513
                      href={applicantFaq(locale, "levels")}
514
                    >
515
                      {intl.formatMessage(getSkillLevelName(criterion, skill))}
516
                    </a>
517
                  </h3>
518
                  {getExperiencesOfSkill(skill, experienceSkills).length ===
519
                  0 ? (
520
                    <div
521
                      data-c-background="gray(10)"
522
                      data-c-radius="rounded"
523
                      data-c-border="all(thin, solid, gray)"
524
                      data-c-padding="all(1)"
525
                    >
526
                      <div data-c-align="base(center)">
527
                        <p data-c-color="gray">
528
                          <FormattedMessage
529
                            id="application.skills.noLinkedExperiences"
530
                            defaultMessage="Looks like you don't have any experiences linked to this skill. You can link experiences to skills in the previous step."
531
                            description="Text displayed under a skill section with no experiences."
532
                          />
533
                        </p>
534
                      </div>
535
                    </div>
536
                  ) : (
537
                    <div data-c-accordion-group="">
538
                      {getExperiencesOfSkill(skill, experienceSkills).map(
539
                        (experienceSkill) => {
540
                          const experienceStatus =
541
                            status[experienceSkill.skill_id].experiences[
542
                              `${experienceSkill.experience_type}_${experienceSkill.experience_id}`
543
                            ];
544
                          const relevantExperience = getExperienceOfExperienceSkills(
545
                            experienceSkill,
546
                            experiences,
547
                          );
548
549
                          if (relevantExperience === null) {
550
                            return (
551
                              <div
552
                                data-c-background="gray(10)"
553
                                data-c-radius="rounded"
554
                                data-c-border="all(thin, solid, gray)"
555
                                data-c-padding="all(1)"
556
                              >
557
                                <div data-c-align="base(center)">
558
                                  <p data-c-color="gray">
559
                                    <FormattedMessage
560
                                      id="application.skills.missingExperience"
561
                                      defaultMessage="Looks like something went wrong on our end and your experience can't be displayed. Please try again later."
562
                                      description="Text displayed under a skill section with a missing linked experience."
563
                                    />
564
                                  </p>
565
                                </div>
566
                              </div>
567
                            );
568
                          }
569
570
                          return (
571
                            <ExperienceSkillAccordion
572
                              key={`experience-skill-textarea-${experienceSkill.experience_type}-${experienceSkill.skill_id}-${experienceSkill.experience_id}`}
573
                              experience={relevantExperience}
574
                              experienceSkill={experienceSkill}
575
                              intl={intl}
576
                              status={experienceStatus}
577
                              handleUpdateStatus={dispatchStatus}
578
                              skillName={skillName}
579
                              handleUpdateExperienceJustification={
580
                                handleUpdateExperienceJustification
581
                              }
582
                              handleRemoveExperience={
583
                                handleRemoveExperienceJustification
584
                              }
585
                            />
586
                          );
587
                        },
588
                      )}
589
                    </div>
590
                  )}
591
                </div>
592
              );
593
            })}
594
          </div>
595
        </div>
596
      </div>
597
      <div data-c-dialog-overlay={visible ? "active" : ""} />
598
      <Modal
599
        id={modalId}
600
        parentElement={modalParentRef.current}
601
        visible={visible}
602
        onModalConfirm={(e): void => setVisible(false)}
603
        onModalCancel={(e): void => setVisible(false)}
604
      >
605
        <Modal.Header>
606
          <div
607
            data-c-padding="tb(1)"
608
            data-c-border="bottom(thin, solid, black)"
609
            data-c-background="c1(100)"
610
            className="dialog-header"
611
          >
612
            <div data-c-container="medium">
613
              <h5
614
                data-c-font-size="h3"
615
                data-c-font-weight="bold"
616
                id={`${modalId}-title`}
617
                data-c-dialog-focus=""
618
                data-c-color="white"
619
              >
620
                {modalHeading}
621
              </h5>
622
              <button
623
                data-c-dialog-action="close"
624
                data-c-dialog-id={`${modalId}`}
625
                type="button"
626
                data-c-color="white"
627
                tabIndex={0}
628
                onClick={(e): void => setVisible(false)}
629
              >
630
                <i className="fas fa-times" />
631
              </button>
632
            </div>
633
          </div>
634
        </Modal.Header>
635
        <Modal.Body>
636
          <div data-c-border="bottom(thin, solid, black)">
637
            <div id={`${modalId}-description`}>
638
              <div data-c-container="medium" data-c-padding="tb(1)">
639
                <p>{modalBody}</p>
640
              </div>
641
            </div>
642
          </div>
643
        </Modal.Body>
644
        <Modal.Footer>
645
          <Modal.FooterConfirmBtn>
646
            <FormattedMessage
647
              id="application.skills.modal.confirmButton"
648
              defaultMessage="Okay"
649
            />
650
          </Modal.FooterConfirmBtn>
651
        </Modal.Footer>
652
      </Modal>
653
    </div>
654
  );
655
};
656
657
export default Skills;
658