Passed
Push — feature/micro-ref-email ( b987f2...6f6312 )
by Tristan
04:47
created

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

Complexity

Total Complexity 27
Complexity/F 0

Size

Lines of Code 602
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

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