Passed
Push — feature/application-skill-info... ( 2a7f8b...1281e5 )
by Chris
06:47
created

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

Complexity

Total Complexity 11
Complexity/F 0

Size

Lines of Code 556
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 11
eloc 449
mnd 11
bc 11
fnc 0
dl 0
loc 556
bpm 0
cpm 0
noi 0
c 0
b 0
f 0
rs 10
1
/* eslint camelcase: "off", @typescript-eslint/camelcase: "off" */
2
import React, { useState } from "react";
3
import {
4
  FormattedMessage,
5
  useIntl,
6
  defineMessages,
7
  IntlShape,
8
} from "react-intl";
9
import { Formik, Form, FastField } from "formik";
10
import * as Yup from "yup";
11
import { ExperienceSkill, Skill, Criteria } from "../../../models/types";
12
import { slugify } from "../../../helpers/routes";
13
import { getLocale, localizeFieldNonNull } from "../../../helpers/localize";
14
import { validationMessages } from "../../Form/Messages";
15
import {
16
  getExperienceHeading,
17
  getExperienceSubheading,
18
  getExperienceJustificationLabel,
19
} from "../../../models/localizedConstants";
20
import { getId, hasKey, mapToObject } from "../../../helpers/queries";
21
import { getSkillLevelName } from "../../../models/jobUtil";
22
import AlertWhenUnsaved from "../../Form/AlertWhenUnsaved";
23
import TextAreaInput from "../../Form/TextAreaInput";
24
import WordCounter from "../../WordCounter/WordCounter";
25
import { countNumberOfWords } from "../../WordCounter/helpers";
26
27
const displayMessages = defineMessages({
28
  sidebarLinkTitle: {
29
    id: "application.skills.sidebarLinkTitle",
30
    defaultMessage: "Go to this skill.",
31
    description: "Title attribute for sidebar links.",
32
  },
33
  accessibleAccordionButtonText: {
34
    id: "application.skills.accessibleAccordionButtonText",
35
    defaultMessage: "Click to view...",
36
    description: "Hidden accordion button text for accessibility.",
37
  },
38
  save: {
39
    id: "application.skills.saveButtonText",
40
    defaultMessage: "Save",
41
    description: "Button text for saving an experience skill justification.",
42
  },
43
  saved: {
44
    id: "application.skills.savedButtonText",
45
    defaultMessage: "Saved",
46
    description:
47
      "Button text for after an experience skill justification is saved.",
48
  },
49
  wordCountUnderMax: {
50
    id: "application.skills.wordCountUnderMax",
51
    defaultMessage: " words left.",
52
    description:
53
      "Message displayed next to word counter when user is under the maximum count.",
54
  },
55
  wordCountOverMax: {
56
    id: "application.skills.wordCountOverMax",
57
    defaultMessage: " words over the limit.",
58
    description:
59
      "Message displayed next to word counter when user is over the maximum count.",
60
  },
61
});
62
63
const JUSTIFICATION_WORD_LIMIT = 100;
64
65
enum IconStatus {
66
  COMPLETE = "fas fa-check-circle",
67
  DEFAULT = "far fa-circle",
68
  ERROR = "fas fa-exclamation-circle",
69
}
70
71
interface StatusIconProps {
72
  status: IconStatus;
73
  size: string;
74
}
75
76
const StatusIcon: React.FC<StatusIconProps> = ({
77
  status,
78
  size,
79
}): React.ReactElement => {
80
  let color: string;
81
  switch (status) {
82
    case IconStatus.COMPLETE:
83
      color = "go";
84
      break;
85
    case IconStatus.ERROR:
86
      color = "stop";
87
      break;
88
    default:
89
      color = "c1";
90
  }
91
92
  return <i className={status} data-c-color={color} data-c-font-size={size} />;
93
};
94
95
interface SidebarProps {
96
  menuSkills: string[];
97
  intl: IntlShape;
98
  status: {
99
    [k: string]: IconStatus;
100
  };
101
}
102
103
const Sidebar: React.FC<SidebarProps> = ({ menuSkills, intl, status }) => {
104
  return (
105
    <div data-c-padding="top(3)" className="application-skill-navigation">
106
      <p
107
        data-c-font-size="h3"
108
        data-c-font-weight="bold"
109
        data-c-margin="bottom(1)"
110
      >
111
        <FormattedMessage
112
          id="application.skills.sidebarHeading"
113
          defaultMessage="On this page:"
114
          description="Heading for the sidebar on the Skills page."
115
        />
116
      </p>
117
      <ul>
118
        {menuSkills.map((skillName: string) => (
119
          <li key={slugify(skillName)}>
120
            <StatusIcon status={status[slugify(skillName)]} size="" />
121
            <a
122
              href={`#${slugify(skillName)}`}
123
              title={intl.formatMessage(displayMessages.sidebarLinkTitle)}
124
            >
125
              {skillName}
126
            </a>
127
          </li>
128
        ))}
129
      </ul>
130
    </div>
131
  );
132
};
133
134
interface ExperienceAccordionProps {
135
  experienceSkill: ExperienceSkill;
136
  intl: IntlShape;
137
  status: {
138
    [k: string]: IconStatus;
139
  };
140
  skillName: string;
141
  handleUpdateExperienceJustification: (
142
    experience: ExperienceSkill,
143
  ) => Promise<ExperienceSkill>;
144
  handleUpdateStatus: React.Dispatch<
145
    React.SetStateAction<{
146
      [k: string]: IconStatus;
147
    }>
148
  >;
149
}
150
151
interface ExperienceSkillFormValues {
152
  justification: string;
153
}
154
155
const ExperienceAccordion: React.FC<ExperienceAccordionProps> = ({
156
  experienceSkill,
157
  intl,
158
  status,
159
  skillName,
160
  handleUpdateExperienceJustification,
161
  handleUpdateStatus,
162
}) => {
163
  const [isExpanded, setIsExpanded] = useState(false);
164
  let heading = "";
165
  let subHeading = "";
166
  let label = "";
167
168
  const initialValues: ExperienceSkillFormValues = {
169
    justification: experienceSkill.justification || "",
170
  };
171
172
  const experienceSkillSchema = Yup.object().shape({
173
    justification: Yup.string()
174
      .test(
175
        "wordCount",
176
        intl.formatMessage(validationMessages.overMaxWords, {
177
          numberOfWords: JUSTIFICATION_WORD_LIMIT,
178
        }),
179
        (value) => countNumberOfWords(value) <= JUSTIFICATION_WORD_LIMIT,
180
      )
181
      .required(intl.formatMessage(validationMessages.required)),
182
  });
183
184
  if (experienceSkill.experience !== null) {
185
    heading = getExperienceHeading(experienceSkill.experience, intl);
186
    subHeading = getExperienceSubheading(experienceSkill.experience, intl);
187
    label = getExperienceJustificationLabel(
188
      experienceSkill.experience,
189
      intl,
190
      skillName,
191
    );
192
  }
193
194
  const handleExpandClick = (): void => {
195
    setIsExpanded(!isExpanded);
196
  };
197
198
  const updateExperienceSkill = (
199
    oldExperience: ExperienceSkill,
200
    values: ExperienceSkillFormValues,
201
  ): ExperienceSkill => {
202
    const experienceJustification: ExperienceSkill = {
203
      ...oldExperience,
204
      justification: values.justification || "",
205
    };
206
    return experienceJustification;
207
  };
208
209
  return (
210
    <Formik
211
      initialValues={initialValues}
212
      validationSchema={experienceSkillSchema}
213
      onSubmit={(values, { setSubmitting, resetForm }): void => {
214
        const experienceJustification = updateExperienceSkill(
215
          experienceSkill,
216
          values,
217
        );
218
        handleUpdateExperienceJustification(experienceJustification)
219
          .then(() => {
220
            handleUpdateStatus({
221
              ...status,
222
              [slugify(skillName)]: IconStatus.COMPLETE,
223
            });
224
            setSubmitting(false);
225
            resetForm();
226
          })
227
          .catch(() => {
228
            setSubmitting(false);
229
          });
230
      }}
231
    >
232
      {({ dirty, isSubmitting }): React.ReactElement => (
233
        <div
234
          data-c-accordion=""
235
          data-c-background="white(100)"
236
          data-c-card=""
237
          data-c-margin="bottom(.5)"
238
          className={`application-skill-explanation${
239
            isExpanded ? " active" : ""
240
          }`}
241
        >
242
          <button
243
            aria-expanded={isExpanded ? "true" : "false"}
244
            data-c-accordion-trigger=""
245
            tabIndex={0}
246
            type="button"
247
            onClick={handleExpandClick}
248
          >
249
            <div data-c-grid="">
250
              <div data-c-grid-item="base(1of4) tl(1of6) equal-col">
251
                <div className="skill-status-indicator">
252
                  <StatusIcon status={status[slugify(skillName)]} size="h4" />
253
                </div>
254
              </div>
255
              <div data-c-grid-item="base(3of4) tl(5of6)">
256
                <div data-c-padding="all(1)">
257
                  <div data-c-grid="middle">
258
                    <div data-c-grid-item="tl(3of4)">
259
                      <p>{heading}</p>
260
                      <p
261
                        data-c-margin="top(quarter)"
262
                        data-c-colour="c1"
263
                        data-c-font-size="small"
264
                      >
265
                        {subHeading}
266
                      </p>
267
                    </div>
268
                    <div
269
                      data-c-grid-item="tl(1of4)"
270
                      data-c-align="base(left) tl(center)"
271
                    >
272
                      {experienceSkill.justification.length === 0 && (
273
                        <span data-c-color="stop" className="missing-info">
274
                          <FormattedMessage
275
                            id="application.skills.justificationMissing"
276
                            defaultMessage="Missing Information"
277
                            description="Accordion heading error that displays when the justification is empty."
278
                          />
279
                        </span>
280
                      )}
281
                    </div>
282
                  </div>
283
                </div>
284
              </div>
285
            </div>
286
            <span data-c-visibility="invisible">
287
              {intl.formatMessage(
288
                displayMessages.accessibleAccordionButtonText,
289
              )}
290
            </span>
291
            {isExpanded ? (
292
              <i
293
                aria-hidden="true"
294
                className="fas fa-angle-up"
295
                data-c-colour="black"
296
                data-c-accordion-remove=""
297
              />
298
            ) : (
299
              <i
300
                aria-hidden="true"
301
                className="fas fa-angle-down"
302
                data-c-colour="black"
303
                data-c-accordion-add=""
304
              />
305
            )}
306
          </button>
307
          {isExpanded && (
308
            <div
309
              aria-hidden={isExpanded ? "false" : "true"}
310
              data-c-accordion-content=""
311
              data-c-background="gray(10)"
312
            >
313
              <Form>
314
                <AlertWhenUnsaved />
315
                <hr data-c-hr="thin(gray)" data-c-margin="bottom(1)" />
316
                <div data-c-padding="lr(1)">
317
                  <FastField
318
                    id={`experience-skill-textarea-${experienceSkill.experience_type}-${experienceSkill.skill_id}-${experienceSkill.experience_id}`}
319
                    name="justification"
320
                    label={label}
321
                    component={TextAreaInput}
322
                    placeholder="Start writing here..."
323
                    required
324
                  />
325
                </div>
326
                <div data-c-padding="all(1)">
327
                  <div data-c-grid="gutter(all, 1) middle">
328
                    <div
329
                      data-c-grid-item="tp(1of2)"
330
                      data-c-align="base(center) tp(left)"
331
                    >
332
                      <button
333
                        data-c-button="outline(c1)"
334
                        data-c-radius="rounded"
335
                        data-c-dialog-id="confirm-deletion"
336
                        type="button"
337
                        data-c-dialog-action="open"
338
                      >
339
                        <span>
340
                          <FormattedMessage
341
                            id="application.skills.deleteExperienceButtonText"
342
                            defaultMessage="Remove Experience From Skill"
343
                            description="Text for the delete experience button."
344
                          />
345
                        </span>
346
                      </button>
347
                    </div>
348
                    <div
349
                      data-c-grid-item="tp(1of2)"
350
                      data-c-align="base(center) tp(right)"
351
                    >
352
                      <WordCounter
353
                        elementId={`experience-skill-textarea-${experienceSkill.experience_type}-${experienceSkill.skill_id}-${experienceSkill.experience_id}`}
354
                        maxWords={JUSTIFICATION_WORD_LIMIT}
355
                        minWords={0}
356
                        absoluteValue
357
                        dataAttributes={{ "data-c-margin": "right(1)" }}
358
                        underMaxMessage={intl.formatMessage(
359
                          displayMessages.wordCountUnderMax,
360
                        )}
361
                        overMaxMessage={intl.formatMessage(
362
                          displayMessages.wordCountOverMax,
363
                        )}
364
                      />
365
                      <button
366
                        data-c-button="solid(c1)"
367
                        data-c-radius="rounded"
368
                        type="submit"
369
                        disabled={!dirty || isSubmitting}
370
                      >
371
                        <span>
372
                          {dirty
373
                            ? intl.formatMessage(displayMessages.save)
374
                            : intl.formatMessage(displayMessages.saved)}
375
                        </span>
376
                      </button>
377
                    </div>
378
                  </div>
379
                </div>
380
              </Form>
381
            </div>
382
          )}
383
        </div>
384
      )}
385
    </Formik>
386
  );
387
};
388
389
interface SkillsProps {
390
  criteria: Criteria[];
391
  experiences: ExperienceSkill[];
392
  skills: Skill[];
393
  handleUpdateExperienceJustification: (
394
    experience: ExperienceSkill,
395
  ) => Promise<ExperienceSkill>;
396
}
397
398
const Skills: React.FC<SkillsProps> = ({
399
  criteria,
400
  experiences,
401
  skills,
402
  handleUpdateExperienceJustification,
403
}) => {
404
  const intl = useIntl();
405
  const locale = getLocale(intl.locale);
406
407
  const skillsById = mapToObject(skills, getId);
408
  const getSkillOfCriteria = (criterion: Criteria): Skill | null => {
409
    return hasKey(skillsById, criterion.skill_id)
410
      ? skillsById[criterion.skill_id]
411
      : null;
412
  };
413
414
  const getExperiencesOfSkill = (skill: Skill): ExperienceSkill[] =>
415
    experiences.filter((experience) => experience.skill_id === skill.id);
416
417
  const menuSkills = criteria.flatMap((criterion: Criteria) => {
418
    const skill = getSkillOfCriteria(criterion);
419
    if (skill) {
420
      return [localizeFieldNonNull(locale, skill, "name")];
421
    }
422
    return [];
423
  });
424
425
  const [experienceSkillStatus, setExperienceSkillStatus] = useState(
426
    Object.fromEntries(
427
      menuSkills.map((menuSkill) => [slugify(menuSkill), IconStatus.DEFAULT]),
428
    ),
429
  );
430
431
  return (
432
    <div data-c-container="large">
433
      <div data-c-grid="gutter(all, 1)">
434
        <div data-c-grid-item="tl(1of4)">
435
          <Sidebar
436
            menuSkills={menuSkills}
437
            intl={intl}
438
            status={experienceSkillStatus}
439
          />
440
        </div>
441
        <div data-c-grid-item="tl(3of4)">
442
          <h2 data-c-heading="h2" data-c-margin="top(3) bottom(1)">
443
            <FormattedMessage
444
              id="application.skills.heading"
445
              defaultMessage="How You Used Each Skill"
446
              description="Heading text on the Skills step."
447
            />
448
          </h2>
449
          <p data-c-margin="bottom(1)">
450
            <span data-c-font-weight="bold">
451
              This is the most important part of your application.
452
            </span>{" "}
453
            Each box only needs a couple of sentences, but make them good ones!
454
          </p>
455
          <p data-c-margin="bottom(.5)">
456
            Try answering one or two of the following questions:
457
          </p>
458
          <ul data-c-margin="bottom(1)">
459
            <li>
460
              What did you accomplish, create, or deliver using this skill?
461
            </li>
462
            <li>
463
              What tasks or activities did you do that relate to this skill?
464
            </li>
465
            <li>
466
              Were there any special techniques or approaches that you used?
467
            </li>
468
            <li>How much responsibility did you have in this role?</li>
469
          </ul>
470
          <p>
471
            If a skill is only loosely connected to an experience, consider
472
            removing it. This can help the manager focus on your best examples.
473
          </p>
474
          <div className="skills-list">
475
            {criteria.map((criterion) => {
476
              const skill = getSkillOfCriteria(criterion);
477
              if (skill === null) {
478
                return null;
479
              }
480
              const skillName = localizeFieldNonNull(locale, skill, "name");
481
              const skillHtmlId = slugify(skillName);
482
483
              return (
484
                <>
485
                  <h3
486
                    className="application-skill-title"
487
                    data-c-heading="h3"
488
                    data-c-padding="top(3) bottom(1)"
489
                    data-c-margin="bottom(1)"
490
                    id={skillHtmlId}
491
                  >
492
                    <button
493
                      data-c-font-size="h3"
494
                      data-c-dialog-id="skill-description"
495
                      type="button"
496
                      data-c-dialog-action="open"
497
                    >
498
                      {skillName}
499
                    </button>
500
                    <br />
501
                    <button
502
                      data-c-font-size="normal"
503
                      data-c-font-weight="bold"
504
                      data-c-dialog-id="level-description"
505
                      type="button"
506
                      data-c-dialog-action="open"
507
                    >
508
                      {intl.formatMessage(getSkillLevelName(criterion, skill))}
509
                    </button>
510
                  </h3>
511
                  {getExperiencesOfSkill(skill).length === 0 ? (
512
                    <div
513
                      data-c-background="gray(10)"
514
                      data-c-radius="rounded"
515
                      data-c-border="all(thin, solid, gray)"
516
                      data-c-padding="all(1)"
517
                    >
518
                      <div data-c-align="base(center)">
519
                        <p data-c-color="gray">
520
                          <FormattedMessage
521
                            id="application.skills.noLinkedExperiences"
522
                            defaultMessage="Looks like you don't have any experiences linked to this skill. You can link experiences to skills in the previous step."
523
                            description="Text displayed under a skill section with no experiences."
524
                          />
525
                        </p>
526
                      </div>
527
                    </div>
528
                  ) : (
529
                    <div data-c-accordion-group="">
530
                      {getExperiencesOfSkill(skill).map((experienceSkill) => (
531
                        <ExperienceAccordion
532
                          key={`experience-skill-textarea-${experienceSkill.experience_type}-${experienceSkill.skill_id}-${experienceSkill.experience_id}`}
533
                          experienceSkill={experienceSkill}
534
                          intl={intl}
535
                          status={experienceSkillStatus}
536
                          handleUpdateStatus={setExperienceSkillStatus}
537
                          skillName={skillName}
538
                          handleUpdateExperienceJustification={
539
                            handleUpdateExperienceJustification
540
                          }
541
                        />
542
                      ))}
543
                    </div>
544
                  )}
545
                </>
546
              );
547
            })}
548
          </div>
549
        </div>
550
      </div>
551
    </div>
552
  );
553
};
554
555
export default Skills;
556