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
|
|
|
|