Passed
Push — dev ( 2cea8b...7299be )
by Tristan
04:19
created

resources/assets/js/components/StrategicTalentResponse/ResponseScreening/ApplicantBucket.tsx   A

Complexity

Total Complexity 31
Complexity/F 0

Size

Lines of Code 678
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 31
eloc 536
dl 0
loc 678
rs 9.92
c 0
b 0
f 0
mnd 31
bc 31
fnc 0
bpm 0
cpm 0
noi 0
1
/* eslint camelcase: "off", @typescript-eslint/camelcase: "off" */
2
import React, { useState, useEffect } from "react";
3
import { defineMessages, useIntl } from "react-intl";
4
import { FastField, Formik, Form, useFormikContext } from "formik";
5
import Swal from "sweetalert2";
6
import SelectInput from "../../Form/SelectInput";
7
import {
8
  Application,
9
  Department,
10
  ApplicationReview,
11
} from "../../../models/types";
12
import { Portal, ValuesOf } from "../../../models/app";
13
import * as routes from "../../../helpers/routes";
14
import {
15
  ResponseScreeningBuckets,
16
  ResponseReviewStatuses,
17
} from "../../../models/localizedConstants";
18
import {
19
  ResponseScreeningBuckets as ResponseBuckets,
20
  ResponseReviewStatusId,
21
  ReviewStatusId,
22
} from "../../../models/lookupConstants";
23
import {
24
  localizeFieldNonNull,
25
  getLocale,
26
  Locales,
27
} from "../../../helpers/localize";
28
29
const displayMessages = defineMessages({
30
  viewApplication: {
31
    id: "responseScreening.bucket.viewApplicationLabel",
32
    defaultMessage: "View Application",
33
    description: "Label for 'View Application' link.",
34
  },
35
  viewProfile: {
36
    id: "responseScreening.bucket.viewProfileLabel",
37
    defaultMessage: "View Profile",
38
    description: "Label for 'View Profile' link.",
39
  },
40
  notes: {
41
    id: "responseScreening.bucket.notesLabel",
42
    defaultMessage: "Add/Edit Notes",
43
    description: "Label for 'Add/Edit Notes' button.",
44
  },
45
  save: {
46
    id: "responseScreening.bucket.saveLabel",
47
    defaultMessage: "Save Changes",
48
    description: "Label for 'Save Changes' button when changes are unsaved.",
49
  },
50
  saving: {
51
    id: "responseScreening.bucket.savingLabel",
52
    defaultMessage: "Saving...",
53
    description:
54
      "Label for 'Save Changes' button when form submission is in progress.",
55
  },
56
  saved: {
57
    id: "responseScreening.bucket.savedLabel",
58
    defaultMessage: "Saved",
59
    description:
60
      "Label for 'Save Changes' button when form submission succeeded.",
61
  },
62
  cancel: {
63
    id: "responseScreening.bucket.cancelLabel",
64
    defaultMessage: "Cancel",
65
    description: "Label for 'Cancel' button.",
66
  },
67
  selectStatusDefault: {
68
    id: "responseScreening.bucket.selectStatusDefault",
69
    defaultMessage: "Select a status...",
70
    description: "Default option text for the Status dropdown.",
71
  },
72
  selectStatusLabel: {
73
    id: "responseScreening.bucket.selectStatusLabel",
74
    defaultMessage: "Review Status",
75
    description: "Label for the Status dropdown.",
76
  },
77
  selectDepartmentDefault: {
78
    id: "responseScreening.bucket.selectDepartmentDefault",
79
    defaultMessage: "Select a department...",
80
    description: "Default option text for the Department dropdown.",
81
  },
82
  selectDepartmentLabel: {
83
    id: "responseScreening.bucket.selectDepartmentLabel",
84
    defaultMessage: "Department Allocation",
85
    description: "Label for the Department dropdown.",
86
  },
87
  clickView: {
88
    id: "responseScreening.bucket.accessibleViewLabel",
89
    defaultMessage: "Click to view...",
90
    description: "Accessible text for screen reading accordion elements.",
91
  },
92
  noApplicants: {
93
    id: "responseScreening.bucket.noApplicants",
94
    defaultMessage: "There are currently no applicants in this bucket.",
95
    description: "Fallback label for a bucket with no applicants.",
96
  },
97
  setAllocated: {
98
    id: "responseScreening.bucket.confirmSetAllocated",
99
    defaultMessage:
100
      "Setting this candidate to allocated will mark them as currently unavailable for all other streams. Are you sure you want to continue?",
101
    description:
102
      "Confirmation text when attempting to set a candidate as Allocated.",
103
  },
104
  setUnavailable: {
105
    id: "responseScreening.bucket.confirmSetUnavailable",
106
    defaultMessage:
107
      "Setting this candidate to not available will mark them as currently unavailable for all other streams. Are you sure you want to continue?",
108
    description:
109
      "Confirmation text when attempting to set a candidate as Not Available.",
110
  },
111
  confirmAction: {
112
    id: "responseScreening.bucket.confirmAction",
113
    defaultMessage: "Please confirm this action",
114
    description:
115
      "Title for the confirmation dialog when making changes with side effects.",
116
  },
117
  yes: {
118
    id: "responseScreening.bucket.yes",
119
    defaultMessage: "Yes",
120
    description: "Confirmation button text for dialog",
121
  },
122
});
123
124
enum IconStatus {
125
  ASSESSMENT = "question",
126
  READY = "check",
127
  RECEIVED = "exclamation",
128
}
129
130
interface StatusIconProps {
131
  status: IconStatus;
132
  color: string;
133
  small: boolean;
134
}
135
136
const StatusIcon: React.FC<StatusIconProps> = ({
137
  status,
138
  color,
139
  small,
140
}): React.ReactElement => {
141
  return (
142
    <i
143
      className={`fas fa-${status}-circle`}
144
      data-c-color={color}
145
      data-c-font-size={small ? "small" : ""}
146
    />
147
  );
148
};
149
150
// Kinda weird "empty" component that hooks into Formik's
151
// context, listens to the 'dirty' prop, and registers
152
// a beforeunload listener to fire if a user attempts to
153
// leave with unsaved work.
154
// https://github.com/jaredpalmer/formik/issues/1657#issuecomment-509388871
155
const AlertWhenUnsaved = (): React.ReactElement => {
156
  const { dirty } = useFormikContext();
157
  const handleUnload = (event: BeforeUnloadEvent): void => {
158
    event.preventDefault();
159
    event.returnValue = "Are you sure you want to leave with unsaved changes?";
160
  };
161
162
  useEffect(() => {
163
    if (dirty) {
164
      window.addEventListener("beforeunload", handleUnload);
165
    }
166
    return (): void => {
167
      window.removeEventListener("beforeunload", handleUnload);
168
    };
169
  }, [dirty]);
170
171
  return <></>;
172
};
173
174
interface FormValues {
175
  reviewStatus: ReviewStatusId | ResponseReviewStatusId | null;
176
  department: number | null;
177
  notes: string;
178
}
179
180
interface ApplicationRowProps {
181
  application: Application;
182
  departmentEditable: boolean;
183
  departments: Department[];
184
  handleUpdateReview: (review: ApplicationReview) => Promise<ApplicationReview>;
185
  portal: Portal;
186
}
187
188
const ApplicationRow: React.FC<ApplicationRowProps> = ({
189
  application,
190
  departmentEditable,
191
  departments,
192
  handleUpdateReview,
193
  portal,
194
}): React.ReactElement => {
195
  const intl = useIntl();
196
  const locale = getLocale(intl.locale);
197
198
  const applicantUrlMap: { [key in typeof portal]: string } = {
199
    hr: routes.hrApplicantShow(intl.locale, application.id),
200
    manager: routes.managerApplicantShow(intl.locale, application.id),
201
  };
202
  const applicationUrlMap: { [key in typeof portal]: string } = {
203
    hr: routes.hrApplicationShow(intl.locale, application.id),
204
    manager: routes.managerApplicationShow(intl.locale, application.id),
205
  };
206
  const applicantUrl = applicantUrlMap[portal];
207
  const applicationUrl = applicationUrlMap[portal];
208
209
  const reviewOptions = Object.values(ResponseReviewStatuses).map((status): {
210
    value: number;
211
    label: string;
212
  } => ({
213
    value: status.id,
214
    label: intl.formatMessage(status.name),
215
  }));
216
217
  const departmentOptions = departments.map((department) => ({
218
    value: department.id,
219
    label: localizeFieldNonNull(locale, department, "name"),
220
  }));
221
222
  let rowIcon: React.ReactElement;
223
224
  switch (application.application_review?.review_status_id) {
225
    case 4:
226
    case 5:
227
    case 7:
228
      rowIcon = (
229
        <StatusIcon status={IconStatus.READY} color="go" small={false} />
230
      );
231
      break;
232
    case 6:
233
      rowIcon = (
234
        <StatusIcon status={IconStatus.ASSESSMENT} color="slow" small={false} />
235
      );
236
      break;
237
    default:
238
      rowIcon = (
239
        <StatusIcon status={IconStatus.RECEIVED} color="c1" small={false} />
240
      );
241
  }
242
243
  const emptyReview: ApplicationReview = {
244
    id: 0,
245
    job_application_id: application.id,
246
    review_status_id: null,
247
    department_id: null,
248
    notes: null,
249
    created_at: new Date(),
250
    updated_at: new Date(),
251
    department: null,
252
    review_status: null,
253
  };
254
255
  const initialValues: FormValues = {
256
    reviewStatus: application.application_review?.review_status_id || null,
257
    department: application.application_review?.department_id || null,
258
    notes: application.application_review?.notes || "",
259
  };
260
261
  const updateApplicationReview = (
262
    oldReview: ApplicationReview,
263
    values: FormValues,
264
  ): ApplicationReview => {
265
    const applicationReview: ApplicationReview = {
266
      ...oldReview,
267
      review_status_id: values.reviewStatus
268
        ? Number(values.reviewStatus)
269
        : null,
270
      department_id: values.department ? Number(values.department) : null,
271
      notes: values.notes || null,
272
    };
273
    return applicationReview;
274
  };
275
276
  const handleNotesButtonClick = (
277
    notes: string,
278
    updateField: (
279
      field: string,
280
      value: any,
281
      shouldValidate?: boolean | undefined,
282
    ) => void,
283
  ): void => {
284
    Swal.fire({
285
      title: intl.formatMessage(displayMessages.notes),
286
      icon: "info",
287
      input: "textarea",
288
      showCancelButton: true,
289
      confirmButtonColor: "#0A6CBC",
290
      cancelButtonColor: "#F94D4D",
291
      cancelButtonText: intl.formatMessage(displayMessages.cancel),
292
      confirmButtonText: intl.formatMessage(displayMessages.save),
293
      inputValue: notes,
294
    }).then((result) => {
295
      if (result && result.value !== undefined) {
296
        const value = result.value ? result.value : "";
297
        updateField("notes", value);
298
      }
299
    });
300
  };
301
302
  return (
303
    <div className="applicant">
304
      <Formik
305
        initialValues={initialValues}
306
        onSubmit={(values, { setSubmitting, resetForm }): void => {
307
          const review = updateApplicationReview(
308
            application.application_review || emptyReview,
309
            values,
310
          );
311
          const performUpdate = (): void => {
312
            handleUpdateReview(review)
313
              .then(() => {
314
                setSubmitting(false);
315
                resetForm();
316
              })
317
              .catch(() => {
318
                setSubmitting(false);
319
              });
320
          };
321
          // Changing an application's status to Allocated
322
          if (
323
            Number(values.reviewStatus) ===
324
              Number(ResponseReviewStatusId.Allocated) &&
325
            values.reviewStatus !== initialValues.reviewStatus
326
          ) {
327
            Swal.fire({
328
              title: intl.formatMessage(displayMessages.confirmAction),
329
              text: intl.formatMessage(displayMessages.setAllocated),
330
              icon: "warning",
331
              showCancelButton: true,
332
              confirmButtonColor: "#0A6CBC",
333
              cancelButtonColor: "#F94D4D",
334
              cancelButtonText: intl.formatMessage(displayMessages.cancel),
335
              confirmButtonText: intl.formatMessage(displayMessages.yes),
336
            }).then((result) => {
337
              if (result.value === undefined) {
338
                setSubmitting(false);
339
              } else {
340
                performUpdate();
341
              }
342
            });
343
            // Changing an Application's status to Unavailable
344
          } else if (
345
            Number(values.reviewStatus) ===
346
              Number(ResponseReviewStatusId.NotAvailable) &&
347
            values.reviewStatus !== initialValues.reviewStatus
348
          ) {
349
            Swal.fire({
350
              title: intl.formatMessage(displayMessages.confirmAction),
351
              text: intl.formatMessage(displayMessages.setUnavailable),
352
              icon: "warning",
353
              showCancelButton: true,
354
              confirmButtonColor: "#0A6CBC",
355
              cancelButtonColor: "#F94D4D",
356
              cancelButtonText: intl.formatMessage(displayMessages.cancel),
357
              confirmButtonText: intl.formatMessage(displayMessages.yes),
358
            }).then((result) => {
359
              if (result.value === undefined) {
360
                setSubmitting(false);
361
              } else {
362
                performUpdate();
363
              }
364
            });
365
            // Everything else
366
          } else {
367
            performUpdate();
368
          }
369
        }}
370
      >
371
        {({
372
          values,
373
          dirty,
374
          isSubmitting,
375
          setFieldValue,
376
        }): React.ReactElement => (
377
          <Form data-c-grid="gutter(all, 1) middle">
378
            <AlertWhenUnsaved />
379
            <div data-c-grid-item="base(1of4)">
380
              <div>
381
                {rowIcon}
382
                <div>
383
                  <p data-c-font-weight="bold" data-c-font-size="h4">
384
                    {application.applicant.user.full_name}
385
                  </p>
386
                  <p data-c-margin="bottom(.5)">
387
                    <a
388
                      href={`mailto:${application.applicant.user.email}`}
389
                      title=""
390
                    >
391
                      {application.applicant.user.email}
392
                    </a>
393
                  </p>
394
                  <p data-c-font-size="small">
395
                    <a href={applicationUrl} title="" data-c-margin="right(.5)">
396
                      {intl.formatMessage(displayMessages.viewApplication)}
397
                    </a>
398
                    <a href={applicantUrl} title="">
399
                      {intl.formatMessage(displayMessages.viewProfile)}
400
                    </a>
401
                  </p>
402
                </div>
403
              </div>
404
            </div>
405
            <FastField
406
              id={`review-status-select-${application.applicant_id}`}
407
              name="reviewStatus"
408
              label={intl.formatMessage(displayMessages.selectStatusLabel)}
409
              grid="base(1of4)"
410
              component={SelectInput}
411
              nullSelection={intl.formatMessage(
412
                displayMessages.selectStatusDefault,
413
              )}
414
              options={reviewOptions}
415
            />
416
            {departmentEditable && (
417
              <FastField
418
                id={`department-allocation-select-${application.applicant_id}`}
419
                name="department"
420
                label={intl.formatMessage(
421
                  displayMessages.selectDepartmentLabel,
422
                )}
423
                grid="base(1of4)"
424
                component={SelectInput}
425
                nullSelection={intl.formatMessage(
426
                  displayMessages.selectDepartmentDefault,
427
                )}
428
                options={departmentOptions}
429
              />
430
            )}
431
            <div
432
              data-c-grid-item={`base(${departmentEditable ? 1 : 2}of4)`}
433
              data-c-align="base(right)"
434
            >
435
              <button
436
                data-c-button="outline(c1)"
437
                type="button"
438
                data-c-radius="rounded"
439
                onClick={(): void =>
440
                  handleNotesButtonClick(values.notes, setFieldValue)
441
                }
442
              >
443
                <i className="fas fa-plus" />
444
                <span>{intl.formatMessage(displayMessages.notes)}</span>
445
              </button>
446
              <button
447
                data-c-button="solid(c1)"
448
                type="submit"
449
                data-c-radius="rounded"
450
                disabled={isSubmitting}
451
              >
452
                <span>
453
                  {dirty
454
                    ? intl.formatMessage(displayMessages.save)
455
                    : intl.formatMessage(displayMessages.saved)}
456
                </span>
457
              </button>
458
            </div>
459
          </Form>
460
        )}
461
      </Formik>
462
    </div>
463
  );
464
};
465
466
const applicationSort = (locale: Locales) => {
467
  return (first: Application, second: Application): number => {
468
    // Applications without a review status should appear first
469
    if (
470
      first.application_review === undefined &&
471
      second.application_review !== undefined
472
    ) {
473
      return -1;
474
    }
475
    if (
476
      first.application_review !== undefined &&
477
      second.application_review === undefined
478
    ) {
479
      return 1;
480
    }
481
    // Applications with a review status should be grouped by status
482
    if (first.application_review && second.application_review) {
483
      if (
484
        first.application_review.review_status_id &&
485
        second.application_review.review_status_id
486
      ) {
487
        if (
488
          first.application_review.review_status_id <
489
          second.application_review.review_status_id
490
        ) {
491
          return -1;
492
        }
493
        if (
494
          first.application_review.review_status_id >
495
          second.application_review.review_status_id
496
        ) {
497
          return 1;
498
        }
499
      }
500
      // Applications without a Department should appear first
501
      if (
502
        first.application_review.department === null &&
503
        second.application_review.department !== null
504
      ) {
505
        return -1;
506
      }
507
      if (
508
        first.application_review.department !== null &&
509
        second.application_review.department === null
510
      ) {
511
        return 1;
512
      }
513
      // Applications with a Department set should be grouped by Department
514
      if (
515
        first.application_review.department &&
516
        second.application_review.department
517
      ) {
518
        const firstDepartmentName = localizeFieldNonNull(
519
          locale,
520
          first.application_review.department,
521
          "name",
522
        ).toUpperCase();
523
        const secondDepartmentName = localizeFieldNonNull(
524
          locale,
525
          second.application_review.department,
526
          "name",
527
        ).toUpperCase();
528
        if (firstDepartmentName < secondDepartmentName) {
529
          return -1;
530
        }
531
        if (firstDepartmentName > secondDepartmentName) {
532
          return 1;
533
        }
534
        return 0;
535
      }
536
    }
537
    return 0;
538
  };
539
};
540
541
interface ApplicantBucketProps {
542
  applications: Application[];
543
  bucket: string;
544
  departments: Department[];
545
  handleUpdateReview: (review: ApplicationReview) => Promise<ApplicationReview>;
546
  portal: Portal;
547
}
548
549
const ApplicantBucket: React.FC<ApplicantBucketProps> = ({
550
  applications,
551
  bucket,
552
  departments,
553
  handleUpdateReview,
554
  portal,
555
}): React.ReactElement => {
556
  const intl = useIntl();
557
  const locale = getLocale(intl.locale);
558
559
  const [isExpanded, setIsExpanded] = useState(false);
560
  const {
561
    title: bucketTitle,
562
    description: bucketDescription,
563
  }: ValuesOf<typeof ResponseScreeningBuckets> = ResponseScreeningBuckets[
564
    bucket
565
  ];
566
567
  const handleExpandClick = (): void => {
568
    setIsExpanded(!isExpanded);
569
  };
570
571
  return (
572
    <div
573
      data-c-accordion=""
574
      data-c-background="white(100)"
575
      data-c-margin="top(.5)"
576
      data-c-card=""
577
      className={isExpanded ? "active" : ""}
578
    >
579
      <button
580
        aria-expanded={isExpanded}
581
        data-c-accordion-trigger
582
        tabIndex={0}
583
        type="button"
584
        onClick={handleExpandClick}
585
      >
586
        <div data-c-padding="top(normal) right bottom(normal) left(normal)">
587
          <p data-c-font-weight="bold" data-c-font-size="h3">
588
            {intl.formatMessage(bucketTitle)} ({applications.length})
589
          </p>
590
          <p data-c-margin="top(quarter)" data-c-colour="gray">
591
            {bucket === ResponseBuckets.Consideration
592
              ? intl.formatMessage(
593
                  ResponseScreeningBuckets.consideration.description,
594
                  {
595
                    iconAssessment: (
596
                      <StatusIcon
597
                        status={IconStatus.ASSESSMENT}
598
                        color="slow"
599
                        small
600
                      />
601
                    ),
602
                    iconReady: (
603
                      <StatusIcon status={IconStatus.READY} color="go" small />
604
                    ),
605
                    iconReceived: (
606
                      <StatusIcon
607
                        status={IconStatus.RECEIVED}
608
                        color="c1"
609
                        small
610
                      />
611
                    ),
612
                  },
613
                )
614
              : intl.formatMessage(bucketDescription)}
615
          </p>
616
        </div>
617
        <span data-c-visibility="invisible">
618
          {intl.formatMessage(displayMessages.clickView)}
619
        </span>
620
        {isExpanded ? (
621
          <i
622
            aria-hidden="true"
623
            className="fas fa-minus"
624
            data-c-accordion-remove=""
625
            data-c-colour="black"
626
          />
627
        ) : (
628
          <i
629
            aria-hidden="true"
630
            className="fas fa-plus"
631
            data-c-accordion-add=""
632
            data-c-colour="black"
633
          />
634
        )}
635
      </button>
636
      <div
637
        aria-hidden={!isExpanded}
638
        data-c-accordion-content=""
639
        data-c-padding="right(normal) left(normal)"
640
      >
641
        <div data-c-padding="bottom(normal)">
642
          {isExpanded &&
643
            applications.length > 0 &&
644
            applications
645
              .sort(applicationSort(locale))
646
              .map((application) => (
647
                <ApplicationRow
648
                  key={application.id}
649
                  application={application}
650
                  departmentEditable={bucket === ResponseBuckets.Allocated}
651
                  departments={departments}
652
                  handleUpdateReview={handleUpdateReview}
653
                  portal={portal}
654
                />
655
              ))}
656
          {isExpanded && applications.length === 0 && (
657
            <div data-c-padding="bottom(normal)">
658
              <div
659
                data-c-border="all(thin, solid, gray)"
660
                data-c-background="gray(10)"
661
                data-c-padding="all(1)"
662
                data-c-radius="rounded"
663
                data-c-align="base(center)"
664
              >
665
                <p data-c-color="gray">
666
                  {intl.formatMessage(displayMessages.noApplicants)}
667
                </p>
668
              </div>
669
            </div>
670
          )}
671
        </div>
672
      </div>
673
    </div>
674
  );
675
};
676
677
export default ApplicantBucket;
678