1
|
|
|
/* eslint-disable @typescript-eslint/camelcase */ |
2
|
|
|
import React, { FunctionComponent } from "react"; |
3
|
|
|
import { FastField, Field, Formik, Form } from "formik"; |
4
|
|
|
import { defineMessages, useIntl, IntlShape } from "react-intl"; |
5
|
|
|
import * as Yup from "yup"; |
6
|
|
|
import { |
7
|
|
|
EducationFormValues, |
8
|
|
|
EducationSubform, |
9
|
|
|
validationShape as educationValidationShape, |
10
|
|
|
} from "./EducationSubform"; |
11
|
|
|
import TextInput from "../../Form/TextInput"; |
12
|
|
|
import CheckboxInput from "../../Form/CheckboxInput"; |
13
|
|
|
import { validationMessages } from "../../Form/Messages"; |
14
|
|
|
import SkillSubform, { |
15
|
|
|
SkillFormValues, |
16
|
|
|
validationShape as skillValidationShape, |
17
|
|
|
} from "./SkillSubform"; |
18
|
|
|
import { Skill, ExperiencePersonal } from "../../../models/types"; |
19
|
|
|
import { |
20
|
|
|
ExperienceModalHeader, |
21
|
|
|
ExperienceDetailsIntro, |
22
|
|
|
ExperienceModalFooter, |
23
|
|
|
} from "./ExperienceModalCommon"; |
24
|
|
|
import Modal from "../../Modal"; |
25
|
|
|
import DateInput from "../../Form/DateInput"; |
26
|
|
|
import { toInputDateString, fromInputDateString } from "../../../helpers/dates"; |
27
|
|
|
import { |
28
|
|
|
Locales, |
29
|
|
|
localizeFieldNonNull, |
30
|
|
|
getLocale, |
31
|
|
|
matchValueToModel, |
32
|
|
|
} from "../../../helpers/localize"; |
33
|
|
|
import { notEmpty } from "../../../helpers/queries"; |
34
|
|
|
import TextAreaInput from "../../Form/TextAreaInput"; |
35
|
|
|
import { countNumberOfWords } from "../../WordCounter/helpers"; |
36
|
|
|
|
37
|
|
|
export const messages = defineMessages({ |
38
|
|
|
modalTitle: { |
39
|
|
|
id: "application.personalExperienceModal.modalTitle", |
40
|
|
|
defaultMessage: "Add Personal Experience", |
41
|
|
|
}, |
42
|
|
|
modalDescription: { |
43
|
|
|
id: "application.personalExperienceModal.modalDescription", |
44
|
|
|
defaultMessage: |
45
|
|
|
"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.)", |
46
|
|
|
}, |
47
|
|
|
titleLabel: { |
48
|
|
|
id: "application.personalExperienceModal.titleLabel", |
49
|
|
|
defaultMessage: "Give this experience a title:", |
50
|
|
|
}, |
51
|
|
|
titlePlaceholder: { |
52
|
|
|
id: "application.personalExperienceModal.titlePlaceholder", |
53
|
|
|
defaultMessage: "e.g. My Parenting Experience", |
54
|
|
|
}, |
55
|
|
|
descriptionLabel: { |
56
|
|
|
id: "application.personalExperienceModal.descriptionLabel", |
57
|
|
|
defaultMessage: "Describe the project or activity:", |
58
|
|
|
}, |
59
|
|
|
descriptionPlaceholder: { |
60
|
|
|
id: "application.personalExperienceModal.descriptionPlaceholder", |
61
|
|
|
defaultMessage: "e.g. I have extensive experience in...", |
62
|
|
|
}, |
63
|
|
|
isShareableLabel: { |
64
|
|
|
id: "application.personalExperienceModal.isShareableLabel", |
65
|
|
|
defaultMessage: "Sharing Consent", |
66
|
|
|
}, |
67
|
|
|
isShareableInlineLabel: { |
68
|
|
|
id: "application.personalExperienceModal.isShareableInlineLabel", |
69
|
|
|
defaultMessage: |
70
|
|
|
"Please acknowledge that this information is not sensitive in nature as it may be shared with the staff managing this job application.", |
71
|
|
|
}, |
72
|
|
|
startDateLabel: { |
73
|
|
|
id: "application.personalExperienceModal.startDateLabel", |
74
|
|
|
defaultMessage: "Select a Start Date", |
75
|
|
|
}, |
76
|
|
|
datePlaceholder: { |
77
|
|
|
id: "application.personalExperienceModal.datePlaceholder", |
78
|
|
|
defaultMessage: "yyyy-mm-dd", |
79
|
|
|
}, |
80
|
|
|
isActiveLabel: { |
81
|
|
|
id: "application.personalExperienceModal.isActiveLabel", |
82
|
|
|
defaultMessage: "This experience is still ongoing, or...", |
83
|
|
|
description: "Label for checkbox that indicates work is still ongoing.", |
84
|
|
|
}, |
85
|
|
|
endDateLabel: { |
86
|
|
|
id: "application.personalExperienceModal.endDateLabel", |
87
|
|
|
defaultMessage: "Select an End Date", |
88
|
|
|
}, |
89
|
|
|
}); |
90
|
|
|
|
91
|
|
|
const DESCRIPTION_WORD_LIMIT = 100; |
92
|
|
|
|
93
|
|
|
const DetailsSubform: FunctionComponent = () => { |
94
|
|
|
const intl = useIntl(); |
95
|
|
|
return ( |
96
|
|
|
<div data-c-container="medium"> |
97
|
|
|
<div data-c-grid="gutter(all, 1) middle"> |
98
|
|
|
<FastField |
99
|
|
|
id="personal-title" |
100
|
|
|
name="title" |
101
|
|
|
type="text" |
102
|
|
|
grid="base(1of1)" |
103
|
|
|
component={TextInput} |
104
|
|
|
required |
105
|
|
|
label={intl.formatMessage(messages.titleLabel)} |
106
|
|
|
placeholder={intl.formatMessage(messages.titlePlaceholder)} |
107
|
|
|
/> |
108
|
|
|
<FastField |
109
|
|
|
id="personal-description" |
110
|
|
|
type="text" |
111
|
|
|
name="description" |
112
|
|
|
component={TextAreaInput} |
113
|
|
|
required |
114
|
|
|
grid="tl(1of1)" |
115
|
|
|
label={intl.formatMessage(messages.descriptionLabel)} |
116
|
|
|
placeholder={intl.formatMessage(messages.descriptionPlaceholder)} |
117
|
|
|
wordLimit={DESCRIPTION_WORD_LIMIT} |
118
|
|
|
/> |
119
|
|
|
<div data-c-input="checkbox(group)" data-c-grid-item="base(1of1)"> |
120
|
|
|
<label>{intl.formatMessage(messages.isShareableLabel)}</label> |
121
|
|
|
<FastField |
122
|
|
|
id="personal-isShareable" |
123
|
|
|
name="isShareable" |
124
|
|
|
component={CheckboxInput} |
125
|
|
|
grid="base(1of1)" |
126
|
|
|
label={intl.formatMessage(messages.isShareableInlineLabel)} |
127
|
|
|
checkboxGroup |
128
|
|
|
/> |
129
|
|
|
</div> |
130
|
|
|
<FastField |
131
|
|
|
id="personal-startDate" |
132
|
|
|
name="startDate" |
133
|
|
|
component={DateInput} |
134
|
|
|
required |
135
|
|
|
grid="base(1of1)" |
136
|
|
|
label={intl.formatMessage(messages.startDateLabel)} |
137
|
|
|
placeholder={intl.formatMessage(messages.datePlaceholder)} |
138
|
|
|
/> |
139
|
|
|
<Field |
140
|
|
|
id="personal-isActive" |
141
|
|
|
name="isActive" |
142
|
|
|
component={CheckboxInput} |
143
|
|
|
grid="tl(1of2)" |
144
|
|
|
label={intl.formatMessage(messages.isActiveLabel)} |
145
|
|
|
/> |
146
|
|
|
<Field |
147
|
|
|
id="personal-endDate" |
148
|
|
|
name="endDate" |
149
|
|
|
component={DateInput} |
150
|
|
|
grid="base(1of2)" |
151
|
|
|
label={intl.formatMessage(messages.endDateLabel)} |
152
|
|
|
placeholder={intl.formatMessage(messages.datePlaceholder)} |
153
|
|
|
/> |
154
|
|
|
</div> |
155
|
|
|
</div> |
156
|
|
|
); |
157
|
|
|
}; |
158
|
|
|
|
159
|
|
|
export interface PersonalDetailsFormValues { |
160
|
|
|
title: string; |
161
|
|
|
description: string; |
162
|
|
|
isShareable: boolean; |
163
|
|
|
startDate: string; |
164
|
|
|
isActive: boolean; |
165
|
|
|
endDate: string; |
166
|
|
|
} |
167
|
|
|
|
168
|
|
|
type PersonalExperienceFormValues = SkillFormValues & |
169
|
|
|
EducationFormValues & |
170
|
|
|
PersonalDetailsFormValues; |
171
|
|
|
export interface PersonalExperienceSubmitData { |
172
|
|
|
experiencePersonal: ExperiencePersonal; |
173
|
|
|
savedRequiredSkills: Skill[]; |
174
|
|
|
savedOptionalSkills: Skill[]; |
175
|
|
|
} |
176
|
|
|
|
177
|
|
|
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type |
178
|
|
|
export const validationShape = (intl: IntlShape) => { |
179
|
|
|
const requiredMsg = intl.formatMessage(validationMessages.required); |
180
|
|
|
const conditionalRequiredMsg = intl.formatMessage( |
181
|
|
|
validationMessages.endDateRequiredIfNotOngoing, |
182
|
|
|
); |
183
|
|
|
const inPastMsg = intl.formatMessage(validationMessages.dateMustBePast); |
184
|
|
|
const afterStartDateMsg = intl.formatMessage( |
185
|
|
|
validationMessages.endDateAfterStart, |
186
|
|
|
); |
187
|
|
|
const tooLong = intl.formatMessage(validationMessages.tooLong); |
188
|
|
|
return { |
189
|
|
|
title: Yup.string().required(requiredMsg), |
190
|
|
|
description: Yup.string() |
191
|
|
|
.required(requiredMsg) |
192
|
|
|
.test( |
193
|
|
|
"under-word-limit", |
194
|
|
|
tooLong, |
195
|
|
|
(value: string) => countNumberOfWords(value) <= DESCRIPTION_WORD_LIMIT, |
196
|
|
|
), |
197
|
|
|
isShareable: Yup.boolean(), |
198
|
|
|
startDate: Yup.date().required(requiredMsg).max(new Date(), inPastMsg), |
199
|
|
|
isActive: Yup.boolean(), |
200
|
|
|
endDate: Yup.date().when("isActive", { |
201
|
|
|
is: false, |
202
|
|
|
then: Yup.date() |
203
|
|
|
.required(conditionalRequiredMsg) |
204
|
|
|
.min(Yup.ref("startDate"), afterStartDateMsg), |
205
|
|
|
otherwise: Yup.date().min(Yup.ref("startDate"), afterStartDateMsg), |
206
|
|
|
}), |
207
|
|
|
}; |
208
|
|
|
}; |
209
|
|
|
|
210
|
|
|
const experienceToDetails = ( |
211
|
|
|
experiencePersonal: ExperiencePersonal, |
212
|
|
|
): PersonalDetailsFormValues => { |
213
|
|
|
return { |
214
|
|
|
title: experiencePersonal.title, |
215
|
|
|
description: experiencePersonal.description, |
216
|
|
|
isShareable: experiencePersonal.is_shareable, |
217
|
|
|
startDate: toInputDateString(experiencePersonal.start_date), |
218
|
|
|
isActive: experiencePersonal.is_active, |
219
|
|
|
endDate: experiencePersonal.end_date |
220
|
|
|
? toInputDateString(experiencePersonal.end_date) |
221
|
|
|
: "", |
222
|
|
|
}; |
223
|
|
|
}; |
224
|
|
|
|
225
|
|
|
const dataToFormValues = ( |
226
|
|
|
data: PersonalExperienceSubmitData, |
227
|
|
|
locale: Locales, |
228
|
|
|
): PersonalExperienceFormValues => { |
229
|
|
|
const { experiencePersonal, savedRequiredSkills, savedOptionalSkills } = data; |
230
|
|
|
const skillToName = (skill: Skill): string => |
231
|
|
|
localizeFieldNonNull(locale, skill, "name"); |
232
|
|
|
return { |
233
|
|
|
...experienceToDetails(data.experiencePersonal), |
234
|
|
|
requiredSkills: savedRequiredSkills.map(skillToName), |
235
|
|
|
optionalSkills: savedOptionalSkills.map(skillToName), |
236
|
|
|
useAsEducationRequirement: experiencePersonal.is_education_requirement, |
237
|
|
|
}; |
238
|
|
|
}; |
239
|
|
|
|
240
|
|
|
const detailsToExperience = ( |
241
|
|
|
formValues: PersonalDetailsFormValues, |
242
|
|
|
originalExperience: ExperiencePersonal, |
243
|
|
|
): ExperiencePersonal => { |
244
|
|
|
return { |
245
|
|
|
...originalExperience, |
246
|
|
|
title: formValues.title, |
247
|
|
|
description: formValues.description, |
248
|
|
|
is_shareable: formValues.isShareable, |
249
|
|
|
start_date: fromInputDateString(formValues.startDate), |
250
|
|
|
is_active: formValues.isActive, |
251
|
|
|
end_date: formValues.endDate |
252
|
|
|
? fromInputDateString(formValues.endDate) |
253
|
|
|
: null, |
254
|
|
|
}; |
255
|
|
|
}; |
256
|
|
|
|
257
|
|
|
const formValuesToData = ( |
258
|
|
|
formValues: PersonalExperienceFormValues, |
259
|
|
|
originalExperience: ExperiencePersonal, |
260
|
|
|
locale: Locales, |
261
|
|
|
skills: Skill[], |
262
|
|
|
): PersonalExperienceSubmitData => { |
263
|
|
|
const nameToSkill = (name: string): Skill | null => |
264
|
|
|
matchValueToModel(locale, "name", name, skills); |
265
|
|
|
return { |
266
|
|
|
experiencePersonal: { |
267
|
|
|
...detailsToExperience(formValues, originalExperience), |
268
|
|
|
is_education_requirement: formValues.useAsEducationRequirement, |
269
|
|
|
}, |
270
|
|
|
savedRequiredSkills: formValues.requiredSkills |
271
|
|
|
.map(nameToSkill) |
272
|
|
|
.filter(notEmpty), |
273
|
|
|
savedOptionalSkills: formValues.optionalSkills |
274
|
|
|
.map(nameToSkill) |
275
|
|
|
.filter(notEmpty), |
276
|
|
|
}; |
277
|
|
|
}; |
278
|
|
|
|
279
|
|
|
const newPersonalExperience = ( |
280
|
|
|
experienceableId: number, |
281
|
|
|
experienceableType: ExperiencePersonal["experienceable_type"], |
282
|
|
|
): ExperiencePersonal => ({ |
283
|
|
|
id: 0, |
284
|
|
|
title: "", |
285
|
|
|
description: "", |
286
|
|
|
is_shareable: false, |
287
|
|
|
is_active: false, |
288
|
|
|
start_date: new Date(), |
289
|
|
|
end_date: null, |
290
|
|
|
is_education_requirement: false, |
291
|
|
|
experienceable_id: experienceableId, |
292
|
|
|
experienceable_type: experienceableType, |
293
|
|
|
type: "experience_personal", |
294
|
|
|
}); |
295
|
|
|
|
296
|
|
|
interface ProfilePersonalModalProps { |
297
|
|
|
modalId: string; |
298
|
|
|
experiencePersonal: ExperiencePersonal | null; |
299
|
|
|
experienceableId: number; |
300
|
|
|
experienceableType: ExperiencePersonal["experienceable_type"]; |
301
|
|
|
parentElement: Element | null; |
302
|
|
|
visible: boolean; |
303
|
|
|
onModalCancel: () => void; |
304
|
|
|
onModalConfirm: (data: ExperiencePersonal) => Promise<void>; |
305
|
|
|
} |
306
|
|
|
|
307
|
|
|
export const ProfilePersonalModal: FunctionComponent<ProfilePersonalModalProps> = ({ |
308
|
|
|
modalId, |
309
|
|
|
experiencePersonal, |
310
|
|
|
experienceableId, |
311
|
|
|
experienceableType, |
312
|
|
|
parentElement, |
313
|
|
|
visible, |
314
|
|
|
onModalCancel, |
315
|
|
|
onModalConfirm, |
316
|
|
|
}) => { |
317
|
|
|
const intl = useIntl(); |
318
|
|
|
|
319
|
|
|
const originalExperience = |
320
|
|
|
experiencePersonal ?? |
321
|
|
|
newPersonalExperience(experienceableId, experienceableType); |
322
|
|
|
|
323
|
|
|
const initialFormValues = experienceToDetails(originalExperience); |
324
|
|
|
|
325
|
|
|
const validationSchema = Yup.object().shape({ |
326
|
|
|
...validationShape(intl), |
327
|
|
|
}); |
328
|
|
|
|
329
|
|
|
return ( |
330
|
|
|
<Modal |
331
|
|
|
id={modalId} |
332
|
|
|
parentElement={parentElement} |
333
|
|
|
visible={visible} |
334
|
|
|
onModalCancel={onModalCancel} |
335
|
|
|
onModalConfirm={onModalCancel} |
336
|
|
|
className="application-experience-dialog" |
337
|
|
|
> |
338
|
|
|
<ExperienceModalHeader |
339
|
|
|
title={intl.formatMessage(messages.modalTitle)} |
340
|
|
|
iconClass="fa-mountain" |
341
|
|
|
/> |
342
|
|
|
<Formik |
343
|
|
|
enableReinitialize |
344
|
|
|
initialValues={initialFormValues} |
345
|
|
|
onSubmit={async (values, actions): Promise<void> => { |
346
|
|
|
await onModalConfirm(detailsToExperience(values, originalExperience)); |
347
|
|
|
actions.setSubmitting(false); |
348
|
|
|
actions.resetForm(); |
349
|
|
|
}} |
350
|
|
|
validationSchema={validationSchema} |
351
|
|
|
> |
352
|
|
|
{(formikProps): React.ReactElement => ( |
353
|
|
|
<Form> |
354
|
|
|
<Modal.Body> |
355
|
|
|
<ExperienceDetailsIntro |
356
|
|
|
description={intl.formatMessage(messages.modalDescription)} |
357
|
|
|
/> |
358
|
|
|
<DetailsSubform /> |
359
|
|
|
</Modal.Body> |
360
|
|
|
<ExperienceModalFooter buttonsDisabled={formikProps.isSubmitting} /> |
361
|
|
|
</Form> |
362
|
|
|
)} |
363
|
|
|
</Formik> |
364
|
|
|
</Modal> |
365
|
|
|
); |
366
|
|
|
}; |
367
|
|
|
|
368
|
|
|
interface PersonalExperienceModalProps { |
369
|
|
|
modalId: string; |
370
|
|
|
experiencePersonal: ExperiencePersonal | null; |
371
|
|
|
jobId: number; |
372
|
|
|
jobClassification: string; |
373
|
|
|
jobEducationRequirements: string | null; |
374
|
|
|
requiredSkills: Skill[]; |
375
|
|
|
savedRequiredSkills: Skill[]; |
376
|
|
|
optionalSkills: Skill[]; |
377
|
|
|
savedOptionalSkills: Skill[]; |
378
|
|
|
experienceableId: number; |
379
|
|
|
experienceableType: ExperiencePersonal["experienceable_type"]; |
380
|
|
|
parentElement: Element | null; |
381
|
|
|
visible: boolean; |
382
|
|
|
onModalCancel: () => void; |
383
|
|
|
onModalConfirm: (data: PersonalExperienceSubmitData) => Promise<void>; |
384
|
|
|
} |
385
|
|
|
|
386
|
|
|
export const PersonalExperienceModal: React.FC<PersonalExperienceModalProps> = ({ |
387
|
|
|
modalId, |
388
|
|
|
experiencePersonal, |
389
|
|
|
jobId, |
390
|
|
|
jobClassification, |
391
|
|
|
jobEducationRequirements, |
392
|
|
|
requiredSkills, |
393
|
|
|
savedRequiredSkills, |
394
|
|
|
optionalSkills, |
395
|
|
|
savedOptionalSkills, |
396
|
|
|
experienceableId, |
397
|
|
|
experienceableType, |
398
|
|
|
parentElement, |
399
|
|
|
visible, |
400
|
|
|
onModalCancel, |
401
|
|
|
onModalConfirm, |
402
|
|
|
}) => { |
403
|
|
|
const intl = useIntl(); |
404
|
|
|
const locale = getLocale(intl.locale); |
405
|
|
|
|
406
|
|
|
const originalExperience = |
407
|
|
|
experiencePersonal ?? |
408
|
|
|
newPersonalExperience(experienceableId, experienceableType); |
409
|
|
|
|
410
|
|
|
const skillToName = (skill: Skill): string => |
411
|
|
|
localizeFieldNonNull(locale, skill, "name"); |
412
|
|
|
|
413
|
|
|
const initialFormValues = dataToFormValues( |
414
|
|
|
{ |
415
|
|
|
experiencePersonal: originalExperience, |
416
|
|
|
savedRequiredSkills, |
417
|
|
|
savedOptionalSkills, |
418
|
|
|
}, |
419
|
|
|
locale, |
420
|
|
|
); |
421
|
|
|
|
422
|
|
|
const validationSchema = Yup.object().shape({ |
423
|
|
|
...skillValidationShape, |
424
|
|
|
...educationValidationShape, |
425
|
|
|
...validationShape(intl), |
426
|
|
|
}); |
427
|
|
|
|
428
|
|
|
return ( |
429
|
|
|
<Modal |
430
|
|
|
id={modalId} |
431
|
|
|
parentElement={parentElement} |
432
|
|
|
visible={visible} |
433
|
|
|
onModalCancel={onModalCancel} |
434
|
|
|
onModalConfirm={onModalCancel} |
435
|
|
|
className="application-experience-dialog" |
436
|
|
|
> |
437
|
|
|
<ExperienceModalHeader |
438
|
|
|
title={intl.formatMessage(messages.modalTitle)} |
439
|
|
|
iconClass="fa-mountain" |
440
|
|
|
/> |
441
|
|
|
<Formik |
442
|
|
|
enableReinitialize |
443
|
|
|
initialValues={initialFormValues} |
444
|
|
|
onSubmit={async (values, actions): Promise<void> => { |
445
|
|
|
await onModalConfirm( |
446
|
|
|
formValuesToData(values, originalExperience, locale, [ |
447
|
|
|
...requiredSkills, |
448
|
|
|
...optionalSkills, |
449
|
|
|
]), |
450
|
|
|
); |
451
|
|
|
actions.setSubmitting(false); |
452
|
|
|
actions.resetForm(); |
453
|
|
|
}} |
454
|
|
|
validationSchema={validationSchema} |
455
|
|
|
> |
456
|
|
|
{(formikProps): React.ReactElement => ( |
457
|
|
|
<Form> |
458
|
|
|
<Modal.Body> |
459
|
|
|
<ExperienceDetailsIntro |
460
|
|
|
description={intl.formatMessage(messages.modalDescription)} |
461
|
|
|
/> |
462
|
|
|
<DetailsSubform /> |
463
|
|
|
<SkillSubform |
464
|
|
|
keyPrefix="personal" |
465
|
|
|
jobId={jobId} |
466
|
|
|
jobRequiredSkills={requiredSkills.map(skillToName)} |
467
|
|
|
jobOptionalSkills={optionalSkills.map(skillToName)} |
468
|
|
|
/> |
469
|
|
|
<EducationSubform |
470
|
|
|
keyPrefix="personal" |
471
|
|
|
jobClassification={jobClassification} |
472
|
|
|
jobEducationRequirements={jobEducationRequirements} |
473
|
|
|
/> |
474
|
|
|
</Modal.Body> |
475
|
|
|
<ExperienceModalFooter buttonsDisabled={formikProps.isSubmitting} /> |
476
|
|
|
</Form> |
477
|
|
|
)} |
478
|
|
|
</Formik> |
479
|
|
|
</Modal> |
480
|
|
|
); |
481
|
|
|
}; |
482
|
|
|
|
483
|
|
|
export default PersonalExperienceModal; |
484
|
|
|
|