Passed
Push — feature/connect-application-st... ( a6f23b...9d3dc6 )
by Chris
04:02
created

resources/assets/js/components/Application/ExperienceModals/PersonalExperienceModal.tsx   A

Complexity

Total Complexity 4
Complexity/F 0

Size

Lines of Code 385
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 4
eloc 315
mnd 4
bc 4
fnc 0
dl 0
loc 385
rs 10
bpm 0
cpm 0
noi 0
c 0
b 0
f 0
1
import React from "react";
2
import { FastField, Field, Formik, Form } from "formik";
3
import { defineMessages, useIntl, IntlShape } from "react-intl";
4
import * as Yup from "yup";
5
import {
6
  EducationFormValues,
7
  EducationSubform,
8
  validationShape as educationValidationShape,
9
} from "./EducationSubform";
10
import TextInput from "../../Form/TextInput";
11
import CheckboxInput from "../../Form/CheckboxInput";
12
import { validationMessages } from "../../Form/Messages";
13
import SkillSubform, {
14
  SkillFormValues,
15
  validationShape as skillValidationShape,
16
} from "./SkillSubform";
17
import { Skill, ExperiencePersonal } from "../../../models/types";
18
import {
19
  ExperienceModalHeader,
20
  ExperienceDetailsIntro,
21
  ExperienceModalFooter,
22
} from "./ExperienceModalCommon";
23
import Modal from "../../Modal";
24
import DateInput from "../../Form/DateInput";
25
import { toInputDateString, fromInputDateString } from "../../../helpers/dates";
26
import {
27
  Locales,
28
  localizeFieldNonNull,
29
  getLocale,
30
  matchValueToModel,
31
} from "../../../helpers/localize";
32
import { notEmpty } from "../../../helpers/queries";
33
import TextAreaInput from "../../Form/TextAreaInput";
34
import { countNumberOfWords } from "../../WordCounter/helpers";
35
36
interface PersonalExperienceModalProps {
37
  modalId: string;
38
  experiencePersonal: ExperiencePersonal | null;
39
  jobId: number;
40
  jobClassification: string;
41
  requiredSkills: Skill[];
42
  savedRequiredSkills: Skill[];
43
  optionalSkills: Skill[];
44
  savedOptionalSkills: Skill[];
45
  experienceableId: number;
46
  experienceableType: ExperiencePersonal["experienceable_type"];
47
  parentElement: Element | null;
48
  visible: boolean;
49
  onModalCancel: () => void;
50
  onModalConfirm: (data: PersonalExperienceSubmitData) => Promise<void>;
51
}
52
53
export const messages = defineMessages({
54
  modalTitle: {
55
    id: "application.personalExperienceModal.modalTitle",
56
    defaultMessage: "Add Personal Experience",
57
  },
58
  modalDescription: {
59
    id: "application.personalExperienceModal.modalDescription",
60
    defaultMessage:
61
      "People are more than just education and work experiences. We want to make space for you to share your learning from other experiences. To protect your privacy, please don't share sensitive information about yourself or others. A good measure would be if you are comfortable with all your colleagues knowing it. (Hint: Focus on the skills for the job when you decide on what examples to share.)",
62
  },
63
  titleLabel: {
64
    id: "application.personalExperienceModal.titleLabel",
65
    defaultMessage: "Give this experience a title:",
66
  },
67
  titlePlaceholder: {
68
    id: "application.personalExperienceModal.titlePlaceholder",
69
    defaultMessage: "e.g. My Parenting Experience",
70
  },
71
  descriptionLabel: {
72
    id: "application.personalExperienceModal.descriptionLabel",
73
    defaultMessage: "Describe the project or activity:",
74
  },
75
  descriptionPlaceholder: {
76
    id: "application.personalExperienceModal.descriptionPlaceholder",
77
    defaultMessage: "e.g. I have extensive experience in...",
78
  },
79
  isShareableLabel: {
80
    id: "application.personalExperienceModal.isShareableLabel",
81
    defaultMessage: "Sharing Consent",
82
  },
83
  isShareableInlineLabel: {
84
    id: "application.personalExperienceModal.isShareableInlineLabel",
85
    defaultMessage:
86
      "This information is not sensitive in nature and I am comfortable sharing it with the staff managing this job application.",
87
  },
88
  startDateLabel: {
89
    id: "application.personalExperienceModal.startDateLabel",
90
    defaultMessage: "Select a Start Date",
91
  },
92
  datePlaceholder: {
93
    id: "application.personalExperienceModal.datePlaceholder",
94
    defaultMessage: "yyyy-mm-dd",
95
  },
96
  isActiveLabel: {
97
    id: "application.personalExperienceModal.isActiveLabel",
98
    defaultMessage: "This experience is still ongoing, or...",
99
    description: "Label for checkbox that indicates work is still ongoing.",
100
  },
101
  endDateLabel: {
102
    id: "application.personalExperienceModal.endDateLabel",
103
    defaultMessage: "Select an End Date",
104
  },
105
});
106
107
export interface PersonalDetailsFormValues {
108
  title: string;
109
  description: string;
110
  isShareable: boolean;
111
  startDate: string;
112
  isActive: boolean;
113
  endDate: string;
114
}
115
116
type PersonalExperienceFormValues = SkillFormValues &
117
  EducationFormValues &
118
  PersonalDetailsFormValues;
119
export interface PersonalExperienceSubmitData {
120
  experiencePersonal: ExperiencePersonal;
121
  savedRequiredSkills: Skill[];
122
  savedOptionalSkills: Skill[];
123
}
124
125
const DESCRIPTION_WORD_LIMIT = 100;
126
127
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
128
export const validationShape = (intl: IntlShape) => {
129
  const requiredMsg = intl.formatMessage(validationMessages.required);
130
  const conditionalRequiredMsg = intl.formatMessage(
131
    validationMessages.endDateRequiredIfNotOngoing,
132
  );
133
  const inPastMsg = intl.formatMessage(validationMessages.dateMustBePast);
134
  const afterStartDateMsg = intl.formatMessage(
135
    validationMessages.endDateAfterStart,
136
  );
137
  const tooLong = intl.formatMessage(validationMessages.tooLong);
138
  return {
139
    title: Yup.string().required(requiredMsg),
140
    description: Yup.string()
141
      .required(requiredMsg)
142
      .test(
143
        "under-word-limit",
144
        tooLong,
145
        (value: string) => countNumberOfWords(value) <= DESCRIPTION_WORD_LIMIT,
146
      ),
147
    isShareable: Yup.boolean(),
148
    startDate: Yup.date().required(requiredMsg).max(new Date(), inPastMsg),
149
    isActive: Yup.boolean(),
150
    endDate: Yup.date().when("isActive", {
151
      is: false,
152
      then: Yup.date()
153
        .required(conditionalRequiredMsg)
154
        .min(Yup.ref("startDate"), afterStartDateMsg),
155
      otherwise: Yup.date().min(Yup.ref("startDate"), afterStartDateMsg),
156
    }),
157
  };
158
};
159
160
const dataToFormValues = (
161
  data: PersonalExperienceSubmitData,
162
  locale: Locales,
163
): PersonalExperienceFormValues => {
164
  const { experiencePersonal, savedRequiredSkills, savedOptionalSkills } = data;
165
  const skillToName = (skill: Skill): string =>
166
    localizeFieldNonNull(locale, skill, "name");
167
  return {
168
    requiredSkills: savedRequiredSkills.map(skillToName),
169
    optionalSkills: savedOptionalSkills.map(skillToName),
170
    useAsEducationRequirement: experiencePersonal.is_education_requirement,
171
    title: experiencePersonal.title,
172
    description: experiencePersonal.description,
173
    isShareable: experiencePersonal.is_shareable,
174
    startDate: toInputDateString(experiencePersonal.start_date),
175
    isActive: experiencePersonal.is_active,
176
    endDate: experiencePersonal.end_date
177
      ? toInputDateString(experiencePersonal.end_date)
178
      : "",
179
  };
180
};
181
182
/* eslint-disable @typescript-eslint/camelcase */
183
const formValuesToData = (
184
  formValues: PersonalExperienceFormValues,
185
  originalExperience: ExperiencePersonal,
186
  locale: Locales,
187
  skills: Skill[],
188
): PersonalExperienceSubmitData => {
189
  const nameToSkill = (name: string): Skill | null =>
190
    matchValueToModel(locale, "name", name, skills);
191
  return {
192
    experiencePersonal: {
193
      ...originalExperience,
194
      title: formValues.title,
195
      description: formValues.description,
196
      is_shareable: formValues.isShareable,
197
      start_date: fromInputDateString(formValues.startDate),
198
      is_active: formValues.isActive,
199
      end_date: formValues.endDate
200
        ? fromInputDateString(formValues.endDate)
201
        : null,
202
      is_education_requirement: formValues.useAsEducationRequirement,
203
    },
204
    savedRequiredSkills: formValues.requiredSkills
205
      .map(nameToSkill)
206
      .filter(notEmpty),
207
    savedOptionalSkills: formValues.optionalSkills
208
      .map(nameToSkill)
209
      .filter(notEmpty),
210
  };
211
};
212
213
const newPersonalExperience = (
214
  experienceableId: number,
215
  experienceableType: ExperiencePersonal["experienceable_type"],
216
): ExperiencePersonal => ({
217
  id: 0,
218
  title: "",
219
  description: "",
220
  is_shareable: false,
221
  is_active: false,
222
  start_date: new Date(),
223
  end_date: null,
224
  is_education_requirement: false,
225
  experienceable_id: experienceableId,
226
  experienceable_type: experienceableType,
227
  type: "experience_personal",
228
});
229
/* eslint-enable @typescript-eslint/camelcase */
230
231
export const PersonalExperienceModal: React.FC<PersonalExperienceModalProps> = ({
232
  modalId,
233
  experiencePersonal,
234
  jobId,
235
  jobClassification,
236
  requiredSkills,
237
  savedRequiredSkills,
238
  optionalSkills,
239
  savedOptionalSkills,
240
  experienceableId,
241
  experienceableType,
242
  parentElement,
243
  visible,
244
  onModalCancel,
245
  onModalConfirm,
246
}) => {
247
  const intl = useIntl();
248
  const locale = getLocale(intl.locale);
249
250
  const originalExperience =
251
    experiencePersonal ??
252
    newPersonalExperience(experienceableId, experienceableType);
253
254
  const skillToName = (skill: Skill): string =>
255
    localizeFieldNonNull(locale, skill, "name");
256
257
  const initialFormValues = dataToFormValues(
258
    {
259
      experiencePersonal: originalExperience,
260
      savedRequiredSkills,
261
      savedOptionalSkills,
262
    },
263
    locale,
264
  );
265
266
  const validationSchema = Yup.object().shape({
267
    ...skillValidationShape,
268
    ...educationValidationShape,
269
    ...validationShape(intl),
270
  });
271
272
  const detailsSubform = (
273
    <div data-c-container="medium">
274
      <div data-c-grid="gutter(all, 1) middle">
275
        <FastField
276
          id="title"
277
          name="title"
278
          type="text"
279
          grid="base(1of1)"
280
          component={TextInput}
281
          required
282
          label={intl.formatMessage(messages.titleLabel)}
283
          placeholder={intl.formatMessage(messages.titlePlaceholder)}
284
        />
285
        <FastField
286
          id="description"
287
          type="text"
288
          name="description"
289
          component={TextAreaInput}
290
          required
291
          grid="tl(1of1)"
292
          label={intl.formatMessage(messages.descriptionLabel)}
293
          placeholder={intl.formatMessage(messages.descriptionPlaceholder)}
294
          wordLimit={DESCRIPTION_WORD_LIMIT}
295
        />
296
        <div data-c-input="checkbox(group)" data-c-grid-item="base(1of1)">
297
          <label>{intl.formatMessage(messages.isShareableLabel)}</label>
298
          <FastField
299
            id="isShareable"
300
            name="isShareable"
301
            component={CheckboxInput}
302
            grid="base(1of1)"
303
            label={intl.formatMessage(messages.isShareableInlineLabel)}
304
            checkboxGroup
305
          />
306
        </div>
307
        <FastField
308
          id="startDate"
309
          name="startDate"
310
          component={DateInput}
311
          required
312
          grid="base(1of1)"
313
          label={intl.formatMessage(messages.startDateLabel)}
314
          placeholder={intl.formatMessage(messages.datePlaceholder)}
315
        />
316
        <Field
317
          id="isActive"
318
          name="isActive"
319
          component={CheckboxInput}
320
          grid="tl(1of2)"
321
          label={intl.formatMessage(messages.isActiveLabel)}
322
        />
323
        <Field
324
          id="endDate"
325
          name="endDate"
326
          component={DateInput}
327
          grid="base(1of2)"
328
          label={intl.formatMessage(messages.endDateLabel)}
329
          placeholder={intl.formatMessage(messages.datePlaceholder)}
330
        />
331
      </div>
332
    </div>
333
  );
334
335
  return (
336
    <Modal
337
      id={modalId}
338
      parentElement={parentElement}
339
      visible={visible}
340
      onModalCancel={onModalCancel}
341
      onModalConfirm={onModalCancel}
342
      className="application-experience-dialog"
343
    >
344
      <ExperienceModalHeader
345
        title={intl.formatMessage(messages.modalTitle)}
346
        iconClass="fa-mountain"
347
      />
348
      <Formik
349
        enableReinitialize
350
        initialValues={initialFormValues}
351
        onSubmit={async (values, actions): Promise<void> => {
352
          await onModalConfirm(
353
            formValuesToData(values, originalExperience, locale, [
354
              ...requiredSkills,
355
              ...optionalSkills,
356
            ]),
357
          );
358
          actions.setSubmitting(false);
359
        }}
360
        validationSchema={validationSchema}
361
      >
362
        {(formikProps): React.ReactElement => (
363
          <Form>
364
            <Modal.Body>
365
              <ExperienceDetailsIntro
366
                description={intl.formatMessage(messages.modalDescription)}
367
              />
368
              {detailsSubform}
369
              <SkillSubform
370
                jobId={jobId}
371
                jobRequiredSkills={requiredSkills.map(skillToName)}
372
                jobOptionalSkills={optionalSkills.map(skillToName)}
373
              />
374
              <EducationSubform jobClassification={jobClassification} />
375
            </Modal.Body>
376
            <ExperienceModalFooter buttonsDisabled={formikProps.isSubmitting} />
377
          </Form>
378
        )}
379
      </Formik>
380
    </Modal>
381
  );
382
};
383
384
export default PersonalExperienceModal;
385