Passed
Push — dev ( 172029...63b549 )
by
unknown
11:43 queued 06:06
created

resources/assets/js/components/JobBuilder/Tasks/JobTasks.tsx   A

Complexity

Total Complexity 11
Complexity/F 0

Size

Lines of Code 636
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 11
eloc 496
mnd 11
bc 11
fnc 0
dl 0
loc 636
rs 10
bpm 0
cpm 0
noi 0
c 0
b 0
f 0
1
/* eslint-disable jsx-a11y/label-has-associated-control, @typescript-eslint/no-non-null-assertion, react/no-array-index-key, camelcase, jsx-a11y/no-noninteractive-tabindex */
2
import React, { useState, useRef } from "react";
3
import { FormattedMessage, defineMessages, useIntl } from "react-intl";
4
import {
5
  Form,
6
  Formik,
7
  FieldArray,
8
  FormikErrors,
9
  FormikValues,
10
  FastField,
11
} from "formik";
12
import { array, object, string } from "yup";
13
import nprogress from "nprogress";
14
import { v4 as uuidv4 } from "uuid";
15
import Modal from "../../Modal";
16
import { validationMessages } from "../../Form/Messages";
17
import TextAreaInput from "../../Form/TextAreaInput";
18
import { JobPosterKeyTask } from "../../../models/types";
19
import { find } from "../../../helpers/queries";
20
import { emptyTasks } from "../../../models/jobUtil";
21
import { localizeFieldNonNull, getLocale } from "../../../helpers/localize";
22
23
interface JobTasksProps {
24
  /** Job ID to pass to tasks. */
25
  jobId: number | null;
26
  /** Key Tasks collection to populate the form */
27
  keyTasks: JobPosterKeyTask[] | null;
28
  /** Amount of tasks on the page considered 'valid'. Adding
29
   *  additional tasks will insert error markup and add invalid
30
   *  prop to textareas, as well as prevent form submission.
31
   */
32
  validCount: number;
33
  /** Function to run after successful form validation.
34
   * It must return true if the submission was succesful, false otherwise.
35
   */
36
  handleSubmit: (values: JobPosterKeyTask[]) => Promise<JobPosterKeyTask[]>;
37
  // The function to run when user clicks Prev Page
38
  handleReturn: () => void;
39
  /** Function to run when modal cancel is clicked. */
40
  handleModalCancel: () => void;
41
  /** Function to run when modal confirm is clicked. */
42
  handleModalConfirm: () => void;
43
  /** Whether the entire job is complete and valid for submission. */
44
  jobIsComplete: boolean;
45
  /** Function that skips to final review. */
46
  handleSkipToReview: () => Promise<void>;
47
}
48
49
interface TaskFormValues {
50
  id: string | number;
51
  jobPosterId: number;
52
  description: string;
53
}
54
55
const formMessages = defineMessages({
56
  taskPlaceholder: {
57
    id: "jobBuilder.tasks.taskPlaceholder",
58
    defaultMessage: "Try for a casual, frank, friendly tone...",
59
    description: "Placeholder shown inside a Task text area.",
60
  },
61
  taskLabel: {
62
    id: "jobBuilder.tasks.taskLabel",
63
    defaultMessage: "Task",
64
    description: "Label shown above a Task text area.",
65
  },
66
  tasksRequired: {
67
    id: "jobBuilder.tasks.tasksRequired",
68
    defaultMessage: "At least one task is required.",
69
    description:
70
      "Validation message shown when a user tries to submit no tasks.",
71
  },
72
  tasksMaximum: {
73
    id: "jobBuilder.tasks.tasksMaximum",
74
    defaultMessage: "Please remove any additional tasks before continuing.",
75
    description:
76
      "Validation message shown when a user tries to submit more than the allowed number of tasks.",
77
  },
78
});
79
80
export const JobTasks: React.FunctionComponent<JobTasksProps> = ({
81
  jobId,
82
  keyTasks,
83
  validCount,
84
  handleSubmit,
85
  handleReturn,
86
  handleModalCancel,
87
  handleModalConfirm,
88
  jobIsComplete,
89
  handleSkipToReview,
90
}): React.ReactElement => {
91
  const intl = useIntl();
92
  const locale = getLocale(intl.locale);
93
  const modalId = "tasks-modal";
94
  const [isModalVisible, setIsModalVisible] = useState(false);
95
  const modalParentRef = useRef<HTMLDivElement>(null);
96
97
  const tasksToValues = (
98
    tasks: JobPosterKeyTask[],
99
  ): { tasks: TaskFormValues[] } => ({
100
    tasks: tasks.map(
101
      (task: JobPosterKeyTask): TaskFormValues => ({
102
        id: task.id,
103
        jobPosterId: task.job_poster_id,
104
        description: localizeFieldNonNull(locale, task, "description"),
105
      }),
106
    ),
107
  });
108
109
  const updateTasksWithValues = (
110
    formTasks: TaskFormValues[],
111
    canonicalTasks: JobPosterKeyTask[],
112
  ): JobPosterKeyTask[] => {
113
    return formTasks
114
      .map(
115
        (task: TaskFormValues): JobPosterKeyTask => {
116
          const keyTask =
117
            task.id && typeof task.id === "number"
118
              ? find(canonicalTasks, task.id)
119
              : null;
120
          if (keyTask) {
121
            return {
122
              ...keyTask,
123
              description: {
124
                en: locale === "en" ? task.description : keyTask.description.en,
125
                fr: locale === "fr" ? task.description : keyTask.description.fr,
126
              },
127
            };
128
          }
129
          return {
130
            id: 0,
131
            job_poster_id: task.jobPosterId,
132
            description: {
133
              en: locale === "en" ? task.description : "",
134
              fr: locale === "fr" ? task.description : "",
135
            },
136
          };
137
        },
138
      )
139
      .filter((task: JobPosterKeyTask) => {
140
        const { description } = task;
141
        return (
142
          description !== undefined &&
143
          description !== null &&
144
          description[locale] !== ""
145
        );
146
      });
147
  };
148
149
  const taskSchema = object().shape({
150
    tasks: array()
151
      .of(
152
        object().shape({
153
          description: string().required(
154
            intl.formatMessage(validationMessages.required),
155
          ),
156
        }),
157
      )
158
      .required(intl.formatMessage(formMessages.tasksRequired))
159
      .max(validCount, intl.formatMessage(formMessages.tasksMaximum)),
160
  });
161
162
  const initialValues = keyTasks ? tasksToValues(keyTasks) : { tasks: [] };
163
164
  const updateValuesAndReturn = (values: { tasks: TaskFormValues[] }): void => {
165
    // The following only triggers after validations pass
166
    nprogress.start();
167
    handleSubmit(updateTasksWithValues(values.tasks, keyTasks || emptyTasks()))
168
      .then((): void => {
169
        nprogress.done();
170
        handleReturn();
171
      })
172
      .catch((): void => {
173
        nprogress.done();
174
      });
175
  };
176
177
  return (
178
    <div
179
      data-c-container="form"
180
      data-c-padding="top(triple) bottom(triple)"
181
      ref={modalParentRef}
182
    >
183
      <h3
184
        data-c-font-size="h3"
185
        data-c-font-weight="bold"
186
        data-c-margin="bottom(double)"
187
      >
188
        <FormattedMessage
189
          id="jobBuilder.tasks.heading"
190
          defaultMessage="Add Key Tasks"
191
          description="Job Tasks page heading"
192
        />
193
      </h3>
194
      <ul data-c-margin="bottom(double)">
195
        <li>
196
          <FormattedMessage
197
            id="jobBuilder.tasks.intro.first"
198
            defaultMessage="What will your new team member spend their time on? What will they deliver?"
199
            description="Job Tasks page first intro section"
200
          />
201
        </li>
202
        <li>
203
          <FormattedMessage
204
            id="jobBuilder.tasks.intro.second"
205
            defaultMessage="Focus on the key tasks. You don’t need to list every detail of the job, but applicants want to know how they will be spending most of their time."
206
            description="Job Tasks page second intro section"
207
          />
208
        </li>
209
        <li>
210
          <FormattedMessage
211
            id="jobBuilder.tasks.intro.third"
212
            defaultMessage="Aim to provide between four and six key tasks. (You can add as many key tasks as you want as you brainstorm here, but you can include no more than six in the final job poster.)"
213
            description="Job Tasks page third intro section"
214
          />
215
        </li>
216
        <li>
217
          <FormattedMessage
218
            id="jobBuilder.tasks.intro.fourth"
219
            defaultMessage="Once you have finished entering key tasks, you will move on to identify the individual skills needed to accomplish them."
220
            description="Job Tasks page fourth intro section"
221
          />
222
        </li>
223
      </ul>
224
      <Formik
225
        enableReinitialize
226
        initialValues={initialValues}
227
        validationSchema={taskSchema}
228
        onSubmit={(values, actions): void => {
229
          nprogress.start();
230
          // The following only triggers after validations pass
231
          nprogress.start();
232
          handleSubmit(
233
            updateTasksWithValues(values.tasks, keyTasks || emptyTasks()),
234
          )
235
            .then((updatedTasks): void => {
236
              /** Reseting form with new values adds the new, true ids from the server.
237
               *  This stops tasks from being recreated (instead of updated) if you save the form again.
238
               *  FIXME: However, this resets the ordering as well, to whatever order the server returns them in.
239
               */
240
              actions.resetForm({ values: tasksToValues(updatedTasks) });
241
              nprogress.done();
242
              setIsModalVisible(true);
243
            })
244
            .catch((): void => {
245
              nprogress.done();
246
            })
247
            .finally((): void => {
248
              actions.setSubmitting(false); // Required by Formik to finish the submission cycle
249
            });
250
        }}
251
      >
252
        {({
253
          isSubmitting,
254
          values,
255
          errors,
256
          setFieldValue,
257
        }): React.ReactElement => (
258
          <>
259
            {values.tasks.length > 0 && (
260
              <p data-c-alignment="tl(right)" data-c-margin="bottom(double)">
261
                <FormattedMessage
262
                  id="jobBuilder.tasks.taskCount.some"
263
                  defaultMessage="You have {taskCount, plural, one {# task} other {# tasks}} added."
264
                  description="Indicates how many tasks are present on the page."
265
                  values={{
266
                    taskCount: values.tasks.length,
267
                  }}
268
                />
269
              </p>
270
            )}
271
            {values.tasks.length === 0 && (
272
              <div
273
                data-c-margin="top(normal) bottom(double)"
274
                data-c-background="grey(20)"
275
                data-c-padding="normal"
276
                data-c-radius="rounded"
277
                data-c-border="all(thin, solid, grey)"
278
                data-c-alignment="centre"
279
              >
280
                <p>
281
                  <FormattedMessage
282
                    id="jobBuilder.tasks.taskCount.none"
283
                    defaultMessage="You don't have any tasks added yet!"
284
                    description="Message displayed when there are no tasks present on the page."
285
                  />
286
                </p>
287
              </div>
288
            )}
289
            <Form id="job-tasks">
290
              <FieldArray
291
                name="tasks"
292
                render={({ push }): React.ReactElement => {
293
                  /* The next two methods are workaround replacements
294
                   * for Formik's built-in array helpers. Due to the
295
                   * way they're called, they end up crashing the page
296
                   * when a Yup validation on the array is thrown,
297
                   * see https://github.com/jaredpalmer/formik/issues/1158#issuecomment-510868126
298
                   */
299
                  const move = (from: number, to: number): void => {
300
                    const copy = [...(values.tasks || [])];
301
                    const value = copy[from];
302
                    copy.splice(from, 1);
303
                    copy.splice(to, 0, value);
304
                    setFieldValue("tasks", copy);
305
                  };
306
307
                  const remove = (position: number): void => {
308
                    const copy = values.tasks ? [...values.tasks] : [];
309
                    copy.splice(position, 1);
310
                    setFieldValue("tasks", copy);
311
                  };
312
313
                  const taskArrayErrors = (
314
                    arrayErrors: FormikErrors<FormikValues>,
315
                  ): React.ReactElement | null =>
316
                    typeof arrayErrors.tasks === "string" ? (
317
                      <div
318
                        data-c-alert="error"
319
                        data-c-radius="rounded"
320
                        role="alert"
321
                        data-c-margin="top(normal)"
322
                      >
323
                        <div data-c-padding="half">
324
                          <p>{arrayErrors.tasks}</p>
325
                        </div>
326
                      </div>
327
                    ) : null;
328
329
                  const tempId = uuidv4();
330
331
                  return (
332
                    <>
333
                      <div data-c-grid="gutter">
334
                        {values.tasks &&
335
                          values.tasks.length > 0 &&
336
                          values.tasks.map(
337
                            (task, index): React.ReactElement => (
338
                              <React.Fragment key={task.id}>
339
                                {validCount === index && (
340
                                  <div
341
                                    key="taskError"
342
                                    className="job-builder-task-warning"
343
                                    data-c-grid-item="base(1of1)"
344
                                  >
345
                                    <div
346
                                      data-c-alert="error"
347
                                      data-c-radius="rounded"
348
                                      role="alert"
349
                                      data-c-margin="bottom(normal)"
350
                                    >
351
                                      <div data-c-padding="half">
352
                                        <span
353
                                          data-c-margin="bottom(quarter)"
354
                                          data-c-font-weight="bold"
355
                                        >
356
                                          <i
357
                                            aria-hidden="true"
358
                                            className="fas fa-exclamation-circle"
359
                                          />
360
                                          <FormattedMessage
361
                                            id="jobBuilder.tasks.taskCount.error.title"
362
                                            description="Error message displayed when too many tasks are on screen."
363
                                            defaultMessage="Just a heads up!"
364
                                          />
365
                                        </span>
366
                                        <p>
367
                                          <FormattedMessage
368
                                            id="jobBuilder.tasks.taskCount.error.body"
369
                                            description="Error message displayed when too many tasks are on screen."
370
                                            defaultMessage="You have exceeded the maximum number of key tasks allowed, but that’s okay. You can continue to add key tasks as you brainstorm here, but you will be asked to trim your list to 6 key tasks or fewer to proceed."
371
                                          />
372
                                        </p>
373
                                      </div>
374
                                    </div>
375
                                  </div>
376
                                )}
377
                                <div
378
                                  key={task.id}
379
                                  className={`job-builder-task${
380
                                    index + 1 > validCount ? " invalid" : ""
381
                                  }`}
382
                                  data-c-grid-item="base(1of1)"
383
                                  data-tc-up-down-item
384
                                >
385
                                  <div data-c-grid="gutter middle">
386
                                    <div
387
                                      data-c-grid-item="base(1of7) tl(1of10)"
388
                                      data-c-align="base(centre)"
389
                                    >
390
                                      <button
391
                                        type="button"
392
                                        data-tc-move-up-trigger
393
                                        onClick={(): void =>
394
                                          move(index, index - 1)
395
                                        }
396
                                      >
397
                                        <i className="fas fa-angle-up" />
398
                                      </button>
399
                                      <button
400
                                        type="button"
401
                                        data-tc-move-down-trigger
402
                                        onClick={(): void =>
403
                                          move(index, index + 1)
404
                                        }
405
                                      >
406
                                        <i className="fas fa-angle-down" />
407
                                      </button>
408
                                    </div>
409
                                    <FastField
410
                                      id={`task-${task.id}`}
411
                                      name={`tasks.${index}.description`}
412
                                      grid="base(5of7) tl(8of10)"
413
                                      label={`${intl.formatMessage(
414
                                        formMessages.taskLabel,
415
                                      )} ${index + 1}`}
416
                                      component={TextAreaInput}
417
                                      placeholder={intl.formatMessage(
418
                                        formMessages.taskPlaceholder,
419
                                      )}
420
                                      required
421
                                    />
422
                                    <div
423
                                      data-c-grid-item="base(1of7) tl(1of10)"
424
                                      data-c-align="base(centre)"
425
                                    >
426
                                      <button
427
                                        type="button"
428
                                        data-tc-builder-task-delete-trigger
429
                                        onClick={(): void => {
430
                                          remove(index);
431
                                        }}
432
                                      >
433
                                        <i
434
                                          className="fas fa-trash"
435
                                          data-c-colour="stop"
436
                                        />
437
                                      </button>
438
                                    </div>
439
                                  </div>
440
                                </div>
441
                              </React.Fragment>
442
                            ),
443
                          )}
444
                      </div>
445
                      <div data-c-grid="gutter">
446
                        <div
447
                          data-c-grid-item="base(1of1)"
448
                          data-c-alignment="base(centre)"
449
                        >
450
                          <button
451
                            data-c-button="solid(c2)"
452
                            data-c-radius="rounded"
453
                            type="button"
454
                            disabled={isSubmitting}
455
                            onClick={(): void =>
456
                              push({
457
                                id: tempId,
458
                                job_poster_id: jobId,
459
                                en: { description: "" },
460
                                fr: { description: "" },
461
                              })
462
                            }
463
                          >
464
                            <FormattedMessage
465
                              id="jobBuilder.tasks.addJob"
466
                              description="Text on the Add Task button."
467
                              defaultMessage="Add a Task"
468
                            />
469
                          </button>
470
                        </div>
471
                        <div data-c-grid-item="base(1of1)">
472
                          <hr data-c-margin="top(normal) bottom(normal)" />
473
                        </div>
474
                        <div
475
                          data-c-alignment="base(centre) tp(left)"
476
                          data-c-grid-item="tp(1of2)"
477
                        >
478
                          {/* TODO: Navigate to previous page */}
479
                          <button
480
                            data-c-button="outline(c2)"
481
                            data-c-radius="rounded"
482
                            type="button"
483
                            disabled={isSubmitting}
484
                            onClick={(): void => {
485
                              updateValuesAndReturn(values);
486
                            }}
487
                          >
488
                            <FormattedMessage
489
                              id="jobBuilder.tasks.previous"
490
                              description="Text on the Previous Step button."
491
                              defaultMessage="Save & Return to Impact"
492
                            />
493
                          </button>
494
                        </div>
495
                        <div
496
                          data-c-alignment="base(centre) tp(right)"
497
                          data-c-grid-item="tp(1of2)"
498
                        >
499
                          <button
500
                            data-c-button="solid(c2)"
501
                            data-c-radius="rounded"
502
                            type="submit"
503
                            disabled={isSubmitting}
504
                          >
505
                            <FormattedMessage
506
                              id="jobBuilder.tasks.preview"
507
                              description="Text on the Preview Tasks button."
508
                              defaultMessage="Save & Preview Tasks"
509
                            />
510
                          </button>
511
                          {/* TODO: Figure out how to display FieldArray validation errors. */}
512
                          {taskArrayErrors(errors)}
513
                        </div>
514
                      </div>
515
                    </>
516
                  );
517
                }}
518
              />
519
            </Form>
520
            <Modal
521
              id={modalId}
522
              parentElement={modalParentRef.current}
523
              visible={isModalVisible}
524
              onModalCancel={(): void => {
525
                handleModalCancel();
526
                setIsModalVisible(false);
527
              }}
528
              onModalConfirm={(): void => {
529
                handleModalConfirm();
530
              }}
531
              onModalMiddle={(): void => {
532
                handleSkipToReview();
533
              }}
534
            >
535
              <Modal.Header>
536
                <div
537
                  data-c-background="c1(100)"
538
                  data-c-border="bottom(thin, solid, black)"
539
                  data-c-padding="normal"
540
                >
541
                  <h5
542
                    data-c-colour="white"
543
                    data-c-font-size="h4"
544
                    id={`${modalId}-title`}
545
                  >
546
                    <FormattedMessage
547
                      id="jobBuilder.tasks.modal.title"
548
                      defaultMessage="Keep it up!"
549
                      description="Text displayed on the title of the Job Task page Modal."
550
                    />
551
                  </h5>
552
                </div>
553
              </Modal.Header>
554
              <Modal.Body>
555
                <div data-c-border="bottom(thin, solid, black)">
556
                  <div
557
                    data-c-border="bottom(thin, solid, black)"
558
                    data-c-padding="normal"
559
                    id={`${modalId}-description`}
560
                  >
561
                    <p>
562
                      <FormattedMessage
563
                        id="jobBuilder.tasks.modal.body"
564
                        description="Text displayed above the body of the Job Task page Modal."
565
                        defaultMessage="Here's a preview of the Tasks you just entered. Feel free to go back and edit things or move to the next step if you're happy with it."
566
                      />
567
                    </p>
568
                  </div>
569
                  <div data-c-background="grey(20)" data-c-padding="normal">
570
                    <div
571
                      className="manager-job-card"
572
                      data-c-background="white(100)"
573
                      data-c-padding="normal"
574
                      data-c-radius="rounded"
575
                    >
576
                      <h4
577
                        data-c-border="bottom(thin, solid, black)"
578
                        data-c-font-size="h4"
579
                        data-c-font-weight="600"
580
                        data-c-margin="bottom(normal)"
581
                        data-c-padding="bottom(normal)"
582
                      >
583
                        <FormattedMessage
584
                          id="jobBuilder.tasks.modal.body.heading"
585
                          description="Text displayed above the lists of Tasks inside the Modal body."
586
                          defaultMessage="Tasks"
587
                        />
588
                      </h4>
589
                      <ul>
590
                        {values.tasks &&
591
                          values.tasks.map(
592
                            (task: TaskFormValues): React.ReactElement => (
593
                              <li key={task.id}>{task.description}</li>
594
                            ),
595
                          )}
596
                      </ul>
597
                    </div>
598
                  </div>
599
                </div>
600
              </Modal.Body>
601
              <Modal.Footer>
602
                <Modal.FooterCancelBtn>
603
                  <FormattedMessage
604
                    id="jobBuilder.tasks.modal.cancelButtonLabel"
605
                    description="The text displayed on the cancel button of the Job Tasks modal."
606
                    defaultMessage="Go Back"
607
                  />
608
                </Modal.FooterCancelBtn>
609
                {jobIsComplete && (
610
                  <Modal.FooterMiddleBtn>
611
                    <FormattedMessage
612
                      id="jobBuilder.tasks.modal.middleButtonLabel"
613
                      description="The text displayed on the Skip to Review button of the Job Tasks modal."
614
                      defaultMessage="Skip to Review"
615
                    />
616
                  </Modal.FooterMiddleBtn>
617
                )}
618
                <Modal.FooterConfirmBtn>
619
                  <FormattedMessage
620
                    id="jobBuilder.tasks.modal.confirmButtonLabel"
621
                    description="The text displayed on the confirm button of the Job Tasks modal."
622
                    defaultMessage="Next Step"
623
                  />
624
                </Modal.FooterConfirmBtn>
625
              </Modal.Footer>
626
            </Modal>
627
            <div data-c-dialog-overlay={isModalVisible ? "active" : ""} />
628
          </>
629
        )}
630
      </Formik>
631
    </div>
632
  );
633
};
634
635
export default JobTasks;
636