Passed
Push — task/redirect-to-reserve-site ( 189bc9...c5d7b4 )
by Yonathan
03:43
created

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

Complexity

Total Complexity 11
Complexity/F 0

Size

Lines of Code 637
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

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