- {jobs.map(job => (
+ {jobs.map((job) => (
-
{localizeField(locale, job, "title")}
diff --git a/resources/assets/js/components/WithPropsChecker.tsx b/resources/assets/js/components/WithPropsChecker.tsx
index b25ec2d793..b7ed0da349 100644
--- a/resources/assets/js/components/WithPropsChecker.tsx
+++ b/resources/assets/js/components/WithPropsChecker.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
import React from "react";
interface Props {
diff --git a/resources/assets/js/configureStore.ts b/resources/assets/js/configureStore.ts
index d2a9e79af3..5d6b902505 100644
--- a/resources/assets/js/configureStore.ts
+++ b/resources/assets/js/configureStore.ts
@@ -16,7 +16,9 @@ export type DispatchType = Dispatch &
ThunkDispatch;
export const configureStore = (): Store => {
- const logger = createLogger();
+ const logger = createLogger({
+ collapsed: true,
+ });
const isDev = process.env.NODE_ENV === "development";
diff --git a/resources/assets/js/fakeData/fakeApplications.ts b/resources/assets/js/fakeData/fakeApplications.ts
index 5cacc28052..b0c6bb77f3 100644
--- a/resources/assets/js/fakeData/fakeApplications.ts
+++ b/resources/assets/js/fakeData/fakeApplications.ts
@@ -1,12 +1,19 @@
-/* eslint camelcase: "off", @typescript-eslint/camelcase: "off" */
+/* eslint camelcase: "off" */
import { ApplicationStep, ProgressBarStatus } from "../models/lookupConstants";
import {
Application,
ApplicationNormalized,
ApplicationReview,
Email,
+ ReviewStatus,
} from "../models/types";
+export const reviewStatuses: ReviewStatus[] = [
+ { id: 1, name: "screened_out" },
+ { id: 2, name: "still_thinking" },
+ { id: 3, name: "still_in" },
+];
+
export const fakeApplicationReview = (
overrides: Partial = {},
): ApplicationReview => ({
@@ -50,7 +57,6 @@ export const fakeApplicationNormalized = (
version_id: 2,
user_email: null,
user_name: null,
-
veteran_status: {
id: 1,
name: "none",
@@ -147,10 +153,109 @@ export const fakeApplication3 = (
});
};
+export const fakeApplication4 = (
+ overrides: Partial = {},
+): Application => {
+ return fakeApplication({
+ id: 4,
+ job_poster_id: 3,
+ applicant_id: 4,
+ applicant: {
+ ...fakeApplicationNormalized().applicant,
+ id: 4,
+ user: {
+ ...fakeApplicationNormalized().applicant.user,
+ first_name: "Brianne",
+ last_name: "Rice",
+ full_name: "Brianne Rice",
+ email: "Brianne.Rice@tbs-sct.gc.ca",
+ gov_email: "Brianne.Rice@tbs-sct.gc.ca",
+ },
+ },
+ application_review: {
+ ...fakeApplicationReview(),
+ id: 4,
+ job_application_id: 4,
+ review_status_id: reviewStatuses[1].id,
+ notes:
+ "Still considering this candidate pending completion of assessment.",
+ review_status: reviewStatuses[1],
+ },
+ ...overrides,
+ });
+};
+
+export const fakeApplication5 = (
+ overrides: Partial = {},
+): Application => {
+ return fakeApplication({
+ id: 5,
+ job_poster_id: 3,
+ applicant_id: 5,
+ applicant: {
+ ...fakeApplicationNormalized().applicant,
+ id: 5,
+ user: {
+ ...fakeApplicationNormalized().applicant.user,
+ first_name: "Magnus",
+ last_name: "Bogan",
+ full_name: "Magnus Bogan",
+ email: "Magnus.Bogan@tbs-sct.gc.ca",
+ gov_email: "Magnus.Bogan@tbs-sct.gc.ca",
+ },
+ },
+ application_review: {
+ ...fakeApplicationReview(),
+ id: 5,
+ job_application_id: 5,
+ review_status_id: reviewStatuses[1].id,
+ notes:
+ "Still considering this candidate pending completion of assessment.",
+ review_status: reviewStatuses[1],
+ },
+ ...overrides,
+ });
+};
+
+export const fakeApplication6 = (
+ overrides: Partial = {},
+): Application => {
+ return fakeApplication({
+ id: 6,
+ job_poster_id: 3,
+ applicant_id: 6,
+ applicant: {
+ ...fakeApplicationNormalized().applicant,
+ id: 6,
+ user: {
+ ...fakeApplicationNormalized().applicant.user,
+ first_name: "Henriette",
+ last_name: "Brackus",
+ full_name: "Henriette Brackus",
+ email: "Henriette.Brackus@tbs-sct.gc.ca",
+ gov_email: "Henriette.Brackus@tbs-sct.gc.ca",
+ },
+ },
+ application_review: {
+ ...fakeApplicationReview(),
+ id: 6,
+ job_application_id: 6,
+ review_status_id: reviewStatuses[0].id,
+ notes:
+ "This candidate did not successfully complete the written assessment.",
+ review_status: reviewStatuses[0],
+ },
+ ...overrides,
+ });
+};
+
export const fakeApplications = (): Application[] => [
fakeApplication1(),
fakeApplication2(),
fakeApplication3(),
+ fakeApplication4(),
+ fakeApplication5(),
+ fakeApplication6(),
];
const defaultEmailBody = `Dear *Name*:
diff --git a/resources/assets/js/fakeData/fakeClassifications.ts b/resources/assets/js/fakeData/fakeClassifications.ts
index 3c2e022cdd..c2a940c8c3 100644
--- a/resources/assets/js/fakeData/fakeClassifications.ts
+++ b/resources/assets/js/fakeData/fakeClassifications.ts
@@ -4,20 +4,67 @@ export const fakeClassification1 = (): Classification => ({
id: 5,
key: "CS",
name: {
- en: "CS - Computer Services",
- fr: "",
+ en: "CS - Computer Systems",
+ fr: "CS - Systèmes d’ordinateurs",
+ },
+ education_requirements: {
+ en:
+ "2 years post-secondary, or equivalent:\nSuccessful completion of two years of post-secondary education in computer science, information technology, information management or another specialty relevant to this position.\n\nor\n\nEquivalent experience:\nIf you have on-the-job learning or other non-conventional training that you believe is equivalent to the 2 year post-secondary requirement, put it forward for consideration. The manager may accept a combination of education, training and/or experience in a related field as an alternative to the minimum post-secondary education stated above.",
+ fr:
+ "Deux (2) ans d’études postsecondaires ou l’équivalent:\nDeux années d’études postsecondaires en informatique, en technologie de l’information, en gestion de l’information ou dans une autre spécialité pertinente à ce poste.\n\nou\n\nExpérience équivalente:\nSi vous avez reçu une formation en cours d’emploi ou une autre formation non traditionnelle que vous croyez équivalente aux deux années d’études postsecondaires requises, faites-en état afin qu’on en tienne compte. Le gestionnaire pourrait accepter une combinaison d’études, de formation et/ou d’expérience dans un domaine pertinent comme étant équivalente au niveau minimal d’études postsecondaires énoncé ci-dessus.",
},
});
export const fakeClassification2 = (): Classification => ({
- id: 7,
- key: "EX",
- name: {
- en: "Executive",
- fr: "",
- },
- });
+ id: 7,
+ key: "EX",
+ name: {
+ en: "EX - Executive",
+ fr: "EX - Direction",
+ },
+ education_requirements: {
+ en:
+ "Post-secondary degree, or equivalent:\nPost-secondary degree, or eligibility for a recognized professional designation in one of the provinces or territories of Canada.\n\nor\n\nEquivalent experience:\nIf you have on-the-job learning or other non-conventional training that you believe is equivalent to the post-secondary degree requirement, put it forward for consideration. The manager may accept a combination of education, training and/or experience in a related field as an alternative to the minimum post-secondary education stated above.",
+ fr:
+ "Diplôme d’études postsecondaires ou l’équivalent:\nDiplôme d’études postsecondaires, ou admissibilité à un titre professionnel reconnu dans une province ou un territoire du Canada.\n\nou\n\nExpérience équivalente:\nSi vous avez reçu une formation en cours d’emploi ou une autre formation non traditionnelle que vous croyez équivalente à l’exigence relative au diplôme d’études postsecondaires, indiquez-le aux fins d’examen. Le gestionnaire pourrait accepter une combinaison d’études, de formation et/ou d’expérience dans un domaine pertinent comme étant équivalente au niveau minimal d’études postsecondaires énoncé ci-dessus.",
+ },
+});
+
+export const fakeClassification3 = (): Classification => ({
+ id: 9,
+ key: "IS",
+ name: {
+ en: "IS - Information Services",
+ fr: "IS - Services d’information",
+ },
+ education_requirements: {
+ en:
+ "Post-secondary degree, or equivalent:\nSuccessful completion of a post-secondary degree.\n\nor\n\nEquivalent experience:\nIf you have on-the-job learning or other non-conventional training that you believe is equivalent to the post-secondary degree requirement, put it forward for consideration. The manager may accept a combination of education, training and/or experience in a related field as an alternative to the minimum post-secondary education stated above.",
+ fr:
+ "Diplôme d’études postsecondaires ou l’équivalent:\nDiplôme d’études postsecondaires.\n\nou\n\nExpérience équivalente:\nSi vous avez reçu une formation en cours d’emploi ou une autre formation non traditionnelle que vous croyez équivalente à l’exigence relative au diplôme d’études postsecondaires, indiquez-le aux fins d’examen. Le gestionnaire pourrait accepter une combinaison d’études, de formation et/ou d’expérience dans un domaine pertinent comme étant équivalente au niveau minimal d’études postsecondaires énoncé ci-dessus.",
+ },
+});
+
+export const fakeClassification4 = (): Classification => ({
+ id: 10,
+ key: "PC",
+ name: {
+ en: "PC - Physical Sciences",
+ fr: "PC - Sciences physiques",
+ },
+ education_requirements: {
+ en:
+ "Post-secondary degree:\nSuccessful completion of a post-secondary degree with specialization in physics, geology, chemistry or some other science relevant to the position.",
+ fr:
+ "Diplôme d’études postsecondaires:\nDiplôme d’études postsecondaires, avec spécialisation en physique, en géologie, en chimie ou dans une autre science liée aux fonctions du poste.",
+ },
+});
- export const fakeClassifications = (): Classification[] => [fakeClassification1(), fakeClassification2()];
+export const fakeClassifications = (): Classification[] => [
+ fakeClassification1(),
+ fakeClassification2(),
+ fakeClassification3(),
+ fakeClassification4(),
+];
- export default fakeClassifications;
+export default fakeClassifications;
diff --git a/resources/assets/js/fakeData/fakeCriteria.ts b/resources/assets/js/fakeData/fakeCriteria.ts
index 378c6b4dd2..727ed15390 100644
--- a/resources/assets/js/fakeData/fakeCriteria.ts
+++ b/resources/assets/js/fakeData/fakeCriteria.ts
@@ -1,4 +1,3 @@
-/* eslint-disable @typescript-eslint/camelcase */
import { Criteria } from "../models/types";
export const fakeCriterion1 = (): Criteria => ({
diff --git a/resources/assets/js/fakeData/fakeExperience.ts b/resources/assets/js/fakeData/fakeExperience.ts
index 81cfd30d07..dd30a3f861 100644
--- a/resources/assets/js/fakeData/fakeExperience.ts
+++ b/resources/assets/js/fakeData/fakeExperience.ts
@@ -1,5 +1,3 @@
-/* eslint camelcase: "off", @typescript-eslint/camelcase: "off" */
-import dayjs from "dayjs";
import {
ExperienceWork,
ExperienceEducation,
@@ -17,8 +15,8 @@ export const fakeExperienceWork = (
organization: "ACME Labs.",
group: "Research Division",
is_active: false,
- start_date: dayjs("2010-09-01T14:47:29+00:00").toDate(),
- end_date: dayjs("2015-04-30T14:47:29+00:00").toDate(),
+ start_date: "2010-09-01",
+ end_date: "2015-04-30",
experienceable_id: 1,
experienceable_type: "applicant",
is_education_requirement: true,
@@ -40,8 +38,8 @@ export const fakeExperienceEducation = (
fr: "Terminé (titre de compétences décern)",
},
is_active: false,
- start_date: dayjs("2010-09-01T14:47:29+00:00").toDate(),
- end_date: dayjs("2015-04-30T14:47:29+00:00").toDate(),
+ start_date: "2010-09-01",
+ end_date: "2015-04-30",
thesis_title: "How do concrete structures withstand hurricane wind stress?",
has_blockcert: true,
experienceable_id: 1,
@@ -59,7 +57,7 @@ export const fakeExperienceCommunity = (
group: "SPCA Ottawa",
project: "Adoption Weekend",
is_active: true,
- start_date: dayjs("2015-04-30T14:47:29+00:00").toDate(),
+ start_date: "2015-04-30",
end_date: null,
experienceable_id: 1,
experienceable_type: "applicant",
@@ -78,7 +76,7 @@ export const fakeExperienceAward = (
issued_by: "McGill University",
award_recognition_type_id: 1,
award_recognition_type: { en: "International", fr: "Internationale" },
- awarded_date: dayjs("2015-04-30T14:47:29+00:00").toDate(),
+ awarded_date: "2015-04-30",
experienceable_id: 1,
experienceable_type: "applicant",
is_education_requirement: false,
@@ -95,7 +93,7 @@ export const fakeExperiencePersonal = (
"Massa tincidunt nunc pulvinar sapien et ligula ullamcorper malesuada proin libero nunc consequat interdum varius sit amet mattis vulputate enim nulla aliquet porttitor lacus luctus accumsan tortor posuere ac ut consequat semper viverra nam libero justo laoreet sit amet cursus sit amet dictum sit amet justo donec enim diam vulputate.",
is_shareable: true,
is_active: true,
- start_date: dayjs("2015-04-30T14:47:29+00:00").toDate(),
+ start_date: "2015-04-30",
end_date: null,
experienceable_id: 1,
experienceable_type: "applicant",
diff --git a/resources/assets/js/fakeData/fakeExperienceSkills.ts b/resources/assets/js/fakeData/fakeExperienceSkills.ts
index 83634cad8d..667b21baba 100644
--- a/resources/assets/js/fakeData/fakeExperienceSkills.ts
+++ b/resources/assets/js/fakeData/fakeExperienceSkills.ts
@@ -1,4 +1,4 @@
-/* eslint camelcase: "off", @typescript-eslint/camelcase: "off" */
+/* eslint camelcase: "off" */
import dayjs from "dayjs";
import { v4 as uuidv4 } from "uuid";
import { ExperienceSkill, Skill, Experience } from "../models/types";
diff --git a/resources/assets/js/fakeData/fakeHrAdvisor.ts b/resources/assets/js/fakeData/fakeHrAdvisor.ts
index f5f5d6cbe5..63707d73fa 100644
--- a/resources/assets/js/fakeData/fakeHrAdvisor.ts
+++ b/resources/assets/js/fakeData/fakeHrAdvisor.ts
@@ -1,4 +1,3 @@
-/* eslint-disable @typescript-eslint/camelcase */
import { HrAdvisor } from "../models/types";
export const fakeHrAdvisor = (
diff --git a/resources/assets/js/fakeData/fakeJob.ts b/resources/assets/js/fakeData/fakeJob.ts
index 086427bb95..78a7042d42 100644
--- a/resources/assets/js/fakeData/fakeJob.ts
+++ b/resources/assets/js/fakeData/fakeJob.ts
@@ -1,4 +1,3 @@
-/* eslint-disable @typescript-eslint/camelcase */
import {
Job,
Criteria,
diff --git a/resources/assets/js/fakeData/fakeManager.ts b/resources/assets/js/fakeData/fakeManager.ts
index d3bf49c590..273c255ecc 100644
--- a/resources/assets/js/fakeData/fakeManager.ts
+++ b/resources/assets/js/fakeData/fakeManager.ts
@@ -1,4 +1,3 @@
-/* eslint-disable @typescript-eslint/camelcase */
import { Manager } from "../models/types";
export const fakeManager = (
diff --git a/resources/assets/js/fakeData/fakeRatingGuideAnswer.ts b/resources/assets/js/fakeData/fakeRatingGuideAnswer.ts
index 0e5733b2a3..c076f15340 100644
--- a/resources/assets/js/fakeData/fakeRatingGuideAnswer.ts
+++ b/resources/assets/js/fakeData/fakeRatingGuideAnswer.ts
@@ -1,4 +1,3 @@
-/* eslint-disable @typescript-eslint/camelcase */
import { RatingGuideAnswer } from "../models/types";
export const fakeAnswer = (
diff --git a/resources/assets/js/fakeData/fakeRatingGuideQuestion.ts b/resources/assets/js/fakeData/fakeRatingGuideQuestion.ts
index 6449d21b16..39ca25641d 100644
--- a/resources/assets/js/fakeData/fakeRatingGuideQuestion.ts
+++ b/resources/assets/js/fakeData/fakeRatingGuideQuestion.ts
@@ -1,4 +1,3 @@
-/* eslint-disable @typescript-eslint/camelcase */
import { RatingGuideQuestion } from "../models/types";
import { AssessmentTypeId } from "../models/lookupConstants";
diff --git a/resources/assets/js/fakeData/fakeSkillCategories.ts b/resources/assets/js/fakeData/fakeSkillCategories.ts
new file mode 100644
index 0000000000..a819f1394c
--- /dev/null
+++ b/resources/assets/js/fakeData/fakeSkillCategories.ts
@@ -0,0 +1,208 @@
+import { SkillCategory } from "../models/types";
+
+export const fakeSkillCategoryParent1 = (): SkillCategory => ({
+ id: 1,
+ key: "transferable",
+ name: {
+ en: "Transferable",
+ fr: "Transférable",
+ },
+ // description: {
+ // en:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // fr:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // },
+ parent_id: null,
+ lft: 2,
+ rgt: 11,
+ depth: 1,
+});
+
+export const fakeSkillCategoryParent2 = (): SkillCategory => ({
+ id: 2,
+ key: "digital",
+ name: {
+ en: "Digital and Technology",
+ fr: "Numérique et technologie",
+ },
+ // description: {
+ // en: "On the job work skills and knowledge specific to the digital sector.",
+ // fr:
+ // "Sur le lieu de travail, les compétences et connaissances professionnelles spécifiques au secteur numérique.",
+ // },
+ parent_id: null,
+ lft: 12,
+ rgt: 21,
+ depth: 1,
+});
+
+export const fakeSkillCategoryChild1 = (): SkillCategory => ({
+ id: 3,
+ key: "working-in-government",
+ name: {
+ en: "Working In Government",
+ fr: "Travailler au gouvernement",
+ },
+ // description: {
+ // en:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // fr:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // },
+ parent_id: 1,
+ lft: 3,
+ rgt: 4,
+ depth: 2,
+});
+
+export const fakeSkillCategoryChild2 = (): SkillCategory => ({
+ id: 4,
+ key: "cognitive",
+ name: {
+ en: "Cognitive",
+ fr: "Cognitive",
+ },
+ // description: {
+ // en:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // fr:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // },
+ parent_id: 1,
+ lft: 5,
+ rgt: 6,
+ depth: 2,
+});
+
+export const fakeSkillCategoryChild3 = (): SkillCategory => ({
+ id: 5,
+ key: "communication",
+ name: {
+ en: "Communication",
+ fr: "Communication",
+ },
+ // description: {
+ // en:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // fr:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // },
+ parent_id: 1,
+ lft: 7,
+ rgt: 8,
+ depth: 2,
+});
+
+export const fakeSkillCategoryChild4 = (): SkillCategory => ({
+ id: 6,
+ key: "personal",
+ name: {
+ en: "Personal",
+ fr: "Personnel",
+ },
+ // description: {
+ // en:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // fr:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // },
+ parent_id: 1,
+ lft: 9,
+ rgt: 10,
+ depth: 2,
+});
+
+export const fakeSkillCategoryChild5 = (): SkillCategory => ({
+ id: 7,
+ key: "web",
+ name: {
+ en: "Web",
+ fr: "Web",
+ },
+ // description: {
+ // en:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // fr:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // },
+ parent_id: 2,
+ lft: 13,
+ rgt: 14,
+ depth: 2,
+});
+
+export const fakeSkillCategoryChild6 = (): SkillCategory => ({
+ id: 8,
+ key: "devops",
+ name: {
+ en: "DevOps",
+ fr: "Devops",
+ },
+ // description: {
+ // en:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // fr:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // },
+ parent_id: 2,
+ lft: 15,
+ rgt: 16,
+ depth: 2,
+});
+
+export const fakeSkillCategoryChild7 = (): SkillCategory => ({
+ id: 9,
+ key: "systems",
+ name: {
+ en: "Systems",
+ fr: "Systèmes",
+ },
+ // description: {
+ // en:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // fr:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // },
+ parent_id: 2,
+ lft: 17,
+ rgt: 18,
+ depth: 2,
+});
+
+export const fakeSkillCategoryChild8 = (): SkillCategory => ({
+ id: 10,
+ key: "analytics",
+ name: {
+ en: "Analytics",
+ fr: "Analyse des données",
+ },
+ // description: {
+ // en:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // fr:
+ // "Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint, eius! Laudantium maxime magnam temporibus perferendis.",
+ // },
+ parent_id: 2,
+ lft: 19,
+ rgt: 20,
+ depth: 2,
+});
+
+export const fakeSkillCategoriesParents = (): SkillCategory[] => [
+ fakeSkillCategoryParent1(),
+ fakeSkillCategoryParent2(),
+];
+
+export const fakeSkillCategories = (): SkillCategory[] => [
+ fakeSkillCategoryParent1(),
+ fakeSkillCategoryParent2(),
+ fakeSkillCategoryChild1(),
+ fakeSkillCategoryChild2(),
+ fakeSkillCategoryChild3(),
+ fakeSkillCategoryChild4(),
+ fakeSkillCategoryChild5(),
+ fakeSkillCategoryChild6(),
+ fakeSkillCategoryChild7(),
+ fakeSkillCategoryChild8(),
+];
diff --git a/resources/assets/js/fakeData/fakeSkills.ts b/resources/assets/js/fakeData/fakeSkills.ts
index 187daa4fb9..4cb752b409 100644
--- a/resources/assets/js/fakeData/fakeSkills.ts
+++ b/resources/assets/js/fakeData/fakeSkills.ts
@@ -1,8 +1,14 @@
-/* eslint-disable @typescript-eslint/camelcase */
import { Skill } from "../models/types";
import { SkillTypeId } from "../models/lookupConstants";
-
-// Classifications used: CS, EX
+import {
+ fakeClassification1,
+ fakeClassification2,
+} from "./fakeClassifications";
+import {
+ fakeSkillCategoryChild1,
+ fakeSkillCategoryChild4,
+ fakeSkillCategoryChild5,
+} from "./fakeSkillCategories";
export const fakeSkill = (overrides: Partial = {}): Skill => ({
id: 1,
@@ -18,7 +24,8 @@ export const fakeSkill = (overrides: Partial = {}): Skill => ({
},
is_future_skill: false,
is_culture_skill: false,
- classifications: [{ id: 1, key: "CS", name: {en: "", fr: ""} }],
+ classifications: [fakeClassification1()],
+ skill_category_ids: [fakeSkillCategoryChild5().id],
...overrides,
});
@@ -37,7 +44,8 @@ export const fakeSkill2 = (overrides: Partial = {}): Skill => ({
},
is_future_skill: false,
is_culture_skill: false,
- classifications: [{ id: 1, key: "CS", name: { en: "", fr: "" } }],
+ classifications: [fakeClassification1()],
+ skill_category_ids: [fakeSkillCategoryChild5().id],
...overrides,
});
@@ -56,7 +64,8 @@ export const fakeSkill3 = (overrides: Partial = {}): Skill => ({
},
is_future_skill: true,
is_culture_skill: false,
- classifications: [{ id: 1, key: "CS", name: { en: "", fr: "" } }],
+ classifications: [fakeClassification1()],
+ skill_category_ids: [fakeSkillCategoryChild5().id],
...overrides,
});
@@ -75,15 +84,15 @@ export const fakeSkill4 = (overrides: Partial = {}): Skill => ({
},
is_future_skill: false,
is_culture_skill: true,
- classifications: [
- {
- id: 1, key: "CS", name: { en: "", fr: "" }
- },
+ classifications: [fakeClassification1()],
+ skill_category_ids: [
+ fakeSkillCategoryChild1().id,
+ fakeSkillCategoryChild4().id,
],
...overrides,
});
-export const fakeSkill5 = (): Skill => ({
+export const fakeSkill5 = (overrides: Partial = {}): Skill => ({
id: 13,
skill_type_id: SkillTypeId.Hard,
name: { en: "HTML", fr: "HTML" },
@@ -95,14 +104,15 @@ export const fakeSkill5 = (): Skill => ({
},
is_future_skill: false,
is_culture_skill: true,
- classifications: [
- {
- id: 1, key: "CS", name: { en: "", fr: "" }
- },
+ classifications: [fakeClassification1()],
+ skill_category_ids: [
+ fakeSkillCategoryChild4().id,
+ fakeSkillCategoryChild5().id,
],
+ ...overrides,
});
-export const fakeSkill6 = (): Skill => ({
+export const fakeSkill6 = (overrides: Partial = {}): Skill => ({
id: 25,
skill_type_id: SkillTypeId.Soft,
name: { en: "Flexibility", fr: "Flexibilité" },
@@ -114,11 +124,9 @@ export const fakeSkill6 = (): Skill => ({
},
is_future_skill: false,
is_culture_skill: true,
- classifications: [
- {
- id: 2, key: "EX", name: { en: "", fr: "" }
- },
- ],
+ classifications: [fakeClassification2()],
+ skill_category_ids: [],
+ ...overrides,
});
export const fakeSkills = (): Skill[] => [
diff --git a/resources/assets/js/fakeData/fakeUsers.ts b/resources/assets/js/fakeData/fakeUsers.ts
index 62dff5b047..2e68b95f30 100644
--- a/resources/assets/js/fakeData/fakeUsers.ts
+++ b/resources/assets/js/fakeData/fakeUsers.ts
@@ -1,4 +1,4 @@
-/* eslint-disable @typescript-eslint/camelcase, @typescript-eslint/no-non-null-assertion */
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { User } from "../models/types";
import { find } from "../helpers/queries";
diff --git a/resources/assets/js/helpers/components.ts b/resources/assets/js/helpers/components.ts
new file mode 100644
index 0000000000..11594d27b8
--- /dev/null
+++ b/resources/assets/js/helpers/components.ts
@@ -0,0 +1,10 @@
+/**
+ * Removes whitespace and returns the first 6 characters of a string for a consistent key
+ * where ID's are not available.
+ *
+ * @param string
+ */
+const keyFromString = (string: string): string =>
+ string.replace(/\s/g, "").slice(0, 6);
+
+export default keyFromString;
diff --git a/resources/assets/js/helpers/dates.ts b/resources/assets/js/helpers/dates.ts
index 1ddbc290c8..c33234fe6a 100644
--- a/resources/assets/js/helpers/dates.ts
+++ b/resources/assets/js/helpers/dates.ts
@@ -4,6 +4,7 @@ import relativeTime from "dayjs/plugin/relativeTime";
import utc from "dayjs/plugin/utc";
import "dayjs/locale/fr";
import { Locales } from "./localize";
+import { DateString } from "../models/types";
// dayjs() relativeTime API plugin configuration https://day.js.org/docs/en/display/from-now.
const relativeTimeConfig = {
@@ -35,6 +36,16 @@ export const readableDate = (locale: Locales, date: Date): string => {
return dayjs(date).locale(locale).format("LL");
};
+export const readableDateFromString = (
+ locale: Locales,
+ date: DateString | null,
+): string => {
+ if (date !== null) {
+ return dayjs(date).locale(locale).format("LL");
+ }
+ return "";
+};
+
export const readableTimeFromNow = (locale: Locales, date: Date): string => {
return dayjs(date).utc().locale(locale).fromNow();
};
@@ -46,3 +57,7 @@ export const toInputDateString = (date: Date): string => {
export const fromInputDateString = (dateStr: string): Date => {
return dayjs(dateStr).toDate();
};
+
+export const newDateString = (): string => {
+ return dayjs().format("YYYY-MM-DD");
+};
diff --git a/resources/assets/js/helpers/deepEquals.test.ts b/resources/assets/js/helpers/deepEquals.test.ts
new file mode 100644
index 0000000000..ff23ab8dba
--- /dev/null
+++ b/resources/assets/js/helpers/deepEquals.test.ts
@@ -0,0 +1,49 @@
+import dayjs from "dayjs";
+import { fakeExperienceAward } from "../fakeData/fakeExperience";
+import { fromInputDateString, readableDateTime } from "./dates";
+import deepEquals from "./deepEquals";
+
+describe("deepEquals", (): void => {
+ it("Empty arrays, objects, and strings are equal", (): void => {
+ expect(deepEquals([], [])).toEqual(true);
+ expect(deepEquals({}, {})).toEqual(true);
+ expect(deepEquals("", "")).toEqual(true);
+ });
+ it("works on simple arrays", (): void => {
+ const a = [1, 2, "three", false];
+ const b = [1, 2, "three", false];
+ const c = [1, 2, "four", false];
+ const d = [1, 2, false, "three"];
+ expect(deepEquals(a, b)).toEqual(true);
+ expect(deepEquals(a, c)).toEqual(false);
+ expect(deepEquals(a, d)).toEqual(false);
+ });
+ it("works on nested arrays", (): void => {
+ const a = [1, 2, "three", [4, 5]];
+ const b = [1, 2, "three", [4, 5]];
+ const c = [1, 2, "three", [5, 4]];
+ expect(deepEquals(a, b)).toEqual(true);
+ expect(deepEquals(a, c)).toEqual(false);
+ });
+ it("works on simple objects", (): void => {
+ const a = { id: 1, name: "Talent Cloud" };
+ const b = { name: "Talent Cloud", id: 1 }; // Order shouldn't matter for object attributes
+ const c = { id: 1, name: "Talent Cloud", extra: true };
+ expect(deepEquals(a, b)).toEqual(true);
+ expect(deepEquals(a, c)).toEqual(false);
+ });
+ it("works on dates", (): void => {
+ const a = fromInputDateString("2020-04-20");
+ const b = fromInputDateString("2020-04-20");
+ const c = fromInputDateString("2020-04-21");
+ expect(deepEquals(a, b)).toEqual(true);
+ expect(deepEquals(a, c)).toEqual(false);
+ });
+ it("works on experience objects", (): void => {
+ const a = fakeExperienceAward();
+ const b = fakeExperienceAward();
+ const c = fakeExperienceAward({ title: "Title has changed" });
+ expect(deepEquals(a, b)).toEqual(true);
+ expect(deepEquals(a, c)).toEqual(false);
+ });
+});
diff --git a/resources/assets/js/helpers/deepEquals.ts b/resources/assets/js/helpers/deepEquals.ts
new file mode 100644
index 0000000000..41d0980ac3
--- /dev/null
+++ b/resources/assets/js/helpers/deepEquals.ts
@@ -0,0 +1,40 @@
+import isEqualWith from "lodash/isEqualWith";
+import isEqual from "lodash/isEqual";
+
+export function deepEquals(object: any, other: any): boolean {
+ return isEqual(object, other);
+}
+
+/**
+ * Logs to console any values which are not equal.
+ * DO NOT USE IN PRODUCTION.
+ */
+function deepEqualsDebug(object: any, other: any): boolean {
+ const debugCompare = (a, b, key) => {
+ const equality = isEqual(a, b);
+ if (!equality) {
+ console.debug(`The values at key/index ${key} were not equal:`, a, b);
+ }
+ return equality;
+ };
+ if (
+ typeof object === "object" &&
+ typeof other === "object" &&
+ object !== null &&
+ other !== null
+ ) {
+ return (
+ debugCompare(
+ Object.keys(object).length,
+ Object.keys(other).length,
+ "length",
+ ) &&
+ Object.keys(object).every((key) =>
+ debugCompare(object[key], other[key], key),
+ )
+ );
+ }
+ return isEqualWith(object, other, debugCompare);
+}
+
+export default deepEquals;
diff --git a/resources/assets/js/helpers/httpRequests.ts b/resources/assets/js/helpers/httpRequests.ts
new file mode 100644
index 0000000000..34e46d2718
--- /dev/null
+++ b/resources/assets/js/helpers/httpRequests.ts
@@ -0,0 +1,77 @@
+import dayjs from "dayjs";
+import { Json } from "../hooks/webResourceHooks/types";
+
+type HttpVerb = "GET" | "POST" | "PUT" | "DELETE";
+
+const csrfElement = document.head.querySelector('meta[name="csrf-token"]');
+const csrfToken: string =
+ csrfElement && csrfElement.getAttribute("content")
+ ? (csrfElement.getAttribute("content") as string)
+ : "";
+
+function jsonDateReplacer(key, value): string | any {
+ if (this[key] instanceof Date) {
+ return dayjs(value).format("YYYY-MM-DDTHH:mm:ssZ");
+ }
+ return value;
+}
+
+export function fetchParameters(method: HttpVerb, body?: any): RequestInit {
+ const basicHeaders = {
+ "X-CSRF-TOKEN": csrfToken,
+ Accept: "application/json",
+ };
+ const jsonBodyHeader = { "Content-Type": "application/json" }; // informs server that the body is a json encoded string
+ const headers =
+ body === undefined ? basicHeaders : { ...basicHeaders, ...jsonBodyHeader };
+ // We must stringify any object bodies, and ensure dates are formatted as server expects.
+ const stringBody =
+ body instanceof Object ? JSON.stringify(body, jsonDateReplacer) : body;
+ return {
+ method,
+ headers,
+ credentials: "same-origin", // NOTE: This may change if we move to token auth.
+ ...(stringBody && { body: stringBody }),
+ };
+}
+
+function defaultFetch(
+ endpoint: string,
+ method: HttpVerb,
+ body?: any,
+): Promise {
+ return fetch(endpoint, fetchParameters(method, body));
+}
+
+export function getRequest(endpoint: string): Promise {
+ return defaultFetch(endpoint, "GET");
+}
+
+export function postRequest(endpoint: string, body: any): Promise {
+ return defaultFetch(endpoint, "POST", body);
+}
+
+export function putRequest(endpoint: string, body: any): Promise {
+ return defaultFetch(endpoint, "PUT", body);
+}
+
+export function deleteRequest(endpoint: string): Promise {
+ return defaultFetch(endpoint, "DELETE");
+}
+
+export class FetchError extends Error {
+ constructor(public response: Response) {
+ super(`${response.status} ${response.statusText}`);
+ /* istanbul ignore next */
+ if (Object.setPrototypeOf) {
+ // Not available in IE 10, but can be polyfilled
+ Object.setPrototypeOf(this, FetchError.prototype);
+ }
+ }
+}
+export async function processJsonResponse(response: Response): Promise {
+ if (!response.ok) {
+ throw new FetchError(response);
+ }
+ return response.json();
+}
diff --git a/resources/assets/js/helpers/localize.test.ts b/resources/assets/js/helpers/localize.test.ts
index 3fe8ea198d..845b8ede2e 100644
--- a/resources/assets/js/helpers/localize.test.ts
+++ b/resources/assets/js/helpers/localize.test.ts
@@ -22,8 +22,8 @@ describe("localize", (): void => {
en: "New",
fr: "Nouvelle",
},
- }
- expect(localizeField('fr', newObj, 'title')).toEqual(newObj.title.fr);
+ };
+ expect(localizeField("fr", newObj, "title")).toEqual(newObj.title.fr);
});
it("works on an object with more languages", (): void => {
const newObj = {
@@ -31,10 +31,10 @@ describe("localize", (): void => {
title: {
en: "New",
fr: "Nouvelle",
- es: "Nueva"
+ es: "Nueva",
},
- }
- expect(localizeField('fr', newObj, 'title')).toEqual(newObj.title.fr);
+ };
+ expect(localizeField("fr", newObj, "title")).toEqual(newObj.title.fr);
});
});
});
diff --git a/resources/assets/js/helpers/localize.ts b/resources/assets/js/helpers/localize.ts
index f77cf0aae9..32b466bc10 100644
--- a/resources/assets/js/helpers/localize.ts
+++ b/resources/assets/js/helpers/localize.ts
@@ -50,3 +50,18 @@ export function matchValueToModel(
);
return matching.length > 0 ? matching[0] : null;
}
+
+export function matchStringsCaseDiacriticInsensitive(
+ needle: string,
+ haystack: string[],
+): string[] {
+ return haystack.filter((name) => {
+ return (
+ name
+ .normalize("NFD") // Normalizing to NFD Unicode normal form decomposes combined graphemes into the combination of simple ones.
+ .replace(/[\u0300-\u036f]/g, "") // Using a regex character class to match the U+0300 → U+036F range, it is now trivial to globally get rid of the diacritics, which the Unicode standard conveniently groups as the Combining Diacritical Marks Unicode block.
+ .search(new RegExp(needle, "i")) !== -1 ||
+ name.search(new RegExp(needle, "i")) !== -1
+ );
+ });
+}
diff --git a/resources/assets/js/helpers/queries.ts b/resources/assets/js/helpers/queries.ts
index 3a11788f02..b85bbeed17 100644
--- a/resources/assets/js/helpers/queries.ts
+++ b/resources/assets/js/helpers/queries.ts
@@ -110,9 +110,9 @@ export function mapObjectValues(
);
}
-interface IndexedObject {
+type IndexedObject = {
[key: string]: T;
-}
+};
/**
* Maps an array of items into an object, with each transformed into an attribute
@@ -156,6 +156,13 @@ export function hasKey(
return object[key] !== undefined;
}
+export function toIdMap(arr: T[]): Map {
+ return arr.reduce((map, x) => {
+ map.set(x.id, x);
+ return map;
+ }, new Map());
+}
+
/**
* Returns the value at the specified key. If the key is not present, throws an error.
* @param object
@@ -188,11 +195,11 @@ export function deleteProperty(
*/
export function filterObjectProps(
obj: IndexedObject,
- filter: (value: T) => boolean,
+ filter: (value: T, key: string) => boolean,
): IndexedObject {
return Object.entries(obj).reduce(
(newObj: IndexedObject, [key, value]): IndexedObject => {
- if (filter(value)) {
+ if (filter(value, key)) {
newObj[key] = value;
}
return newObj;
@@ -227,3 +234,24 @@ export function removeDuplicatesById(
};
return items.reduce(reducer, { contents: [], ids: [] }).contents;
}
+
+/*
+ * Decrement the number if it above zero, else return 0.
+ * This helps to avoid some pathological edge cases where pendingCount becomes permanently bugged.
+ * @param num
+ */
+export function decrement(num: number): number {
+ return num <= 0 ? 0 : num - 1;
+}
+
+/**
+ * If the element already exists in the array, then remove it, and return array.
+ * Otherwise, if the element does not exist in the array, add it to the array, and return new array.
+ * @param element
+ * @param array
+ */
+export function addOrRemove(element: T, array: T[]): T[] {
+ return array.includes(element)
+ ? array.filter((T) => T !== element)
+ : [...array, element];
+}
diff --git a/resources/assets/js/helpers/routes.ts b/resources/assets/js/helpers/routes.ts
index 046e9b6b6b..77b301a680 100644
--- a/resources/assets/js/helpers/routes.ts
+++ b/resources/assets/js/helpers/routes.ts
@@ -1,4 +1,5 @@
import { Locales } from "./localize";
+import { Portal } from "../models/app";
/* eslint-disable no-useless-escape */
function stripTrailingSlash(str: string): string {
@@ -298,3 +299,48 @@ export function slugify(string: string): string {
.replace(/^-+/, "") // Trim - from start of text
.replace(/-+$/, ""); // Trim - from end of text
}
+
+export const getApplicantUrl = (
+ locale: Locales,
+ portal: Portal,
+ applicantId: number,
+ jobId: number,
+): string => {
+ const applicantUrlMap: { [key in typeof portal]: string } = {
+ hr: hrApplicantShow(locale, applicantId, jobId),
+ manager: managerApplicantShow(locale, applicantId, jobId),
+ };
+
+ return applicantUrlMap[portal];
+};
+
+export const getApplicationUrl = (
+ locale: Locales,
+ portal: Portal,
+ applicationId: number,
+ jobId: number,
+): string => {
+ const applicationUrlMap: { [key in typeof portal]: string } = {
+ hr: hrApplicationShow(locale, applicationId, jobId),
+ manager: managerApplicationShow(locale, applicationId, jobId),
+ };
+
+ return applicationUrlMap[portal];
+};
+
+const baseApplicantProfileUrl = (
+ locale: Locales,
+ applicantId: number,
+): string => {
+ return `${baseUrl()}/${locale}/profile/${applicantId}`;
+};
+
+export const getApplicantExperienceUrl = (
+ locale: Locales,
+ applicantId: number,
+): string => `${baseApplicantProfileUrl(locale, applicantId)}/experience`;
+
+export const getApplicantSkillsUrl = (
+ locale: Locales,
+ applicantId: number,
+): string => `${baseApplicantProfileUrl(locale, applicantId)}/skills`;
diff --git a/resources/assets/js/helpers/sorting.ts b/resources/assets/js/helpers/sorting.ts
new file mode 100644
index 0000000000..9afc61cfe1
--- /dev/null
+++ b/resources/assets/js/helpers/sorting.ts
@@ -0,0 +1,57 @@
+import { localizedField, localizedFieldNonNull } from "../models/app";
+import { Locales, localizeFieldNonNull, localizeField } from "./localize";
+
+/**
+ * Wrapper function to be passed to Array.sort() for objects with
+ * a "name" property of type localizedField or localizedFieldNonNull,
+ * sorting alphabetically.
+ *
+ * @param locale
+ */
+// eslint-disable-next-line import/prefer-default-export
+export function sortByLocalizedName(locale: Locales) {
+ return (
+ first: { name: localizedField | localizedFieldNonNull },
+ second: { name: localizedField | localizedFieldNonNull },
+ ): number => {
+ let firstName: string | null | undefined;
+ let secondName: string | null | undefined;
+
+ if (first.name.en !== null && first.name.fr !== null) {
+ firstName = localizeFieldNonNull(
+ locale,
+ first as { name: localizedFieldNonNull },
+ "name",
+ ).toLocaleUpperCase();
+ } else {
+ firstName = localizeField(locale, first, "name")?.toLocaleUpperCase();
+ }
+
+ if (second.name.en !== null && second.name.fr !== null) {
+ secondName = localizeFieldNonNull(
+ locale,
+ second as { name: localizedFieldNonNull },
+ "name",
+ ).toLocaleUpperCase();
+ } else {
+ secondName = localizeField(locale, second, "name")?.toLocaleUpperCase();
+ }
+
+ if (
+ firstName !== null &&
+ firstName !== undefined &&
+ secondName !== null &&
+ secondName !== undefined
+ ) {
+ if (firstName < secondName) {
+ return -1;
+ }
+
+ if (firstName > secondName) {
+ return 1;
+ }
+ }
+
+ return 0;
+ };
+}
diff --git a/resources/assets/js/hooks/apiResourceHooks.tsx b/resources/assets/js/hooks/apiResourceHooks.tsx
new file mode 100644
index 0000000000..be7a0ba526
--- /dev/null
+++ b/resources/assets/js/hooks/apiResourceHooks.tsx
@@ -0,0 +1,119 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/* eslint-disable camelcase */
+/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
+import { useContext } from "react";
+import { getApplicantSkillsEndpoint } from "../api/applicantSkills";
+import {
+ getApplicantExperienceEndpoint,
+ getApplicantExperienceSkillsEndpoint,
+ getCreateExperienceEndpoint,
+ getExperienceEndpoint,
+ getExperienceSkillEndpoint,
+ parseSingleExperience,
+} from "../api/experience";
+import { getSkillCategoriesEndpoint, getSkillsEndpoint } from "../api/skill";
+import {
+ Experience,
+ ExperienceSkill,
+ Skill,
+ SkillCategory,
+} from "../models/types";
+import { useResource, useResourceIndex } from "./webResourceHooks";
+import { ErrorContext } from "../components/ErrorContainer";
+import { FetchError } from "../helpers/httpRequests";
+
+export const useSkills = () => {
+ // The skills endpoint doesn't allow updates, so don't return that function.
+ const { update, ...resource } = useResource(getSkillsEndpoint(), []);
+ return resource;
+};
+
+export const useSkillCategories = () => {
+ // The SkillCategories endpoint doesn't allow updates, so don't return that function.
+ const { update, ...resource } = useResource(
+ getSkillCategoriesEndpoint(),
+ [],
+ );
+ return resource;
+};
+
+/**
+ * If the error is a FetchError and the response body contains a message field, use that to construct the message.
+ * Otherwise, simply use the error object's message.
+ * @param error
+ */
+const errorToMessage = async (error: Error | FetchError): Promise => {
+ if (error instanceof FetchError && error.response.status) {
+ try {
+ const responseBody = await error.response.json();
+ if (responseBody.message) {
+ return `${error.response.status} - ${responseBody.message}`;
+ }
+ } catch (e) {
+ // Can't parse response json body; fall through and return normal error message.
+ }
+ }
+ return error.message;
+};
+
+/**
+ * This hook returns a handleError function which tries to push the error into the ErrorContext queue.
+ * If no ErrorContext Provider exists in the component hierarchy, simply nothing will happen.
+ */
+const useErrorHandler = () => {
+ const { dispatch } = useContext(ErrorContext);
+ const handleError = (error: Error | FetchError): void => {
+ errorToMessage(error).then((message) =>
+ dispatch({
+ type: "push",
+ payload: message,
+ }),
+ );
+ };
+ return handleError;
+};
+
+export const useApplicantSkillIds = (applicantId: number) => {
+ const handleError = useErrorHandler();
+ return useResource<{ skill_ids: number[] }>(
+ getApplicantSkillsEndpoint(applicantId),
+ {
+ skill_ids: [],
+ },
+ {
+ handleError,
+ },
+ );
+};
+
+export const useApplicantExperience = (applicantId: number) => {
+ const handleError = useErrorHandler();
+ return useResourceIndex(
+ getApplicantExperienceEndpoint(applicantId),
+ {
+ parseEntityResponse: (response) =>
+ parseSingleExperience(response).experience,
+ resolveEntityEndpoint: (_, entity) =>
+ getExperienceEndpoint(entity.id, entity.type),
+ resolveCreateEndpoint: (_, entity) =>
+ getCreateExperienceEndpoint(applicantId, entity.type),
+ // Need a custom keyFn because different types of experience may have same id,
+ // meaning default keyFn (getId) may cause collisions in the map of items and they may overwriting each other.
+ keyFn: (experience) => `${experience.type}-${experience.id}`,
+ handleError,
+ },
+ );
+};
+
+export const useApplicantExperienceSkills = (applicantId: number) => {
+ const handleError = useErrorHandler();
+ return useResourceIndex(
+ getApplicantExperienceSkillsEndpoint(applicantId),
+ {
+ resolveEntityEndpoint: (_, entity) =>
+ getExperienceSkillEndpoint(entity.id),
+ resolveCreateEndpoint: (_, entity) => getExperienceSkillEndpoint(null),
+ handleError,
+ },
+ );
+};
diff --git a/resources/assets/js/hooks/applicationHooks.tsx b/resources/assets/js/hooks/applicationHooks.tsx
index 59228c69be..37dfa90e9a 100644
--- a/resources/assets/js/hooks/applicationHooks.tsx
+++ b/resources/assets/js/hooks/applicationHooks.tsx
@@ -34,10 +34,13 @@ import {
getJobApplicationAnswers,
getJobApplicationSteps,
getStepsAreUpdating,
+ getApplicationsByJob,
+ isFetchingApplications,
} from "../store/Application/applicationSelector";
import {
fetchApplication,
touchApplicationStep,
+ fetchApplicationsForJob,
} from "../store/Application/applicationActions";
import {
getCriteriaByJob,
@@ -348,7 +351,29 @@ export function useFetchApplication(
}
/**
- * Return an Job from the redux store, and fetch it from backend if it is not yet in the store.
+ * Return an array of Applications related to a given Job ID from the redux store.
+ * Fetch them from the backend if they are not yet in the store.
+ * @param jobId
+ * @param dispatch
+ */
+export function useFetchApplicationsByJob(
+ jobId: number,
+ dispatch: DispatchType,
+): Application[] | null {
+ const applications = useSelector((state: RootState) =>
+ getApplicationsByJob(state, { jobId }),
+ );
+ const applicationsAreFetching = useSelector(isFetchingApplications);
+ useEffect(() => {
+ if (applications.length === 0 && !applicationsAreFetching) {
+ dispatch(fetchApplicationsForJob(jobId));
+ }
+ }, [applications, jobId, applicationsAreFetching, dispatch]);
+ return applications;
+}
+
+/**
+ * Return a Job from the redux store, and fetch it from backend if it is not yet in the store.
* @param jobId
* @param dispatch
*/
@@ -370,7 +395,7 @@ export function useFetchJob(
}
/**
- * Return all Experience relavant to an Application from the redux store, and fetch it from backend if it is not yet in the store.
+ * Return all Experience relevant to an Application from the redux store, and fetch it from backend if it is not yet in the store.
* @param applicationId
* @param application
* @param dispatch
diff --git a/resources/assets/js/hooks/classificationHooks.ts b/resources/assets/js/hooks/classificationHooks.ts
new file mode 100644
index 0000000000..24c4fed7cf
--- /dev/null
+++ b/resources/assets/js/hooks/classificationHooks.ts
@@ -0,0 +1,28 @@
+import { useEffect } from "react";
+import { useSelector } from "react-redux";
+import { DispatchType } from "../configureStore";
+import { Classification } from "../models/types";
+import {
+ getClassifications,
+ classificationsIsLoading,
+} from "../store/Classification/classificationSelector";
+import { loadClassificationsIntoState } from "../store/Classification/classificationActions";
+
+// eslint-disable-next-line import/prefer-default-export
+export function useLoadClassifications(
+ dispatch: DispatchType,
+): {
+ classifications: Classification[];
+ isLoadingClassifications: boolean;
+} {
+ const classifications = useSelector(getClassifications);
+ const isLoading = useSelector(classificationsIsLoading);
+
+ useEffect((): void => {
+ if (classifications.length === 0 && !isLoading) {
+ dispatch(loadClassificationsIntoState());
+ }
+ }, [classifications.length, isLoading, dispatch]);
+
+ return { classifications, isLoadingClassifications: isLoading };
+}
diff --git a/resources/assets/js/hooks/jobBuilderHooks.ts b/resources/assets/js/hooks/jobBuilderHooks.ts
index 3f4941b1bd..7b69ec0c34 100644
--- a/resources/assets/js/hooks/jobBuilderHooks.ts
+++ b/resources/assets/js/hooks/jobBuilderHooks.ts
@@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { DispatchType } from "../configureStore";
import {
- Classification,
Criteria,
Department,
Job,
@@ -30,8 +29,6 @@ import {
} from "../store/Job/jobActions";
import { getSkills, getSkillsUpdating } from "../store/Skill/skillSelector";
import { fetchSkills } from "../store/Skill/skillActions";
-import { getClassifications, classificationsIsLoading } from "../store/Classification/classificationSelector";
-import { loadClassificationsIntoState } from "../store/Classification/classificationActions";
export function useLoadJob(
jobId: number | null,
@@ -134,25 +131,6 @@ export function useLoadDepartments(
return { departments, isLoadingDepartments: isLoading };
}
-export function useLoadClassifications(
- dispatch: DispatchType,
-) : {
- classifications: Classification[];
- isLoadingClassifications: boolean;
-} {
-
- const classifications = useSelector(getClassifications);
- const isLoading = useSelector(classificationsIsLoading);
-
- useEffect((): void => {
- if (classifications.length === 0 && !isLoading) {
- dispatch(loadClassificationsIntoState());
- }
- }, [classifications.length, isLoading, dispatch]);
-
- return { classifications, isLoadingClassifications: isLoading };
-}
-
export function useLoadSkills(
dispatch: DispatchType,
): {
diff --git a/resources/assets/js/hooks/useDependenciesDebugger.ts b/resources/assets/js/hooks/useDependenciesDebugger.ts
index 22ba1e39d2..8316ce3a34 100644
--- a/resources/assets/js/hooks/useDependenciesDebugger.ts
+++ b/resources/assets/js/hooks/useDependenciesDebugger.ts
@@ -1,7 +1,7 @@
import { useRef, useMemo } from "react";
const compareInputs = (inputKeys, oldInputs, newInputs) => {
- inputKeys.forEach(key => {
+ inputKeys.forEach((key) => {
const oldInput = oldInputs[key];
const newInput = newInputs[key];
if (oldInput !== newInput) {
@@ -10,7 +10,7 @@ const compareInputs = (inputKeys, oldInputs, newInputs) => {
});
};
-const useDependenciesDebugger = inputs => {
+const useDependenciesDebugger = (inputs) => {
const oldInputsRef = useRef(inputs);
const inputValuesArray = Object.values(inputs);
const inputKeysArray = Object.keys(inputs);
diff --git a/resources/assets/js/hooks/webResourceHooks/index.ts b/resources/assets/js/hooks/webResourceHooks/index.ts
new file mode 100644
index 0000000000..8ef57461c2
--- /dev/null
+++ b/resources/assets/js/hooks/webResourceHooks/index.ts
@@ -0,0 +1,2 @@
+export { useResource } from "./singleResourceHook";
+export { useResourceIndex } from "./indexResourceHook";
diff --git a/resources/assets/js/hooks/webResourceHooks/indexCrudReducer.test.ts b/resources/assets/js/hooks/webResourceHooks/indexCrudReducer.test.ts
new file mode 100644
index 0000000000..3da2b0f59c
--- /dev/null
+++ b/resources/assets/js/hooks/webResourceHooks/indexCrudReducer.test.ts
@@ -0,0 +1,823 @@
+import indexCrudReducer, {
+ ActionTypes,
+ CreateFulfillAction,
+ CreateRejectAction,
+ CreateStartAction,
+ DeleteFulfillAction,
+ DeleteRejectAction,
+ DeleteStartAction,
+ IndexFulfillAction,
+ IndexRejectAction,
+ IndexStartAction,
+ initializeState,
+ ResourceState,
+ UpdateFulfillAction,
+ UpdateRejectAction,
+ UpdateStartAction,
+} from "./indexCrudReducer";
+
+interface TestResource {
+ id: number;
+ name: string;
+}
+function addKey(item: T): { item: T; key: string } {
+ return {
+ item,
+ key: String(item.id),
+ };
+}
+
+describe("indexCrudReducer tests", (): void => {
+ describe("Test INDEX actions", () => {
+ it("updates indexMeta in response to INDEX START", () => {
+ const initialState = initializeState([]);
+ const expectState = {
+ ...initialState,
+ indexMeta: {
+ status: "pending",
+ pendingCount: 1,
+ error: undefined,
+ },
+ };
+ const action: IndexStartAction = { type: ActionTypes.IndexStart };
+ expect(indexCrudReducer(initialState, action)).toEqual(expectState);
+ });
+ it("increments index pendingCount twice for two INDEX START actions", () => {
+ const initialState = initializeState([]);
+ const expectState = {
+ ...initialState,
+ indexMeta: {
+ status: "pending",
+ pendingCount: 2,
+ error: undefined,
+ },
+ };
+ const action: IndexStartAction = { type: ActionTypes.IndexStart };
+ const state1 = indexCrudReducer(initialState, action);
+ const state2 = indexCrudReducer(state1, action);
+ expect(state2).toEqual(expectState);
+ });
+ it("decrements pending count, updates status, and saves values when INDEX FULFILLED", () => {
+ const initialState: ResourceState = {
+ ...initializeState([]),
+ indexMeta: {
+ status: "pending",
+ pendingCount: 1,
+ error: undefined,
+ },
+ };
+ const action: IndexFulfillAction = {
+ type: ActionTypes.IndexFulfill,
+ payload: [
+ { id: 1, name: "one" },
+ { id: 2, name: "two" },
+ ].map(addKey),
+ };
+ const expectState = {
+ ...initialState,
+ indexMeta: {
+ status: "fulfilled",
+ pendingCount: 0,
+ error: undefined,
+ },
+ values: {
+ 1: {
+ value: { id: 1, name: "one" },
+ status: "fulfilled",
+ pendingCount: 0,
+ error: undefined,
+ },
+ 2: {
+ value: { id: 2, name: "two" },
+ status: "fulfilled",
+ pendingCount: 0,
+ error: undefined,
+ },
+ },
+ };
+ expect(indexCrudReducer(initialState, action)).toEqual(expectState);
+ });
+ it("INDEX FULFILLED overwrites item values and errors, but not pendingCount (and status if pending), if they already exist", () => {
+ const initialState: ResourceState = {
+ ...initializeState([]),
+ indexMeta: {
+ status: "pending",
+ pendingCount: 1,
+ error: new Error(),
+ },
+ values: {
+ 1: {
+ value: { id: 1, name: "one" },
+ status: "pending",
+ pendingCount: 2,
+ error: undefined,
+ },
+ 2: {
+ value: { id: 2, name: "two" },
+ status: "pending",
+ pendingCount: 1,
+ error: new Error("Something went wrong with a pretend request."),
+ },
+ 3: {
+ value: { id: 3, name: "three" },
+ status: "rejected",
+ pendingCount: 0,
+ error: new Error("Something went wrong with a pretend request."),
+ },
+ 4: {
+ value: { id: 4, name: "four" },
+ status: "initial",
+ pendingCount: 0,
+ error: undefined,
+ },
+ },
+ };
+ const action: IndexFulfillAction = {
+ type: ActionTypes.IndexFulfill,
+ payload: [
+ { id: 1, name: "new one" },
+ { id: 2, name: "new two" },
+ { id: 3, name: "new three" },
+ { id: 4, name: "new four" },
+ ].map(addKey),
+ };
+ const expectState = {
+ ...initialState,
+ indexMeta: {
+ status: "fulfilled",
+ pendingCount: 0,
+ error: undefined,
+ },
+ values: {
+ 1: {
+ ...initialState.values[1],
+ value: { id: 1, name: "new one" },
+ },
+ 2: {
+ ...initialState.values[2],
+ value: { id: 2, name: "new two" },
+ error: undefined, // Overwrites error, but not pendingCount and pending status
+ },
+ 3: {
+ value: { id: 3, name: "new three" },
+ status: "fulfilled", // Rejected status replaced with fulfilled
+ pendingCount: 0,
+ error: undefined,
+ },
+ 4: {
+ value: { id: 4, name: "new four" },
+ status: "fulfilled", // Initial status replaced with fulfilled
+ pendingCount: 0,
+ error: undefined,
+ },
+ },
+ };
+ expect(indexCrudReducer(initialState, action)).toEqual(expectState);
+ });
+ it("INDEX FULFILLED deletes values not included in payload", () => {
+ const initialState: ResourceState = {
+ ...initializeState(
+ [
+ { id: 1, name: "one" },
+ { id: 2, name: "two" },
+ ].map(addKey),
+ ),
+ indexMeta: {
+ status: "pending",
+ pendingCount: 1,
+ error: undefined,
+ },
+ };
+ const action: IndexFulfillAction = {
+ type: ActionTypes.IndexFulfill,
+ payload: [{ id: 2, name: "new two" }].map(addKey),
+ };
+ const expectState = {
+ ...initialState,
+ indexMeta: {
+ status: "fulfilled",
+ pendingCount: 0,
+ error: undefined,
+ },
+ values: {
+ 2: {
+ ...initialState.values[2],
+ value: { id: 2, name: "new two" },
+ status: "fulfilled",
+ },
+ },
+ };
+ expect(indexCrudReducer(initialState, action)).toEqual(expectState);
+ });
+ it("INDEX REJECTED decrements pendingCount, sets status to rejected, and doesn't modify values", () => {
+ const initialState: ResourceState = {
+ ...initializeState([{ id: 1, name: "one" }].map(addKey)),
+ indexMeta: {
+ status: "pending",
+ pendingCount: 1,
+ error: undefined,
+ },
+ };
+ const action: IndexRejectAction = {
+ type: ActionTypes.IndexReject,
+ payload: new Error("Something went wrong with a fake request"),
+ };
+ const expectState = {
+ ...initialState,
+ indexMeta: {
+ status: "rejected",
+ pendingCount: 0,
+ error: action.payload,
+ },
+ // Values are unchanged
+ };
+ expect(indexCrudReducer(initialState, action)).toEqual(expectState);
+ });
+ it("status remains 'pending' after INDEX FULFILLED and INDEX REJECTED if pendingCount was higher than 1", () => {
+ const initialState: ResourceState = {
+ ...initializeState([]),
+ indexMeta: {
+ status: "pending",
+ pendingCount: 2,
+ error: undefined,
+ },
+ };
+ const fulfilledAction: IndexFulfillAction = {
+ type: ActionTypes.IndexFulfill,
+ payload: [],
+ };
+ const fulfilledState = {
+ ...initialState,
+ indexMeta: {
+ status: "pending",
+ pendingCount: 1,
+ error: undefined,
+ },
+ };
+ expect(indexCrudReducer(initialState, fulfilledAction)).toEqual(
+ fulfilledState,
+ );
+ const rejectedAction: IndexRejectAction = {
+ type: ActionTypes.IndexReject,
+ payload: new Error(),
+ };
+ const rejectedState = {
+ ...initialState,
+ indexMeta: {
+ status: "pending",
+ pendingCount: 1,
+ error: rejectedAction.payload,
+ },
+ };
+ expect(indexCrudReducer(initialState, rejectedAction)).toEqual(
+ rejectedState,
+ );
+ });
+ });
+ describe("Test CREATE actions", (): void => {
+ it("CREATE START updates createMeta", () => {
+ const initialState: ResourceState = initializeState([]);
+ const expectState = {
+ ...initialState,
+ createMeta: {
+ status: "pending",
+ pendingCount: 1,
+ error: undefined,
+ },
+ };
+ const action: CreateStartAction = {
+ type: ActionTypes.CreateStart,
+ meta: { item: { id: 1, name: "one" } },
+ };
+ expect(indexCrudReducer(initialState, action)).toEqual(expectState);
+ });
+ it("two CREATE START actions increment pendingCount twice", () => {
+ const initialState: ResourceState = initializeState([]);
+ const expectState = {
+ ...initialState,
+ createMeta: {
+ status: "pending",
+ pendingCount: 2,
+ error: undefined,
+ },
+ };
+ const action: CreateStartAction = {
+ type: ActionTypes.CreateStart,
+ meta: { item: { id: 1, name: "one" } },
+ };
+ const state1 = indexCrudReducer(initialState, action);
+ const state2 = indexCrudReducer(state1, action);
+ expect(state2).toEqual(expectState);
+ });
+ it("CREATE FULFILLED decrements pending count, updates status, and saves new value", () => {
+ const initialState: ResourceState = {
+ ...initializeState([]),
+ createMeta: {
+ status: "pending",
+ pendingCount: 1,
+ error: undefined,
+ },
+ };
+ const action: CreateFulfillAction = {
+ type: ActionTypes.CreateFulfill,
+ payload: addKey({ id: 1, name: "one" }),
+ meta: { item: { id: 0, name: "one" } },
+ };
+ const expectState = {
+ ...initialState,
+ createMeta: {
+ status: "fulfilled",
+ pendingCount: 0,
+ error: undefined,
+ },
+ values: {
+ 1: {
+ value: { id: 1, name: "one" },
+ status: "fulfilled",
+ pendingCount: 0,
+ error: undefined,
+ },
+ },
+ };
+ expect(indexCrudReducer(initialState, action)).toEqual(expectState);
+ });
+ it("CREATE FULFILLED overwrites an error", () => {
+ const initialState: ResourceState = {
+ ...initializeState([]),
+ createMeta: {
+ status: "pending",
+ pendingCount: 1,
+ error: new Error(),
+ },
+ };
+ const action: CreateFulfillAction = {
+ type: ActionTypes.CreateFulfill,
+ payload: addKey({ id: 1, name: "one" }),
+ meta: { item: { id: 0, name: "one" } },
+ };
+ expect(
+ indexCrudReducer(initialState, action).createMeta.error,
+ ).toBeUndefined();
+ });
+ it("CREATE REJECTED decrements pendingCount, sets status to rejected, and doesn't modify values", () => {
+ const initialState: ResourceState = {
+ ...initializeState([{ id: 1, name: "one" }].map(addKey)),
+ createMeta: {
+ status: "pending",
+ pendingCount: 1,
+ error: undefined,
+ },
+ };
+ const action: CreateRejectAction = {
+ type: ActionTypes.CreateReject,
+ payload: new Error("Something went wrong with a fake request"),
+ meta: { item: { id: 0, name: "two" } },
+ };
+ const expectState = {
+ ...initialState,
+ createMeta: {
+ status: "rejected",
+ pendingCount: 0,
+ error: action.payload,
+ },
+ // Values are unchanged
+ };
+ expect(indexCrudReducer(initialState, action)).toEqual(expectState);
+ });
+ it("status remains 'pending' after CREATE FULFILLED and CREATE REJECTED if pendingCount was higher than 1", () => {
+ const initialState: ResourceState = {
+ ...initializeState([]),
+ createMeta: {
+ status: "pending",
+ pendingCount: 2,
+ error: undefined,
+ },
+ };
+ const fulfilledAction: CreateFulfillAction = {
+ type: ActionTypes.CreateFulfill,
+ payload: addKey({ id: 1, name: "one" }),
+ meta: { item: { id: 0, name: "one" } },
+ };
+ const fulfilledState = indexCrudReducer(initialState, fulfilledAction);
+ expect(fulfilledState.createMeta).toEqual({
+ status: "pending",
+ pendingCount: 1,
+ error: undefined,
+ });
+ const rejectedAction: CreateRejectAction = {
+ type: ActionTypes.CreateReject,
+ payload: new Error(),
+ meta: { item: { id: 0, name: "one" } },
+ };
+ const rejectedState = indexCrudReducer(initialState, rejectedAction);
+ expect(rejectedState.createMeta).toEqual({
+ status: "pending",
+ pendingCount: 1,
+ error: rejectedAction.payload,
+ });
+ });
+ });
+ describe("Test UPDATE actions", (): void => {
+ it("UPDATE START updates item-specific metadata", () => {
+ const initialState: ResourceState = initializeState(
+ [{ id: 1, name: "one" }].map(addKey),
+ );
+ const expectState = {
+ ...initialState,
+ values: {
+ 1: {
+ value: initialState.values[1].value,
+ status: "pending",
+ pendingCount: 1,
+ error: undefined,
+ },
+ },
+ };
+ const action: UpdateStartAction