Applicants demo app
This is a demo app I built for a code assignment. Since I didn’t use the code for anything else, this post helps to document the process. Since the assignment I spent some more time on improving the code.
The backend
While the output from the backend is static JSON file, I did need to generate the data. My goal was to easily generate files with which ever amount of candidate. To do that, I used casual, an NPM package for node that allows fake data to be easily generated.
The applicant properties
Using the casual we can create a function that will generate an applicant with random properties.
casual.define("user", function () {
const applicationDay = casual.integer(0, 15);
return {
firstName: casual.first_name,
lastName: casual.last_name,
email: casual.email,
dob: calcBirthDay(casual.integer(24, 65)),
experience: casual.integer(0, 15),
position: casual.random_element(positionsList),
dateOfApplication: moment()
.subtract(applicationDay, "days")
.format("yyyy-MM-DD"),
statusOfApplication: casual.random_element(applicationStatusList),
};
});
casual.define
lets us create function that we can call using the casual object.
Using that feature we create a “user”. Inside we define the properties using other casual
features.
The dob
(date of birth) is calculated with an external function.
Age input is randomised with calcBirthDay(casual.integer(24, 55))
meaning that
we will always get someone with ages between 24 and 65.
export const calcBirthDay = (age) => {
const _year = new Date().getFullYear() + 1;
const day = casual.integer(1, 28);
const month = casual.integer(1, 12);
const year = _year - age;
return `${year}-${month}-${day}`;
};
Using the random age, the date of birth is calculated based on the moment that the
script is executed. This returns the generated birth date in format yyyy-mm-dd
.
The only part that is left is to call this newly defined casual function in a loop to create the amount of candidates we would like to have. Once those have been created we write that to a JSON file.
let applicants = [];
const numberOfApplicants = 2000;
for (let i = 0; i < numberOfApplicants; i++) {
applicants.push(casual.user);
}
fs.writeFileSync(
`./candidatesData/users_${numberOfApplicants}.json`,
JSON.stringify(applicants)
);
Serving the backend data
Using a Netlify serverless function we can serve the backend data.
At a random interval the backend should fail 20% of the time, giving the front-end a chance to also show the error screen.
The file /netlify/functions/candidates.js
is the serverless function.
const candidates = require("../../candidatesData/users_2000.json");
const headers = {
"Content-Type": "application/json",
"access-control-allow-origin": "*",
};
const getRandomInt = function (max) {
return Math.floor(Math.random() * max);
};
exports.handler = async function () {
// Randomly cause the backend to fail.
if (getRandomInt(5) === 4) {
return {
statusCode: 404,
headers,
};
}
return {
statusCode: 200,
headers,
body: JSON.stringify({ candidates }),
};
};
The front-end
The front-end is a react application using react router. It loads the applicants and shows them in a table view. It’s important that the data structure matches.
// src/types/types.d.ts
export type ApplicantType = {
id: number,
firstName: string,
lastName: string,
email: string,
dob: string,
age?: number,
experience: number,
position: string,
dateOfApplication: string,
statusOfApplication: "approved" | "rejected" | "waiting",
};
The sorting and filter functions rely on some of these key fields to be present.
Sorting
The table is sortable by experience, position applied and date of application. The sorting works both ascending as descending.
Filtering
The table can also be filtered. The first and last names are filtered based on the text entered into the input field. In addition the position and status can also be filtered.
Application flow
When App.tsx
is loaded, it fetches the list of applications from the backend using a useEffect
hook.
// src/App.tsx
const _fetchApplicants = useCallback(
() => dispatch(fetchApplicantList()),
[dispatch]
);
useEffect(() => {
_fetchApplicants();
}, []);
Using useCallback
prevents the _fetchApplicants
function to cause an infinite loop in the useEffect
.
After fetching the applicants list, check if the list has been received. If the request failed, show the user an error, otherwise store the list in Redux.
export const fetchApplicantList = ({ force = false } = {}) =>
async function (dispatch: Function, getState: Function) {
const { applicantsList, isLoading } = getState().applicantReducer;
if (
((applicantsList && applicantsList.length > 0) || isLoading) &&
!force
)
return;
dispatch(setIsLoading(true));
dispatch(setError(undefined));
const handleError = (error: Error | unknown) =>
dispatch(
setError({
title: "Failed to load applicants",
error,
})
);
const clearLoading = () => dispatch(setIsLoading(false));
const list = await networkFetchApplicantList(handleError, clearLoading);
const candidates = list?.candidates.map((item: ApplicantType) => ({
...item,
dob: calculateAge(item.dob),
}));
dispatch(setApplicantList(candidates));
};
Importantly the date of birth string is replaced by the calculated age. Keeping the same key value, even though it’s no longer the date of birth but the age, ensures that the table still renders the value in the same position.
From a performance perspective the age should NOT be calculated while rendering the table. The age calculation function is then called four times for each applicant in development mode. It’s likely to be less in production.
Formatting the received data from the backend at the moment it has been received circumvents this issue.
Sorting
Sorting, when applied, is stored in the URL, allowing the user to send the applied sorting (and filters) to someone.
When App.tsx
retrieves the list from Redux, it does so in a way that if there are filters or sort settings applied that it will get that subset.
// src/App.tsx
const applicantList = useAppSelector(
applicantFilteredListSelector(searchParams)
);
The applicantFilteredListSelector
takes the searchParams hook as an argument.
The hook is used in the selector to easily get the parameters from the URL.
// src/features/applicant/applicantSlice.ts
export const applicantFilteredListSelector =
(searchParams: URLSearchParams) => (state: RootState) => {
// check if the filtered list is available otherwise provide the full list
const _list =
state?.applicantReducer?.applicantFilteredList ||
state?.applicantReducer?.applicantList ||
[];
// Sort if there are sort parameters
return sortApplicants(
convertSortKey(searchParams.get("sort")),
_list,
convertSortDirection(searchParams.get("sortDirection"))
);
};
Sorting in the sortController
The sort controller file has all the functions used to apply the sort. The full file can be reviewed in the projects GitHub repo.
Here is the main function to kick-off the sorting.
The key
is the column that should be sorted on. The list
is the list to be sorted and sortDirection
the sort direction.
// src/controller/sortController.ts
export const sortApplicants = (key: string | undefined | null, list: ApplicantType[], sortDirection: boolean) => {
if (!key) return list;
if (!list) {
throw new Error("Error: list required to complete sort");
}
const ref = tableHeaderConstants[key as keyof tableHeaderConstantsType];
return [...list].sort(sortFnReference[ref](sortDirection));
}
Since different sorting methods need to be applied, the following object tracks which sort method should be applied.
// src/controller/sortController.ts
const sortFnReference = {
[tableHeaderConstants.experience]: sortYears,
[tableHeaderConstants.positionApplied]: sortPosition,
[tableHeaderConstants.dateOfApplication]: sortDate,
};
With this it’s easy to pass in the sorting method and get the sort function.
Sorting years of experience
When sorting the years of experience, the direction is retrieved from the URL.
The key
activates the sortYears
method.
// src/controller/sortController.ts
export const sortYears =
(direction: boolean) => (a: ApplicantType, b: ApplicantType) => {
// swap a and b to invert the direction
const [_a, _b] = direction ? [a, b] : [b, a];
return _a.experience > _b.experience ? -1 : 1;
};
With this the direction is known, and the list can be sorted based on the experience
property of the data object.
Sorting on position
The sort functionality consists of two functions. First the property value position
is acquired and
converted to lower case string, needed for accurate comparison. After that the second function evaluates the position property.
Comparison | Result |
"a" < "b" | true |
"b" < "a" | false |
"administrator" < "designer" | true |
// First function to prepare for sorting
export const sortPosition =
(direction: boolean) => (a: ApplicantType, b: ApplicantType) => {
const [pa, pb] = [a, b].map((x) => x.position.toLowerCase());
return sortPositionLogic(direction, pa, pb);
};
// Second function to actually sort based on the position.
export const sortPositionLogic = (
direction: boolean,
pa: string,
pb: string
) => {
if (pa === pb) return 0;
const [_pa, _pb] = direction ? [pa, pb] : [pb, pa];
return _pa < _pb ? 1 : -1;
};
Sorting on Application date
In this case sorting should take place on the application date field.
This also consists of two functions.
The first function merely pulls out the dateOfApplication
property
and passes that to the _sortDate
function.
export const sortDate =
(direction: boolean) =>
(a: ApplicantType, b: ApplicantType): number =>
_sortDate(direction)(a.dateOfApplication, b.dateOfApplication);
// Actually with the sort values
export const _sortDate = (dir: boolean) => (d1: string, d2: string) => {
const _d1 = +new Date(d1),
_d2 = +new Date(d2);
if (_d1 === _d2) return 0;
// reverse the dates based on the direction
const [_dd1, _dd2] = dir ? [_d1, _d2] : [_d2, _d1];
return _dd1 > _dd2 ? 1 : -1;
};
To sort, the date string values are first converted to date values. Those can then be compared against each other.
Filtering in the application flow
A form at the top allows the user to filter the content.
The filterApplicants
function in the applicantsController
kicks off the filtering.
export const filterApplicants =
(searchParams: URLSearchParams, filters: FiltersType) =>
(dispatch: Function, getState: Function) => {
let applicantList = getState().applicantReducer.applicantList;
const nameFilter = filters.name || searchParams.get("name");
const statusFilter = filters.status || searchParams.get("status");
const positionFilter = filters.position || searchParams.get("position");
applicantList = filterByName(applicantList, nameFilter);
applicantList = filterByPosition(applicantList, positionFilter);
applicantList = filterByStatus(applicantList, statusFilter);
dispatch(setFilteredList(applicantList));
};
The React Router
based search params are passed in from the calling component, which gets it from the React hook.
Together with the applicantList
retrieved from redux all the data required to apply the filtering is available.
Filter by name
This filter function takes the list and the name value that should be filtered on.
Both the first name and last name are filtered on the value of nameFilter
.
export const filterByName = (
list: ApplicantType[],
nameFilter: string | null
) => {
if (!nameFilter) return list;
return list.filter(({ firstName, lastName }) => {
const _re = new RegExp(nameFilter, "i");
return _re.test(firstName) || _re.test(lastName);
});
};
Filter by position
Once filtered by name the list can be filtered by position.
The array filter
method is used again returning a subset of the original list.
export const filterByPosition = (
list: ApplicantType[],
positionFilter: string | null
) => {
if (!positionFilter || list.length < 1) return list;
return list.filter(({ position }) =>
position.match(new RegExp(positionFilter, "i"))
);
};
Filter by status
This is slightly longer in that a check is performed for the keyword all
.
If that is in the status filter the entire list should be returned.
If not we filter the list again, this time looking at the statusOfApplication
property.
export const filterByStatus = (
list: ApplicantType[],
statusFilter: string | null
) => {
if (!statusFilter || list.length < 1 || /all/i.test(statusFilter))
return list;
return list.filter(({ statusOfApplication }) => {
return statusOfApplication.match(new RegExp(statusFilter, "i"));
});
};
The web app is live at applicantlist.mytoori.com while the code can be found on GitHub