Skip to content

Commit

Permalink
Merge branch 'develop' into validate-company-application-Page
Browse files Browse the repository at this point in the history
FranciscoCardoso913 committed Sep 16, 2023
2 parents decd155 + 0cab005 commit 23032fa
Showing 80 changed files with 1,647 additions and 852 deletions.
3 changes: 2 additions & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ HOST_PORT=443
# PUBLIC_URL=https://ni.fe.up.pt/st4g1ng/nijobs/
# REACT_APP_BASE_ROUTE=test/test123
REACT_APP_API_HOSTNAME="https://localhost:8087"
REACT_APP_LOCATION_SERVICE_HOSTNAME="https://ni.fe.up.pt/nijobs/locations"

# Uncomment to disable devtools
# REACT_APP_ALLOW_DEV_TOOLS=false
@@ -10,4 +11,4 @@ REACT_APP_API_HOSTNAME="https://localhost:8087"
ANALYTICS_ID=

# GeoLocation Rapid Api key
GEO_API_KEY="APIKEY"
GEO_API_KEY="APIKEY"
6 changes: 6 additions & 0 deletions codecov.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
coverage:
status:
project:
default:
target: 80%
threshold: 5%
540 changes: 252 additions & 288 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -40,7 +40,7 @@
"react-app-polyfill": "^1.0.4",
"react-dom": "^16.12.0",
"react-easy-crop": "^3.5.3",
"react-ga": "^3.3.0",
"react-ga4": "^2.1.0",
"react-hook-form": "^7.20.4",
"react-markdown": "^8.0.3",
"react-redux": "^7.2.6",
6 changes: 6 additions & 0 deletions src/actions/searchOffersActions.js
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ export const OfferSearchTypes = Object.freeze({
SET_JOB_TYPE: "SET_JOB_TYPE",
SET_JOB_FIELDS: "SET_JOB_FIELDS",
SET_JOB_TECHS: "SET_JOB_TECHS",
SET_LOAD_URL_FROM_FILTERS: "SET_LOAD_URL_FROM_FILTERS",
SET_OFFERS_SEARCH_RESULT: "SET_OFFERS_SEARCH_RESULT",
SET_SEARCH_QUERY_TOKEN: "SET_SEARCH_QUERY_TOKEN",
SET_OFFERS_LOADING: "SET_OFFERS_LOADING",
@@ -67,6 +68,11 @@ export const setTechs = (technologies) => ({
technologies,
});

export const setLoadUrlFromFilters = (value) => ({
type: OfferSearchTypes.SET_LOAD_URL_FROM_FILTERS,
value,
});

export const setShowJobDurationSlider = (filterJobDuration) => ({
type: OfferSearchTypes.SET_JOB_DURATION_TOGGLE,
filterJobDuration,
2 changes: 1 addition & 1 deletion src/components/Apply/Company/ApplicationConfirmation.js
Original file line number Diff line number Diff line change
@@ -39,7 +39,7 @@ const ApplicationConfirmation = () => {
<RouterLink
to="/"
>
Go to Homepage
Go to Homepage
</RouterLink>
</CardActions>
</>
4 changes: 2 additions & 2 deletions src/components/Apply/Company/CompanyApplicationForm.js
Original file line number Diff line number Diff line change
@@ -264,15 +264,15 @@ const CompanyApplicationForm = ({ submitCompanyApplication, submittingApplicatio
disabled={submittingApplication}
onClick={onResetButtonClick}
>
Reset
Reset
</Button>
<Button
type="submit"
variant="contained"
color="primary"
disabled={submittingApplication}
>
Apply
Apply
</Button>
</CardActions>
</div>
7 changes: 6 additions & 1 deletion src/components/Company/Offers/Manage/CompanyOffersActions.js
Original file line number Diff line number Diff line change
@@ -26,7 +26,12 @@ const CompanyOffersActions = ({
<TableCell align="right">
{ !isMobile ? (
<>
<CompanyOffersVisibilityActions offer={row?.payload.offer} />
<CompanyOffersVisibilityActions
offer={row?.payload.offer}
getOfferVisibility={row?.payload.getOfferVisibility}
setOfferVisibility={row?.payload.setOfferVisibility}
offerId={row?.payload.offerId}
/>
<Tooltip title="Edit Offer">
<Link to={editOfferRoute}>
<IconButton aria-label="Edit Offer">
111 changes: 80 additions & 31 deletions src/components/Company/Offers/Manage/CompanyOffersManagementWidget.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,46 @@
import { Divider, Grid, IconButton, makeStyles, Tooltip, Typography } from "@material-ui/core";
import { Edit as EditIcon } from "@material-ui/icons";
import { format, parseISO } from "date-fns";
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import React, { useCallback, useEffect, useState } from "react";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { addSnackbar } from "../../../../actions/notificationActions";
import useSession from "../../../../hooks/useSession";
import { fetchCompanyOffers } from "../../../../services/companyOffersService";
import ControlledSortableSelectableTable from "../../../../utils/Table/ControlledSortableSelectableTable";
import FilterableTable from "../../../../utils/Table/FilterableTable";
import { alphabeticalSorter, GenerateTableCellFromField } from "../../../../utils/Table/utils";
import { columns } from "./CompanyOffersManagementSchema";
import PropTypes from "prop-types";
import useSession from "../../../../hooks/useSession";
import { OfferTitleFilter, PublishDateFilter, PublishEndDateFilter, LocationFilter } from "../Filters/index";
import { Edit as EditIcon } from "@material-ui/icons";
import { Link } from "react-router-dom";
import { addSnackbar } from "../../../../actions/notificationActions";
import { connect } from "react-redux";
import { RowActions } from "./CompanyOffersActions";
import Offer from "../../../HomePage/SearchResultsArea/Offer/Offer";
import { OfferConstants } from "../../../Offers/Form/OfferUtils";
import { LocationFilter, OfferTitleFilter, PublishDateFilter, PublishEndDateFilter } from "../Filters/index";
import { RowActions } from "./CompanyOffersActions";
import { columns } from "./CompanyOffersManagementSchema";
import OfferTitle from "./CompanyOffersTitle";
import CompanyOffersVisibilityActions from "./CompanyOffersVisibilityActions";

const generateRow = ({
title, location, publishDate, publishEndDate,
ownerName, _id, ...args }) => ({
title, location, publishDate, publishEndDate, isHidden, isArchived, hiddenReason,
ownerName, getOfferVisibility, setOfferVisibility, offerId, _id, ...args }) => ({
fields: {
title: { value: title, align: "left", linkDestination: `/offer/${_id}` },
title: { value: (
<OfferTitle
title={title}
getOfferVisibility={getOfferVisibility}
offerId={offerId}
/>), align: "left", linkDestination: `/offer/${_id}` },
publishStartDate: { value: format(parseISO(publishDate), "yyyy-MM-dd") },
publishEndDate: { value: format(parseISO(publishEndDate), "yyyy-MM-dd") },
location: { value: location },
},
payload: {
offer: new Offer({
title, location, publishDate, publishEndDate,
ownerName, _id, ...args,
title, location, publishDate, publishEndDate, isHidden,
isArchived, hiddenReason, ownerName, _id, ...args,
}),
getOfferVisibility: getOfferVisibility,
setOfferVisibility: setOfferVisibility,
offerId: offerId,
},
});

@@ -65,21 +75,29 @@ const filters = [
const CompanyOffersManagementWidget = ({ addSnackbar, isMobile }) => {
const { data, isLoggedIn } = useSession();
const [offers, setOffers] = useState({});
const [fetchedOffers, setFetchedOffers] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const mobileCols = ["title", "publishStartDate", "actions"];
const [offerVisibilityStates, setOfferVisibilityStates] = useState([]);

const getOfferVisibilityState = useCallback(
(offerId) => offerVisibilityStates[offerId],
[offerVisibilityStates]
);

const setOfferVisibilityState = useCallback((offerId, stateFunc) => {
const newVisibilityStates = [...offerVisibilityStates];
newVisibilityStates[offerId] = stateFunc(newVisibilityStates[offerId]);
setOfferVisibilityStates(newVisibilityStates);
}, [offerVisibilityStates, setOfferVisibilityStates]);

useEffect(() => {
if (isLoggedIn) fetchCompanyOffers(data.company._id).then((offers) => {
if (Array.isArray(offers)) {
const fetchedRows = offers.reduce((rows, row) => {
rows[row._id] = generateRow(row);
return rows;
}, {});

setOffers(fetchedRows);
setFetchedOffers(offers);
} else {
setOffers({});
setFetchedOffers([]);
}
setIsLoading(false);
}).catch((err) => {
@@ -92,19 +110,44 @@ const CompanyOffersManagementWidget = ({ addSnackbar, isMobile }) => {
});
}, [addSnackbar, data.company._id, isLoggedIn]);

const RowContent = ({ rowKey, labelId }) => {
useEffect(() => {
if (Array.isArray(fetchedOffers)) {
const newVisibilityStates = fetchedOffers.map((offer) => ({
isHidden: offer.isHidden && offer.hiddenReason === OfferConstants.COMPANY_REQUEST,
isDisabled: offer.isHidden && offer.hiddenReason === OfferConstants.ADMIN_REQUEST,
isVisible: !offer.isHidden && !offer.isArchived,
isBlocked: offer.isHidden && offer.hiddenReason === OfferConstants.COMPANY_BLOCKED,
isArchived: offer.isArchived,
}));
setOfferVisibilityStates(newVisibilityStates);
}
}, [fetchedOffers]);

useEffect(() => {
if (Array.isArray(fetchedOffers)) {
const fetchedRows = fetchedOffers.reduce((rows, row, i) => {
rows[row._id] = generateRow(
{ ...row, getOfferVisibility: getOfferVisibilityState, setOfferVisibility: setOfferVisibilityState, offerId: i }
);
return rows;
}, {});
setOffers(fetchedRows);
}
}, [setOffers, setOfferVisibilityState, fetchedOffers, getOfferVisibilityState]);

const RowContent = useCallback(({ rowKey, labelId }) => {
const fields = offers[rowKey].fields;

return (
<>
{!isMobile ? Object.entries(fields).map(([fieldId, fieldOptions], i) => (
GenerateTableCellFromField(i, fieldId, fieldOptions, labelId)
GenerateTableCellFromField(i, fieldId, fieldOptions, labelId, true)
)) : Object.entries(fields).filter(([fieldId, _]) => mobileCols.includes(fieldId)).map(([fieldId, fieldOptions], i) => (
GenerateTableCellFromField(i, fieldId, fieldOptions, labelId)
GenerateTableCellFromField(i, fieldId, fieldOptions, labelId, true)
))}
</>
);
};
}, [isMobile, mobileCols, offers]);

RowContent.propTypes = {
rowKey: PropTypes.string.isRequired,
@@ -126,10 +169,11 @@ const CompanyOffersManagementWidget = ({ addSnackbar, isMobile }) => {
},
}));

const RowCollapseComponent = ({ rowKey }) => {
const classes = useRowCollapseStyles();

const RowCollapseComponent = useCallback(({ rowKey }) => {
const row = offers[rowKey];
const offerRoute = `/offer/${rowKey}`;
const classes = useRowCollapseStyles();
const mobileFieldKeys = ["location", "publishEndDate"];

return (
@@ -139,11 +183,16 @@ const CompanyOffersManagementWidget = ({ addSnackbar, isMobile }) => {
<Grid container alignItems="center">
<Grid item xs={6}>
<Typography className={classes.collapsableTitles} variant="body1">
Actions
Actions
</Typography>
</Grid>
<Grid item xs={6} justifyContent="center">
<CompanyOffersVisibilityActions offer={row?.payload.offer} />
<CompanyOffersVisibilityActions
offer={row?.payload.offer}
getOfferVisibility={row?.payload.getOfferVisibility}
setOfferVisibility={row?.payload.setOfferVisibility}
offerId={row?.payload.offerId}
/>
<Tooltip title="Edit Offer">
<Link to={offerRoute}>
<IconButton aria-label="Edit Offer">
@@ -169,7 +218,7 @@ const CompanyOffersManagementWidget = ({ addSnackbar, isMobile }) => {
</>
)
);
};
}, [classes.collapsableTitles, classes.payloadSection, isMobile, offers]);

RowCollapseComponent.propTypes = {
rowKey: PropTypes.string.isRequired,
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@ import { createTheme } from "@material-ui/core";
import { SnackbarProvider } from "notistack";
import Notifier from "../../../Notifications/Notifier";
import { format, parseISO } from "date-fns";
import { OfferConstants } from "../../../Offers/Form/OfferUtils";

jest.mock("../../../../hooks/useSession");
jest.mock("../../../../services/companyOffersService");
@@ -55,6 +56,7 @@ describe("App", () => {
publishEndDate: "2021-09",
description: "Offer description 2",
isHidden: true,
hiddenReason: OfferConstants.COMPANY_REQUEST,
},
{
_id: "random uuid6",
@@ -67,7 +69,8 @@ describe("App", () => {
publishEndDate: "2021-09",
description: "Offer description 3",
isHidden: true,
hiddenReason: "ADMIN_REQUEST",
hiddenReason: OfferConstants.ADMIN_REQUEST,
isArchived: true,
},
];

@@ -213,8 +216,6 @@ describe("App", () => {
});

it("Should render mobile collapsable content on mobile device", async () => {
addSnackbar.mockImplementationOnce(() => ({ type: "" }));

const MOBILE_WIDTH_PX = 360;
window.matchMedia = createMatchMedia(MOBILE_WIDTH_PX);

@@ -258,6 +259,7 @@ describe("App", () => {
companyOffersService.fetchCompanyOffers.mockImplementationOnce(() => new Promise((resolve) =>
resolve([offer])
));
addSnackbar.mockImplementationOnce(() => ({ type: "" }));
hideOfferService.mockImplementation(() => new Promise((resolve) => resolve()));
enableOfferService.mockImplementation(() => new Promise((resolve) => resolve()));

@@ -282,6 +284,7 @@ describe("App", () => {

expect(queryByTestId(offerRow, "HideOffer")).not.toBeInTheDocument();
expect(getByTestId(offerRow, "EnableOffer")).toBeInTheDocument();
expect(getByTestId(offerRow, "HiddenChip")).toBeInTheDocument();

visibilityButton = getByTestId(offerRow, "EnableOffer");

@@ -291,13 +294,15 @@ describe("App", () => {

expect(getByTestId(offerRow, "HideOffer")).toBeInTheDocument();
expect(queryByTestId(offerRow, "EnableOffer")).not.toBeInTheDocument();
expect(queryByTestId(offerRow, "HiddenChip")).not.toBeInTheDocument();
});

it("Should disable hide/enable offer button when the offer is disabled by an admin", async () => {
const offer = MOCK_OFFERS[2];
companyOffersService.fetchCompanyOffers.mockImplementationOnce(() => new Promise((resolve) =>
resolve([offer])
));
addSnackbar.mockImplementationOnce(() => ({ type: "" }));
hideOfferService.mockImplementation(() => new Promise((resolve) => resolve()));
enableOfferService.mockImplementation(() => new Promise((resolve) => resolve()));

@@ -355,4 +360,35 @@ describe("App", () => {

expect(addSnackbar).toHaveBeenCalledTimes(1);
});

it("Should generate the right offer status chips", async () => {
companyOffersService.fetchCompanyOffers.mockImplementationOnce(() => new Promise((resolve) =>
resolve(MOCK_OFFERS)
));

await act(() =>
renderWithStoreAndTheme(
<BrowserRouter>
<MuiPickersUtilsProvider utils={DateFnsUtils}>
<CompanyOffersManagementWidget />
</MuiPickersUtilsProvider>
</BrowserRouter>, { initialState: {}, theme }
)
);

let offerRow = screen.queryByText(MOCK_OFFERS[0].title).closest("tr");
expect(queryByTestId(offerRow, "HiddenChip")).not.toBeInTheDocument();
expect(queryByTestId(offerRow, "BlockedChip")).not.toBeInTheDocument();
expect(queryByTestId(offerRow, "ArchivedChip")).not.toBeInTheDocument();

offerRow = screen.queryByText(MOCK_OFFERS[1].title).closest("tr");
expect(getByTestId(offerRow, "HiddenChip")).toBeInTheDocument();
expect(queryByTestId(offerRow, "BlockedChip")).not.toBeInTheDocument();
expect(queryByTestId(offerRow, "ArchivedChip")).not.toBeInTheDocument();

offerRow = screen.queryByText(MOCK_OFFERS[2].title).closest("tr");
expect(queryByTestId(offerRow, "HiddenChip")).not.toBeInTheDocument();
expect(getByTestId(offerRow, "BlockedChip")).toBeInTheDocument();
expect(getByTestId(offerRow, "ArchivedChip")).toBeInTheDocument();
});
});
64 changes: 64 additions & 0 deletions src/components/Company/Offers/Manage/CompanyOffersTitle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import { Chip, makeStyles } from "@material-ui/core";

const useStyles = makeStyles((theme) => ({
hiddenChip: {
backgroundColor: "#90A4AE",
marginRight: theme.spacing(.5),
},
blockedChip: {
backgroundColor: "#DC4338",
marginRight: theme.spacing(.5),
},
archivedChip: {
backgroundColor: "#56A8D6",
marginRight: theme.spacing(.5),
},
chips: {
position: "absolute",
},
}));

const OfferTitle = ({ title, getOfferVisibility, offerId }) => {
const [chips, setChips] = useState([]);
const isHidden = getOfferVisibility(offerId)?.isHidden;
const isBlocked = getOfferVisibility(offerId)?.isDisabled;
const isArchived = getOfferVisibility(offerId)?.isArchived;

const classes = useStyles();

useEffect(() => {
const statusChips = {
hidden: <Chip size="small" label="Hidden" data-testid="HiddenChip" className={classes.hiddenChip} />,
blocked: <Chip size="small" label="Blocked" data-testid="BlockedChip" className={classes.blockedChip} />,
archived: <Chip size="small" label="Archived" data-testid="ArchivedChip" className={classes.archivedChip} />,
};

const tempChips = [];
if (isHidden)
tempChips.push(statusChips.hidden);
if (isBlocked)
tempChips.push(statusChips.blocked);
if (isArchived)
tempChips.push(statusChips.archived);
setChips(tempChips);
}, [classes, isArchived, isBlocked, isHidden]);

return (
<>
{title}
<div className={classes.chips}>
{chips}
</div>
</>
);
};

OfferTitle.propTypes = {
title: PropTypes.string.isRequired,
getOfferVisibility: PropTypes.func.isRequired,
offerId: PropTypes.number.isRequired,
};

export default OfferTitle;
Original file line number Diff line number Diff line change
@@ -16,38 +16,30 @@ import { getHumanError } from "../../../../utils/offer/OfferUtils";
import { useDispatch } from "react-redux";
import { addSnackbar as addSnackbarAction } from "../../../../actions/notificationActions";
import Offer from "../../../HomePage/SearchResultsArea/Offer/Offer";
import { OfferConstants } from "../../../Offers/Form/OfferUtils";

const CompanyOffersVisibilityActions = ({ offer }) => {
const CompanyOffersVisibilityActions = ({ offer, getOfferVisibility, setOfferVisibility, offerId }) => {

const dispatch = useDispatch();
const addSnackbar = useCallback((notification) => dispatch(addSnackbarAction(notification)), [dispatch]);

const [offerVisibilityState, setOfferVisibilityState] = useState({
isVisible: undefined,
isDisabled: undefined,
isBlocked: undefined,
});
const offerVisible = getOfferVisibility(offerId)?.isVisible;
const offerDisabled = getOfferVisibility(offerId)?.isDisabled;
const offerBlocked = getOfferVisibility(offerId)?.isBlocked;

const [loadingOfferVisibility, setLoadingOfferVisibility] = useState(false);

const isHiddenOffer = offer?.isHidden;
const offerHiddenReason = offer?.hiddenReason;
const [offerVisibilityButtonDisabled, setOfferVisibilityButtonDisabled] = useState(false);

useEffect(() => {
setOfferVisibilityState({
isDisabled: isHiddenOffer && offerHiddenReason === OfferConstants.ADMIN_REQUEST,
isVisible: !isHiddenOffer,
isBlocked: isHiddenOffer && offerHiddenReason === OfferConstants.COMPANY_BLOCKED,
});
}, [isHiddenOffer, offerHiddenReason]);

setOfferVisibilityButtonDisabled(loadingOfferVisibility
|| offerDisabled
|| offerBlocked);
}, [loadingOfferVisibility, offerBlocked, offerDisabled, offerId]);

const handleHideOffer = useCallback(({ offer, addSnackbar, onError }) => {
setLoadingOfferVisibility(true);
hideOfferService(offer._id)
.then(() => {
setOfferVisibilityState((offerVisibilityState) => ({ ...offerVisibilityState, isVisible: false }));
setOfferVisibility(offerId, (state) => ({ ...state, isVisible: false, isHidden: true }));
addSnackbar({
message: "The offer was hidden",
key: `${Date.now()}-${offer._id}-hidden`,
@@ -56,13 +48,13 @@ const CompanyOffersVisibilityActions = ({ offer }) => {
.catch((err) => {
if (onError) onError(err);
}).finally(() => setLoadingOfferVisibility(false));
}, []);
}, [offerId, setOfferVisibility]);

const handleEnableOffer = useCallback(({ offer, addSnackbar, onError }) => {
setLoadingOfferVisibility(true);
enableOfferService(offer._id)
.then(() => {
setOfferVisibilityState((offerVisibilityState) => ({ ...offerVisibilityState, isVisible: true }));
setOfferVisibility(offerId, (state) => ({ ...state, isVisible: true, isHidden: false }));
addSnackbar({
message: "The offer was enabled",
key: `${Date.now()}-${offer._id}-enabled`,
@@ -71,7 +63,7 @@ const CompanyOffersVisibilityActions = ({ offer }) => {
.catch((err) => {
if (onError) onError(err);
}).finally(() => setLoadingOfferVisibility(false));
}, []);
}, [offerId, setOfferVisibility]);

const handleOfferVisibilityError = useCallback((err) => {
if (Array.isArray(err) && err.length > 0) {
@@ -88,8 +80,8 @@ const CompanyOffersVisibilityActions = ({ offer }) => {
}
}, [addSnackbar, offer._id]);

const handleOfferVisibility = () => {
if (offerVisibilityState.isVisible) {
const handleOfferVisibility = useCallback(() => {
if (offerVisible) {
handleHideOffer({
offer: offer,
addSnackbar: addSnackbar,
@@ -102,18 +94,16 @@ const CompanyOffersVisibilityActions = ({ offer }) => {
onError: handleOfferVisibilityError,
});
}
};

const offerVisibilityButtonDisabled = loadingOfferVisibility || offerVisibilityState.isDisabled || offerVisibilityState.isBlocked;
}, [offerVisible, handleHideOffer, offer, addSnackbar, handleOfferVisibilityError, handleEnableOffer]);

return (
<Tooltip title={offerVisibilityState.isVisible ? "Hide Offer" : "Enable Offer"}>
<Tooltip title={offerVisible ? "Hide Offer" : "Enable Offer"}>
<span>
<IconButton
onClick={handleOfferVisibility}
disabled={offerVisibilityButtonDisabled}
>
{offerVisibilityState.isVisible ?
{offerVisible ?
<VisibilityOffIcon
data-testid="HideOffer"
color={offerVisibilityButtonDisabled ? undefined : "secondary"}
@@ -133,7 +123,10 @@ const CompanyOffersVisibilityActions = ({ offer }) => {
};

CompanyOffersVisibilityActions.propTypes = {
offer: PropTypes.instanceOf(Offer),
offer: PropTypes.instanceOf(Offer).isRequired,
getOfferVisibility: PropTypes.func.isRequired,
setOfferVisibility: PropTypes.func.isRequired,
offerId: PropTypes.number.isRequired,
};

export default CompanyOffersVisibilityActions;
Original file line number Diff line number Diff line change
@@ -167,7 +167,7 @@ const LogoUploadForm = () => {
color="primary"
startIcon={<CloudUpload />}
>
Upload
Upload
</Button>
</label>
</Grid>
2 changes: 1 addition & 1 deletion src/components/Company/Registration/Finish/ReviewForm.js
Original file line number Diff line number Diff line change
@@ -82,7 +82,7 @@ const ReviewInfo = () => {
</CardContent>
<CardContent>
<Typography variant="h6">
Contacts
Contacts
</Typography>
<ul>
{contacts.map((c, i) =>
Original file line number Diff line number Diff line change
@@ -76,7 +76,7 @@ export const ProductDescription = React.forwardRef(({ toggleAuthModal }, ref) =>
component={RouterLink}
to="/offers/new"
>
Create Offer
Create Offer
</Button>
</Grid>
:
@@ -87,7 +87,7 @@ export const ProductDescription = React.forwardRef(({ toggleAuthModal }, ref) =>
color="primary"
onClick={toggleAuthModal}
>
Login
Login
</Button>
}
<Button
Original file line number Diff line number Diff line change
@@ -168,7 +168,7 @@ const AdvancedSearchMobile = () => {
color="primary"
onClick={handleSearchClick}
>
Search
Search
</Button>
</DialogActions>
</Dialog>
46 changes: 34 additions & 12 deletions src/components/HomePage/SearchArea/SearchArea.js
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@ import {
setFields,
setShowJobDurationSlider,
setTechs,
setLoadUrlFromFilters,
} from "../../../actions/searchOffersActions";
import { INITIAL_JOB_TYPE, INITIAL_JOB_DURATION } from "../../../reducers/searchOffersReducer";

@@ -37,6 +38,7 @@ export const AdvancedSearchController = ({
enableAdvancedSearchDefault, showJobDurationSlider, setShowJobDurationSlider, jobMinDuration,
jobMaxDuration, setJobDuration, jobType, setJobType, fields, setFields, technologies, setTechs,
resetAdvancedSearchFields, onSubmit, searchValue, setSearchValue, onMobileClose,
loadUrlFromFilters, setLoadUrlFromFilters,
}) => {

const {
@@ -48,6 +50,7 @@ export const AdvancedSearchController = ({
setTechs: actualSetTechs,
resetAdvancedSearchFields: actualResetAdvancedSearchFields,
setSearchValue: setUrlSearchValue,
setUrlFilters,
} = useSearchParams({
setJobDuration,
setShowJobDurationSlider,
@@ -92,17 +95,30 @@ export const AdvancedSearchController = ({
}, [onSubmit, searchOffers, searchValue, setUrlSearchValue]);

useEffect(() => {
setShowJobDurationSlider(!!queryParams.jobMinDuration && !!queryParams.jobMaxDuration);
setJobDuration(null, [
parseInt(queryParams.jobMinDuration, 10),
parseInt(queryParams.jobMaxDuration, 10),
]);

setJobType(queryParams.jobType);
setFields(ensureArray(queryParams.fields ?? []));
setTechs(ensureArray(queryParams.technologies ?? []));

setSearchValue(queryParams.searchValue);
if (loadUrlFromFilters) {
setUrlFilters(
jobMinDuration,
jobMaxDuration,
fields,
technologies,
jobType,
searchValue,
);
setLoadUrlFromFilters(false);
submitForm(null, false);
} else {
setShowJobDurationSlider(!!queryParams.jobMinDuration && !!queryParams.jobMaxDuration);
setJobDuration(null, [
parseInt(queryParams.jobMinDuration, 10),
parseInt(queryParams.jobMaxDuration, 10),
]);

setJobType(queryParams.jobType);
setFields(ensureArray(queryParams.fields ?? []));
setTechs(ensureArray(queryParams.technologies ?? []));

setSearchValue(queryParams.searchValue);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

@@ -124,7 +140,8 @@ export const AdvancedSearchController = ({
export const SearchArea = ({ onSubmit, searchValue,
jobMinDuration = INITIAL_JOB_DURATION, jobMaxDuration = INITIAL_JOB_DURATION + 1, jobType = INITIAL_JOB_TYPE,
fields, technologies, showJobDurationSlider, setShowJobDurationSlider, advanced: enableAdvancedSearchDefault = false,
setSearchValue, setJobDuration, setJobType, setFields, setTechs, resetAdvancedSearchFields, onMobileClose }) => {
setSearchValue, setJobDuration, setJobType, setFields, setTechs, resetAdvancedSearchFields, onMobileClose,
loadUrlFromFilters, setLoadUrlFromFilters }) => {

const classes = useSearchAreaStyles();
const {
@@ -140,6 +157,7 @@ export const SearchArea = ({ onSubmit, searchValue,
enableAdvancedSearchDefault, showJobDurationSlider, setShowJobDurationSlider, jobMinDuration,
jobMaxDuration, setJobDuration, jobType, setJobType, fields, setFields, technologies, setTechs,
resetAdvancedSearchFields, onSubmit, searchValue, setSearchValue, onMobileClose,
loadUrlFromFilters, setLoadUrlFromFilters,
},
AdvancedSearchControllerContext
);
@@ -196,6 +214,8 @@ SearchArea.propTypes = {
setSearchValue: PropTypes.func.isRequired,
setJobDuration: PropTypes.func.isRequired,
setJobType: PropTypes.func.isRequired,
setLoadUrlFromFilters: PropTypes.func.isRequired,
loadUrlFromFilters: PropTypes.bool,
resetAdvancedSearchFields: PropTypes.func.isRequired,
fields: PropTypes.array.isRequired,
technologies: PropTypes.array.isRequired,
@@ -215,6 +235,7 @@ export const mapStateToProps = ({ offerSearch }) => ({
fields: offerSearch.fields,
technologies: offerSearch.technologies,
showJobDurationSlider: offerSearch.filterJobDuration,
loadUrlFromFilters: offerSearch.loadUrlFromFilters,
});

export const mapDispatchToProps = (dispatch) => ({
@@ -225,6 +246,7 @@ export const mapDispatchToProps = (dispatch) => ({
setTechs: (technologies) => dispatch(setTechs(technologies)),
setShowJobDurationSlider: (val) => dispatch(setShowJobDurationSlider(val)),
resetAdvancedSearchFields: () => dispatch(resetAdvancedSearchFields()),
setLoadUrlFromFilters: (value) => dispatch(setLoadUrlFromFilters(value)),
});

export default connect(mapStateToProps, mapDispatchToProps)(SearchArea);
35 changes: 35 additions & 0 deletions src/components/HomePage/SearchArea/SearchArea.spec.js
Original file line number Diff line number Diff line change
@@ -214,6 +214,41 @@ describe("SearchArea", () => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});

it("should call onSubmit callback on search button click", () => {
const searchValue = "test";
const setSearchValue = () => { };

const onSubmit = jest.fn();
const addSnackbar = () => { };

// Simulate request success
fetch.mockResponse(JSON.stringify({ mockData: true }));

renderWithStoreAndTheme(
<RouteWrappedContent>
<SearchArea
searchValue={searchValue}
setSearchValue={setSearchValue}
addSnackbar={addSnackbar}
loadUrlFromFilters={true}
showJobDurationSlider={() => { }}
setShowJobDurationSlider={() => { }}
setTechs={() => { }}
setJobDuration={() => { }}
setFields={() => { }}
setJobType={() => { }}
onSubmit={onSubmit}
fields={[]}
technologies={[]}
setLoadUrlFromFilters={() => { }}
/>
</RouteWrappedContent>,
{ initialState, theme }
);

expect(onSubmit).toHaveBeenCalledTimes(1);
});

it("should fill in search filters if they are present in the URL", () => {

const urlParams = {
34 changes: 34 additions & 0 deletions src/components/HomePage/SearchArea/useUrlSearchParams.js
Original file line number Diff line number Diff line change
@@ -128,6 +128,39 @@ export default ({

}, [clearURLFilters, location, resetAdvancedSearchFields]);

const setUrlFilters = useCallback((
jobMinDuration,
jobMaxDuration,
fields,
technologies,
jobType,
searchValue,
) => {
let currFilters = {};

if (jobMinDuration && jobMaxDuration) {
currFilters = { ...currFilters, jobMinDuration, jobMaxDuration };
}

if (fields) {
currFilters = { ...currFilters, fields };
}

if (technologies) {
currFilters = { ...currFilters, technologies };
}

if (jobType) {
currFilters = { ...currFilters, jobType };
}

if (searchValue) {
currFilters = { ...currFilters, searchValue };
}

changeURLFilters(location, {}, currFilters);
}, [changeURLFilters, location]);

return {
queryParams,
changeURLFilters,
@@ -138,5 +171,6 @@ export default ({
setTechs: actualSetTechs,
setSearchValue: actualSetSearchValue,
resetAdvancedSearchFields: actualResetAdvancedSearchFields,
setUrlFilters,
};
};
4 changes: 3 additions & 1 deletion src/components/HomePage/SearchResultsArea/Offer/ChipList.js
Original file line number Diff line number Diff line change
@@ -26,7 +26,7 @@ const useStyles = makeStyles((theme) => ({
},
}));

const ChipList = ({ type, values, loading }) => {
const ChipList = ({ type, values, loading, onChipClick }) => {
const classes = useStyles();
if (loading)
return (
@@ -44,6 +44,7 @@ const ChipList = ({ type, values, loading }) => {
variant={type === "Technologies" ? "outlined" : "default"}
size="small"
className={classes.chip}
onClick={onChipClick ? () => onChipClick(value) : null}
/>
</li>
)}
@@ -57,6 +58,7 @@ ChipList.propTypes = {
type: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.string),
loading: PropTypes.bool,
onChipClick: PropTypes.func,
};

export default ChipList;
28 changes: 28 additions & 0 deletions src/components/HomePage/SearchResultsArea/Offer/OfferDetails.js
Original file line number Diff line number Diff line change
@@ -19,6 +19,8 @@ import useSession from "../../../../hooks/useSession";
import useSearchResultsWidgetStyles from "../SearchResultsWidget/searchResultsWidgetStyles";
import { RouterLink } from "../../../../utils";
import { JOB_MAX_DURATION } from "../../../../reducers/searchOffersReducer";
import useChipsFieldSearch from "./useChipsFieldSearch";
import { useHistory } from "react-router-dom";

const defaultLogo = require("./default_icon.svg");

@@ -53,6 +55,30 @@ const OfferDetails = ({
+ "Please contact support for more information."
), [offer, sessionData]);

const { addField, addTech, addFieldWithUrl, addTechWithUrl, setLoadUrlFromFilters } = useChipsFieldSearch();

const handleChipSetFields = useCallback((values) => {
if (isPage) {
history.push("/");
addField(values);
setLoadUrlFromFilters(true);
} else {
addFieldWithUrl(values);
}
}, [history, isPage, addField, addFieldWithUrl, setLoadUrlFromFilters]);

const handleChipSetTechs = useCallback((values) => {
if (isPage) {
history.push("/");
addTech(values);
setLoadUrlFromFilters(true);
} else {
addTechWithUrl(values);
}
}, [history, isPage, setLoadUrlFromFilters, addTech, addTechWithUrl]);

const history = useHistory();

const getHiddenOfferMessage = useCallback(() => {
if (visibilityState.isDisabled)
return getDisabledOfferMessage();
@@ -248,11 +274,13 @@ const OfferDetails = ({
type="Technologies"
values={offer?.technologies}
loading={loading}
onChipClick={handleChipSetTechs}
/>
<ChipList
type="Fields"
values={offer?.fields}
loading={loading}
onChipClick={handleChipSetFields}
/>
</Grid>
</Grid>
Original file line number Diff line number Diff line change
@@ -58,8 +58,8 @@ const OfferInteractionOptions = ({

const canChangeOfferVisibility = useMemo(() => (
visibilityState.isVisible ||
!visibilityState.isDisabled ||
sessionData?.company?._id === offer?.owner) &&
!visibilityState.isDisabled ||
sessionData?.company?._id === offer?.owner) &&
(sessionData?.company?._id === offer?.owner ||
sessionData?.isAdmin
), [offer, sessionData, visibilityState.isDisabled, visibilityState.isVisible]);
@@ -76,16 +76,16 @@ const OfferInteractionOptions = ({
/>}
<div className={classes.offerOptionsButtons}>
{
offer?.applyURL &&
<div className={classes.offerApplyButton}>
<OfferApplyButton
open={showRedirectDialog}
handleAccept={handleApplyURLRedirect}
handleToggle={toggleRedirectDialog}
applyURL={offer.applyURL}
title="You're being redirected to the following website:"
/>
</div>
offer?.applyURL &&
<div className={classes.offerApplyButton}>
<OfferApplyButton
open={showRedirectDialog}
handleAccept={handleApplyURLRedirect}
handleToggle={toggleRedirectDialog}
applyURL={offer.applyURL}
title="You're being redirected to the following website:"
/>
</div>
}
{
(sessionData?.company?._id === offer.owner || sessionData?.isAdmin) &&
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { createTheme } from "@material-ui/core";
import React from "react";
import { MemoryRouter } from "react-router-dom";
import { renderWithStoreAndTheme, fireEvent } from "../../../../test-utils";
import Offer from "./Offer";
import OfferWidget from "./OfferWidget";
import useChipsFieldSearch from "./useChipsFieldSearch";

jest.mock("./useChipsFieldSearch");
const mockHistoryPush = jest.fn();

jest.mock("react-router-dom", () => ({
...jest.requireActual("react-router-dom"),
useHistory: () => ({
push: mockHistoryPush,
}),
}));

describe("useChipsFieldSearch", () => {
const theme = createTheme({});


const offer = new Offer({
_id: "id1",
title: "position1",
owner: "company_id",
ownerName: "company1",
ownerLogo: "",
location: "location1",
fields: ["BACKEND"],
technologies: ["Cassandra"],
jobMinDuration: 1,
jobMaxDuration: 12,
jobStartDate: (new Date()).toISOString(),
publishDate: "2021-04-22T22:35:57.177Z",
publishEndDate: "2021-09-19T23:00:00.000Z",
isPaid: false,
vacancies: 2,
description: "description1",
});


it("should redirect and update field filter state if in page", async () => {
const addFieldMock = jest.fn();
useChipsFieldSearch.mockImplementation(() => ({
addField: addFieldMock,
setLoadUrlFromFilters: jest.fn(),
}));

const initialState = {
offerSearch: {
searchValue: "searchValue",
jobDuration: [1, 2],
fields: [],
technologies: [],
},
};


const wrapper = renderWithStoreAndTheme(
<MemoryRouter initialEntries={["/"]}>
<OfferWidget offer={offer} isPage={true} />
</MemoryRouter>,
{ initialState, theme }
);

await fireEvent.click(wrapper.getByText("Back-End"));

expect(mockHistoryPush).toHaveBeenCalledWith("/");
expect(addFieldMock).toHaveBeenCalledWith("BACKEND");
});

it("should update field filter and url if in search page", async () => {
const addFieldWithUrlMock = jest.fn();

useChipsFieldSearch.mockImplementation(() => ({
addFieldWithUrl: addFieldWithUrlMock,
}));

const initialState = {
offerSearch: {
searchValue: "searchValue",
jobDuration: [1, 2],
fields: [],
technologies: [],
},
};


const wrapper = renderWithStoreAndTheme(
<MemoryRouter initialEntries={["/"]}>
<OfferWidget offer={offer} isPage={false} />
</MemoryRouter>,
{ initialState, theme }
);

await fireEvent.click(wrapper.getByText("Back-End"));

expect(addFieldWithUrlMock).toHaveBeenCalledWith("BACKEND");
});

it("should redirect and update techs filter state if in page", async () => {
const addTechMock = jest.fn();
useChipsFieldSearch.mockImplementation(() => ({
addTech: addTechMock,
setLoadUrlFromFilters: jest.fn(),
}));

const initialState = {
offerSearch: {
searchValue: "searchValue",
jobDuration: [1, 2],
fields: [],
technologies: [],
},
};


const wrapper = renderWithStoreAndTheme(
<MemoryRouter initialEntries={["/"]}>
<OfferWidget offer={offer} isPage={true} />
</MemoryRouter>,
{ initialState, theme }
);

await fireEvent.click(wrapper.getByText("Cassandra"));

expect(mockHistoryPush).toHaveBeenCalledWith("/");
expect(addTechMock).toHaveBeenCalledWith("Cassandra");
});

it("should update techs filter and url if in search page", async () => {
const addTechWithUrlMock = jest.fn();

useChipsFieldSearch.mockImplementation(() => ({
addTechWithUrl: addTechWithUrlMock,
}));

const initialState = {
offerSearch: {
searchValue: "searchValue",
jobDuration: [1, 2],
fields: [],
technologies: [],
},
};


const wrapper = renderWithStoreAndTheme(
<MemoryRouter initialEntries={["/"]}>
<OfferWidget offer={offer} isPage={false} />
</MemoryRouter>,
{ initialState, theme }
);

await fireEvent.click(wrapper.getByText("Cassandra"));

expect(addTechWithUrlMock).toHaveBeenCalledWith("Cassandra");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { setFields, setTechs, setLoadUrlFromFilters } from "../../../../actions/searchOffersActions";

import useSearchParams from "../../SearchArea/useUrlSearchParams";
import { SearchResultsConstants } from "../SearchResultsWidget/SearchResultsUtils";
import useOffersSearcher from "../SearchResultsWidget/useOffersSearcher";

export default () => {
const dispatch = useDispatch();
const fields = useSelector((state) => state.offerSearch.fields);
const techs = useSelector((state) => state.offerSearch.technologies);
const jobMaxDuration = useSelector((state) => state.offerSearch.jobMaxDuration);
const jobMinDuration = useSelector((state) => state.offerSearch.jobMinDuration);
const jobType = useSelector((state) => state.offerSearch.jobType);
const searchValue = useSelector((state) => state.offerSearch.searchValue);

const [search, setSearch] = useState(false);

const { search: searchOffers } = useOffersSearcher({
value: searchValue,
jobMinDuration,
jobMaxDuration,
jobType,
fields,
technologies: techs,
});

const addField = useCallback((value) => dispatch(setFields([...fields, value])), [dispatch, fields]);
const addTech = useCallback((value) => dispatch(setTechs([...techs, value])), [dispatch, techs]);

const {
setFields: urlSetFields,
setTechs: urlSetTechs,
} = useSearchParams({
setFields: (value) => dispatch(setFields(value)),
setTechs: (value) => dispatch(setTechs(value)),
});

const addFieldWithUrl = useCallback((value) => {
/* istanbul ignore else */
if (!fields.includes(value)) {
urlSetFields([...fields, value]);
setSearch(true);
}
}, [fields, urlSetFields]);

const addTechWithUrl = useCallback((value) => {
/* istanbul ignore else */
if (!techs.includes(value)) {
urlSetTechs([...techs, value]);
setSearch(true);
}
}, [techs, urlSetTechs]);

useEffect(() => {
/* istanbul ignore else */
if (search) {
searchOffers(SearchResultsConstants.INITIAL_LIMIT);
setSearch(false);
}
}, [searchOffers, fields, techs, search]);

return {
addField,
addTech,
addFieldWithUrl,
addTechWithUrl,
setLoadUrlFromFilters: (value) => dispatch(setLoadUrlFromFilters(value)),
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { MemoryRouter, useLocation } from "react-router-dom";
import qs from "qs";

import { testHookWithStoreAndTheme } from "../../../../test-utils";
import { act } from "react-dom/test-utils";
import useChipsFieldSearch from "./useChipsFieldSearch";
import { createTheme } from "@material-ui/core";

describe("useChipsFieldSearch", () => {
const theme = createTheme({});

it("should change fields's search param when adding fields", async () => {
let addFieldWithUrl, location;
const initialState = {
offerSearch: {
searchValue: "searchValue",
jobDuration: [1, 2],
fields: [],
technologies: [],
},
};

const callback = () => {
addFieldWithUrl = useChipsFieldSearch().addFieldWithUrl;

location = useLocation();
};

testHookWithStoreAndTheme(callback, initialState, theme, MemoryRouter);

expect(location).toHaveProperty("search", "");

await act(() => {
addFieldWithUrl("TEST-FIELD");
});

let expectedLocationSearch = `?${qs.stringify({
fields: ["TEST-FIELD"],
}, { skipNulls: true, arrayFormat: "brackets" })}`;

expect(location).toHaveProperty("search", expectedLocationSearch);

// Wait for state update
await new Promise((r) => setTimeout(r, 500));

await act(() => {
addFieldWithUrl("TEST-FIELD-2");
});

expectedLocationSearch = `?${qs.stringify({
fields: ["TEST-FIELD", "TEST-FIELD-2"],
}, { skipNulls: true, arrayFormat: "brackets" })}`;

expect(location).toHaveProperty("search", expectedLocationSearch);
});

it("should change technologies's search param when adding fields", async () => {
let addTechWithUrl, location;
const initialState = {
offerSearch: {
searchValue: "searchValue",
jobDuration: [1, 2],
fields: [],
technologies: [],
},
};

const callback = () => {
addTechWithUrl = useChipsFieldSearch().addTechWithUrl;

location = useLocation();
};

testHookWithStoreAndTheme(callback, initialState, theme, MemoryRouter, { initialEntries: ["/"] });


expect(location).toHaveProperty("search", "");

await act(() => {
addTechWithUrl("TEST-TECH");
});

let expectedLocationSearch = `?${qs.stringify({
technologies: ["TEST-TECH"],
}, { skipNulls: true, arrayFormat: "brackets" })}`;

expect(location).toHaveProperty("search", expectedLocationSearch);

// Wait for state update
await new Promise((r) => setTimeout(r, 500));

await act(() => {
addTechWithUrl("TEST-TECH-2");
});

expectedLocationSearch = `?${qs.stringify({
technologies: ["TEST-TECH", "TEST-TECH-2"],
}, { skipNulls: true, arrayFormat: "brackets" })}`;

expect(location).toHaveProperty("search", expectedLocationSearch);
});

});
Original file line number Diff line number Diff line change
@@ -82,7 +82,7 @@ export const OfferViewer = ({
>
<NavigateBefore />
</IconButton>
Offer Details
Offer Details
</DialogTitle>
<DialogContent>
<div className={offerWidgetWrapperClassName}>
Original file line number Diff line number Diff line change
@@ -40,6 +40,7 @@ const SearchURLWidget = () => {
const classes = useSearchURLWidgetStyles();
const dispatch = useDispatch();

// TODO: See if this should come from the window browser object or from the router
const fullURL = window.location.href;

const copyToClipboard = useCallback(() => {
Original file line number Diff line number Diff line change
@@ -49,7 +49,8 @@ describe("Search URL Widget", () => {

const testString = "test-string";

//global.window = Object.create(window);
// global.window = Object.create(window);

Object.defineProperty(window, "location", {
value: {
href: testString,
47 changes: 37 additions & 10 deletions src/components/Navbar/Auth/LoginForm.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import { login } from "../../../services/auth";
import { RouterLink } from "../../../utils";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import LoginSchema from "./LoginSchema";
@@ -13,11 +14,16 @@ import {
DialogActions,
Button,
FormHelperText,
Typography,
} from "@material-ui/core";

import { useMobile } from "../../../utils/media-queries";

import useToggle from "../../../hooks/useToggle";

const LoginForm = ({ toggleAuthModal, updateSessionInfo, setRecoveryRequestPage }) => {
const classes = useAuthStyles();
const isMobile = useMobile();
const classes = useAuthStyles({ isMobile });

const { register, handleSubmit, formState: { errors } } = useForm({
mode: "onBlur",
@@ -90,22 +96,43 @@ const LoginForm = ({ toggleAuthModal, updateSessionInfo, setRecoveryRequestPage
{loginError || " "}
</FormHelperText>
</DialogContent>
<DialogActions>
<div className={classes.modalOptions}>
<Typography className={classes.smallText}>
Lost your password?
</Typography>
<Button
className={classes.smallText}
onClick={setRecoveryRequestPage}
variant="text"
disabled={loginPending}
color="secondary"
color="primary"
>
Recover Password
</Button>
</div>
<div className={classes.modalOptions}>
<Typography className={classes.smallText}>
Don&apos;t have an account?
</Typography>
<Button
className={classes.smallText}
onClick={toggleAuthModal}
variant="text"
color="primary"
component={RouterLink}
to="/apply/company"
>
Lost password?
Join Us
</Button>
</div>
<DialogActions>
<Button
onClick={toggleAuthModal}
variant="text"
color="secondary"
disabled={loginPending}
>
Cancel
Cancel
</Button>
<div className={classes.loginBtnWrapper}>
<Button
@@ -115,13 +142,13 @@ const LoginForm = ({ toggleAuthModal, updateSessionInfo, setRecoveryRequestPage
variant="contained"
disabled={loginPending}
>
Login
Login
</Button>
{loginPending &&
<CircularProgress
size={24}
className={classes.loginProgressRed}
/>
<CircularProgress
size={24}
className={classes.loginProgressRed}
/>
}
</div>
</DialogActions>
67 changes: 40 additions & 27 deletions src/components/Navbar/Auth/LoginForm.spec.js
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@ import { fireEvent, act, renderWithStoreAndTheme } from "../../../test-utils";
import Constants from "../../../utils/Constants";
import { SnackbarProvider } from "notistack";
import { createTheme } from "@material-ui/core";
import { BrowserRouter } from "react-router-dom/cjs/react-router-dom.min";

jest.mock("../../../services/auth");

@@ -29,7 +30,9 @@ describe("Navbar - AuthModal - LoginForm", () => {

const wrapper = renderWithStoreAndTheme(
<SnackbarProvider maxSnack={3}>
<AuthModal open toggleAuthModal={toggleAuthModal} />
<BrowserRouter>
<AuthModal open toggleAuthModal={toggleAuthModal} />
</BrowserRouter>
</SnackbarProvider>, { initialState: {}, theme });

const dialogTitle = wrapper.queryByRole("heading", { level: 2, name: "Login" });
@@ -48,9 +51,11 @@ describe("Navbar - AuthModal - LoginForm", () => {

const wrapper = renderWithStoreAndTheme(
<SnackbarProvider maxSnack={3}>
<AuthModal open toggleAuthModal={toggleAuthModal} />
<BrowserRouter>
<AuthModal open toggleAuthModal={toggleAuthModal} />
</BrowserRouter>
</SnackbarProvider>, { initialState: {}, theme });
fireEvent.click(wrapper.getByText("Lost password?"));
fireEvent.click(wrapper.getByText("Recover Password"));

const dialogTitle = wrapper.queryByRole("heading", { level: 2, name: "Recover Password" });
expect(dialogTitle).toBeInTheDocument();
@@ -65,12 +70,14 @@ describe("Navbar - AuthModal - LoginForm", () => {

const { getByRole, getByLabelText } = renderWithStoreAndTheme(
<SnackbarProvider maxSnack={3}>
<AuthModal
open
toggleAuthModal={toggleAuthModal}
toggleLoginPending={() => {}}
updateSessionInfo={() => {}}
/>
<BrowserRouter>
<AuthModal
open
toggleAuthModal={toggleAuthModal}
toggleLoginPending={() => {}}
updateSessionInfo={() => {}}
/>
</BrowserRouter>
</SnackbarProvider>, { initialState: {}, theme });

await act(async () => {
@@ -90,12 +97,14 @@ describe("Navbar - AuthModal - LoginForm", () => {

const wrapper = renderWithStoreAndTheme(
<SnackbarProvider maxSnack={3}>
<AuthModal
open
toggleAuthModal={() => {}}
toggleLoginPending={() => {}}
updateSessionInfo={() => {}}
/>
<BrowserRouter>
<AuthModal
open
toggleAuthModal={() => {}}
toggleLoginPending={() => {}}
updateSessionInfo={() => {}}
/>
</BrowserRouter>
</SnackbarProvider>, { initialState: {}, theme });


@@ -131,12 +140,14 @@ describe("Navbar - AuthModal - LoginForm", () => {

const wrapper = renderWithStoreAndTheme(
<SnackbarProvider maxSnack={3}>
<AuthModal
open
toggleAuthModal={() => {}}
toggleLoginPending={() => {}}
updateSessionInfo={() => {}}
/>
<BrowserRouter>
<AuthModal
open
toggleAuthModal={() => {}}
toggleLoginPending={() => {}}
updateSessionInfo={() => {}}
/>
</BrowserRouter>
</SnackbarProvider>, { initialState: {}, theme });

await act(async () => {
@@ -159,12 +170,14 @@ describe("Navbar - AuthModal - LoginForm", () => {

const wrapper = renderWithStoreAndTheme(
<SnackbarProvider maxSnack={3}>
<AuthModal
open
toggleAuthModal={() => {}}
toggleLoginPending={() => {}}
updateSessionInfo={() => {}}
/>
<BrowserRouter>
<AuthModal
open
toggleAuthModal={() => {}}
toggleLoginPending={() => {}}
updateSessionInfo={() => {}}
/>
</BrowserRouter>
</SnackbarProvider>, { initialState: {}, theme });

await act(async () => {
8 changes: 4 additions & 4 deletions src/components/Navbar/Auth/PasswordRecoveryFinishForm.js
Original file line number Diff line number Diff line change
@@ -137,15 +137,15 @@ const PasswordRecoveryFinishForm = ({ token, toggleAuthModal, setLoginPage, setR
disabled={requestPending}
color="secondary"
>
Login
Login
</Button>
<Button
onClick={toggleAuthModal}
variant="text"
color="secondary"
disabled={requestPending}
>
Cancel
Cancel
</Button>
<div className={classes.loginBtnWrapper}>
{
@@ -158,7 +158,7 @@ const PasswordRecoveryFinishForm = ({ token, toggleAuthModal, setLoginPage, setR
variant="contained"
disabled={requestPending}
>
Recover Password
Recover Password
</Button>
{requestPending &&
<CircularProgress
@@ -175,7 +175,7 @@ const PasswordRecoveryFinishForm = ({ token, toggleAuthModal, setLoginPage, setR
disabled={requestPending}
color="secondary"
>
Lost password?
Lost password?
</Button>
)
}
4 changes: 2 additions & 2 deletions src/components/Navbar/Auth/PasswordRecoveryForm.js
Original file line number Diff line number Diff line change
@@ -95,15 +95,15 @@ const PasswordRecoveryForm = ({ toggleAuthModal, setLoginPage }) => {
disabled={requestPending}
color="secondary"
>
Login
Login
</Button>
<Button
onClick={toggleAuthModal}
variant="text"
color="secondary"
disabled={requestPending}
>
Cancel
Cancel
</Button>
{
!success &&
57 changes: 34 additions & 23 deletions src/components/Navbar/Auth/PasswordRecoveryForm.spec.js
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@ import { fireEvent, act, renderWithStoreAndTheme } from "../../../test-utils";
import Constants from "../../../utils/Constants";
import { SnackbarProvider } from "notistack";
import { createTheme } from "@material-ui/core";
import { BrowserRouter } from "react-router-dom/cjs/react-router-dom.min";

jest.mock("../../../services/auth");

@@ -31,11 +32,13 @@ describe("Navbar - AuthModal - PasswordRecoveryForm", () => {

const wrapper = renderWithStoreAndTheme(
<SnackbarProvider maxSnack={3}>
<AuthModal open toggleAuthModal={toggleAuthModal} initialPage={1} />
<BrowserRouter>
<AuthModal open toggleAuthModal={toggleAuthModal} initialPage={1} />
</BrowserRouter>
</SnackbarProvider>, { initialState: {}, theme }
);

fireEvent.click(wrapper.getByText("Lost password?"));
fireEvent.click(wrapper.getByText("Recover Password"));

const dialogTitle = wrapper.queryByRole("heading", { level: 2, name: "Recover Password" });
expect(dialogTitle).toBeInTheDocument();
@@ -51,11 +54,13 @@ describe("Navbar - AuthModal - PasswordRecoveryForm", () => {

const wrapper = renderWithStoreAndTheme(
<SnackbarProvider maxSnack={3}>
<AuthModal open toggleAuthModal={toggleAuthModal} initialPage={1} />
<BrowserRouter>
<AuthModal open toggleAuthModal={toggleAuthModal} initialPage={1} />
</BrowserRouter>
</SnackbarProvider>, { initialState: {}, theme }
);

fireEvent.click(wrapper.getByText("Lost password?"));
fireEvent.click(wrapper.getByText("Recover Password"));


fireEvent.click(wrapper.getByText("Login"));
@@ -73,15 +78,17 @@ describe("Navbar - AuthModal - PasswordRecoveryForm", () => {

const wrapper = renderWithStoreAndTheme(
<SnackbarProvider maxSnack={3}>
<AuthModal
open
toggleAuthModal={toggleAuthModal}
updateSessionInfo={() => {}}
/>
<BrowserRouter>
<AuthModal
open
toggleAuthModal={toggleAuthModal}
updateSessionInfo={() => {}}
/>
</BrowserRouter>
</SnackbarProvider>, { initialState: {}, theme }
);

fireEvent.click(wrapper.getByText("Lost password?"));
fireEvent.click(wrapper.getByText("Recover Password"));


await act(async () => {
@@ -102,15 +109,17 @@ describe("Navbar - AuthModal - PasswordRecoveryForm", () => {
it("Should not allow invalid email", async () => {
const wrapper = renderWithStoreAndTheme(
<SnackbarProvider maxSnack={3}>
<AuthModal
open
toggleAuthModal={() => {}}
updateSessionInfo={() => {}}
/>
<BrowserRouter>
<AuthModal
open
toggleAuthModal={() => {}}
updateSessionInfo={() => {}}
/>
</BrowserRouter>
</SnackbarProvider>, { initialState: {}, theme }
);

fireEvent.click(wrapper.getByText("Lost password?"));
fireEvent.click(wrapper.getByText("Recover Password"));


await act(async () => {
@@ -151,16 +160,18 @@ describe("Navbar - AuthModal - PasswordRecoveryForm", () => {

const wrapper = renderWithStoreAndTheme(
<SnackbarProvider maxSnack={3}>
<AuthModal
open
toggleAuthModal={() => {}}
toggleLoginPending={() => {}}
updateSessionInfo={() => {}}
/>
<BrowserRouter>
<AuthModal
open
toggleAuthModal={() => {}}
toggleLoginPending={() => {}}
updateSessionInfo={() => {}}
/>
</BrowserRouter>
</SnackbarProvider>, { initialState: {}, theme }
);

fireEvent.click(wrapper.getByText("Lost password?"));
fireEvent.click(wrapper.getByText("Recover Password"));

await act(async () => {
await fireEvent.change(wrapper.getByLabelText("Email"), { target: { value: "asd@email.com" } });
10 changes: 10 additions & 0 deletions src/components/Navbar/Auth/authStyles.js
Original file line number Diff line number Diff line change
@@ -20,4 +20,14 @@ export default makeStyles((theme) => ({
composes: "$loginProgress",
color: theme.palette.primary.main,
},
modalOptions: {
display: "flex",
flexDirection: "row",
alignItems: "center",
marginLeft: theme.spacing(3),
fontSize: "4px",
},
smallText: {
fontSize: "0.85rem",
},
}));
1 change: 1 addition & 0 deletions src/components/Navbar/UserMenu.js
Original file line number Diff line number Diff line change
@@ -182,6 +182,7 @@ const UserMenuContent = React.forwardRef(({ open, isMobile = false, sessionData,
);
});

UserMenuContent.displayName = "UserMenuContent";
UserMenuContent.propTypes = {
open: PropTypes.bool.isRequired,
isMobile: PropTypes.bool,
19 changes: 16 additions & 3 deletions src/components/Navbar/index.js
Original file line number Diff line number Diff line change
@@ -17,7 +17,7 @@ import useSession from "../../hooks/useSession";
import UserMenu from "./UserMenu";

import { useMobile } from "../../utils/media-queries";
import { MenuRounded, Home } from "@material-ui/icons";
import { MenuRounded, Home, ExitToApp } from "@material-ui/icons";


import useNavbarStyles from "./navbarStyles";
@@ -67,10 +67,10 @@ const Navbar = ({
data-testid="navbar"
>
<Toolbar className={classes.toolbar}>
<div className={ classes.homePageLink }>
<div>
{showHomePageLink &&
<Link to="/" className={classes.linkStyle}>
<Home className={classes.homeIcon} />
<Home className={classes.homeIcon} />
{desktopLayout && "HOMEPAGE"}
</Link>
}
@@ -125,6 +125,19 @@ const Navbar = ({
toggleAuthModal={toggleAuthModal}
addSnackbar={addSnackbar}
/>
<div>
{showHomePageLink && !isLoggedIn &&
<Button
className={classes.linkStyle}
variant="text"
color="primary"
onClick={toggleAuthModal}
>
{!desktopLayout && <ExitToApp />}
{desktopLayout && "SIGN IN"}
</Button>
}
</div>
</Toolbar>
</AppBar>
);
6 changes: 2 additions & 4 deletions src/components/Navbar/navbarStyles.js
Original file line number Diff line number Diff line change
@@ -5,11 +5,9 @@ export default makeStyles((theme) => ({
navbar: ({ isMobile }) => ({
paddingTop: theme.spacing(isMobile ? 0 : 3),
}),
toolbar: {
toolbar: ({ isMobile }) => ({
display: "flex",
},
homePageLink: ({ isMobile }) => ({
marginLeft: theme.spacing(isMobile ? 0 : 3),
paddingInline: theme.spacing(isMobile ? 1 : 6),
}),
linkStyle: ({ desktopLayout }) => ({
textDecoration: "none",
6 changes: 4 additions & 2 deletions src/components/Offers/Edit/EditOfferForm.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useCallback, useContext, useEffect, useState } from "react";
import { OfferConstants, parseRequestErrors } from "../Form/OfferUtils";
import { OfferConstants, parseApplyURL, parseRequestErrors } from "../Form/OfferUtils";
import OfferForm from "../Form/form-components/OfferForm";
import { editOffer } from "../../../services/offerService";
import { Redirect, useLocation, useParams } from "react-router-dom";
@@ -28,6 +28,7 @@ const parseOfferForm = ({
isPaid,
vacancies,
description,
applyURL,
...offer
}) => ({
jobDuration: [
@@ -41,6 +42,7 @@ const parseOfferForm = ({
vacancies: vacancies || "",
description,
descriptionText: parseDescription(description),
applyURL: applyURL.startsWith("mailto:") ? applyURL.substring(7) : applyURL,
...offer,
});

@@ -107,7 +109,7 @@ export const EditOfferController = () => {
requirements: data.requirements.map((val) => val.value),
isPaid: data.isPaid === "none" ? null : data.isPaid,
jobStartDate: !data.jobStartDate ? null : data.jobStartDate,
applyURL: data.applyURL || null,
applyURL: parseApplyURL(data.applyURL),
jobMinDuration,
jobMaxDuration,
})
80 changes: 26 additions & 54 deletions src/components/Offers/Edit/EditOfferForm.spec.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
import React from "react";
import DateFnsUtils from "@date-io/date-fns";
import { createTheme } from "@material-ui/core/styles";
import useComponentController from "../../../hooks/useComponentController";
import { BrowserRouter } from "react-router-dom";
import { screen, renderWithStoreAndTheme } from "../../../test-utils";
import useSession from "../../../hooks/useSession";
import { MuiPickersUtilsProvider } from "@material-ui/pickers";
import DateFnsUtils from "@date-io/date-fns";
import { format } from "date-fns";
import { searchCities } from "../../../services/locationSearchService";
import { fireEvent } from "@testing-library/dom";
import { act } from "@testing-library/react";
import { DAY_IN_MS, MONTH_IN_MS } from "../../../utils/TimeUtils";
import { PAID_OPTIONS } from "../Form/form-components/OfferForm";
import { EditOfferController, EditOfferControllerContext } from "./EditOfferForm";
import EditOfferPage from "../../../pages/EditOfferPage";
import { format } from "date-fns";
import React from "react";
import { BrowserRouter } from "react-router-dom";
import { Route, Switch } from "react-router-dom/cjs/react-router-dom.min";
import useComponentController from "../../../hooks/useComponentController";
import useOffer from "../../../hooks/useOffer";
import useSession from "../../../hooks/useSession";
import EditOfferPage from "../../../pages/EditOfferPage";
import { renderWithStoreAndTheme, screen } from "../../../test-utils";
import { HumanValidationReasons } from "../../../utils";
import { DAY_IN_MS, MONTH_IN_MS } from "../../../utils/TimeUtils";
import Offer from "../../HomePage/SearchResultsArea/Offer/Offer";
import { fireEvent } from "@testing-library/dom";
import JobOptions from "../../utils/offers/JobOptions";
import FieldOptions from "../../utils/offers/FieldOptions";
import JobOptions from "../../utils/offers/JobOptions";
import TechOptions from "../../utils/offers/TechOptions";
import { Route, Switch } from "react-router-dom/cjs/react-router-dom.min";
import { HumanValidationReasons } from "../../../utils";
import { PAID_OPTIONS } from "../Form/form-components/OfferForm";
import { EditOfferController, EditOfferControllerContext } from "./EditOfferForm";

jest.mock("../../../hooks/useOffer");
jest.mock("react-router-dom", () => {
@@ -114,7 +113,7 @@ describe("Edit Offer Form", () => {
key="/"
>
<div>
Test Redirect
Test Redirect
</div>
</Route>
<MuiPickersUtilsProvider utils={DateFnsUtils}>
@@ -143,7 +142,7 @@ describe("Edit Offer Form", () => {
key="/"
>
<div>
Test Redirect
Test Redirect
</div>
</Route>
<MuiPickersUtilsProvider utils={DateFnsUtils}>
@@ -172,7 +171,7 @@ describe("Edit Offer Form", () => {
key="/"
>
<div>
Test Redirect
Test Redirect
</div>
</Route>
<MuiPickersUtilsProvider utils={DateFnsUtils}>
@@ -499,41 +498,6 @@ describe("Edit Offer Form", () => {
expect(await wrapper.findDescriptionOf(input)).toHaveTextContent("Required field.");
});

it("should fail validation if locations not following the regex", async () => {
useSession.mockImplementation(() => ({ isLoggedIn: true, data: { company: { _id: "company_id" } } }));
searchCities.mockImplementation(() => Promise.resolve({ city: "asd", country: "asd" }));
useOffer.mockImplementation(() => ({ offer, loading: false, error: null, mutate: () => {} }));

const wrapper = renderWithStoreAndTheme(
<BrowserRouter>
<MuiPickersUtilsProvider utils={DateFnsUtils}>
<EditOfferWrapper>
<EditOfferPage />
</EditOfferWrapper>
</MuiPickersUtilsProvider>
</BrowserRouter>,
{ initialState, theme }
);

const input = screen.getByLabelText("Location *");

await act(async () => {
await fireEvent.focus(input);
await fireEvent.change(input, { target: { value: "invalid" } });
await fireEvent.blur(input);
});

expect(await wrapper.findDescriptionOf(input))
.toHaveTextContent("The location format must be <city>, <country>. Beware of extra spaces.");

await act(async () => {
await fireEvent.change(input, { target: { value: "city, country" } });
await fireEvent.blur(input);
});

expect(await wrapper.findDescriptionOf(wrapper.getByLabelText("Location *"))).toHaveTextContent("\u200B");
});

it("should allow only numbers in vacancies", async () => {
useSession.mockImplementation(() => ({ isLoggedIn: true, data: { company: { _id: "company_id" } } }));
useOffer.mockImplementation(() => ({ offer, loading: false, error: null, mutate: () => {} }));
@@ -644,6 +608,14 @@ describe("Edit Offer Form", () => {
fireEvent.blur(input);
});

expect(await wrapper.findDescriptionOf(wrapper.getByLabelText("Application URL")))
.not.toHaveTextContent(HumanValidationReasons.BAD_APPLY_URL);

await act(() => {
fireEvent.change(input, { target: { value: "valid@email.com" } });
fireEvent.blur(input);
});

expect(await wrapper.findDescriptionOf(wrapper.getByLabelText("Application URL")))
.not.toHaveTextContent(HumanValidationReasons.BAD_APPLY_URL);
});
2 changes: 0 additions & 2 deletions src/components/Offers/Edit/EditOfferSchema.js
Original file line number Diff line number Diff line change
@@ -59,8 +59,6 @@ export default yup.object().shape({
.required(HumanValidationReasons.REQUIRED),
location: yup.string()
.nullable(true)
.matches(/^([a-zA-Z]+([\s-][a-zA-Z])*)+, ([a-zA-Z]+([\s-][a-zA-Z])*)+$/,
HumanValidationReasons.LOCATION_FORMAT)
.required(HumanValidationReasons.REQUIRED),
requirements: yup.array()
.min(...generateValidationRule("requirements", "minLength", HumanValidationReasons.OPTIONS_TOO_SHORT))
11 changes: 10 additions & 1 deletion src/components/Offers/Form/OfferUtils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { generalHumanError, generalParseRequestErrors, HumanValidationReasons, validationRulesGenerator } from "../../../utils";
import { MailRegex } from "../../../utils/offer/OfferUtils";
import { DAY_IN_MS, MONTH_IN_MS, OFFER_MAX_LIFETIME_MONTHS } from "../../../utils/TimeUtils";
import { MAX_FIELDS, MIN_FIELDS } from "../../utils/offers/FieldOptions";
import { MAX_TECHNOLOGIES, MIN_TECHNOLOGIES } from "../../utils/offers/TechOptions";
@@ -53,9 +54,17 @@ const HumanReadableErrors = Object.freeze({
"company-disabled": () => "Company is disabled. Please enable it or contact the team for help.",
"must-be-ISO8601-date": () => HumanValidationReasons.DATE,
"date-already-past": () => HumanValidationReasons.DATE_EXPIRED,
"invalid-apply-url": () => "Invalid application URL. Ensure your URL starts with 'http(s):' or 'mailto:'",
"invalid-apply-url": () => "Invalid application URL. Ensure your URL starts with 'http(s):' or is a valid email",
});

export const getHumanError = (error) => generalHumanError(error, HumanReadableErrors);

export const parseRequestErrors = (error) => generalParseRequestErrors(error, getHumanError);

export const parseApplyURL = (applyURL) => {
if (!applyURL)
return null;
if (MailRegex.test(applyURL) && !applyURL.startsWith("mailto:"))
return `mailto:${applyURL}`;
return applyURL;
};
Original file line number Diff line number Diff line change
@@ -18,14 +18,14 @@ const ApplyURLComponent = ({ disabled, errors, requestErrors, control, classes }
className={classes.applyURLInput}
value={value}
label="Application URL"
id="applyURL"
id={name}
error={!!errors?.applyURL || !!requestErrors.applyURL}
inputRef={ref}
onBlur={onBlur}
onChange={onChange}
helperText={
`${errors.applyURL?.message ||
requestErrors.applyURL?.message || "Ensure your URL starts with 'http(s):' or 'mailto:'."}`
requestErrors.applyURL?.message || "Ensure your URL starts with 'http(s):' or is a valid email."}`
}
fullWidth
disabled={disabled}
Original file line number Diff line number Diff line change
@@ -19,6 +19,7 @@ const IsHiddenComponent = ({ disabled, control }) => (
checked={value}
onChange={onChange}
name={name}
id={name}
onBlur={onBlur}
disabled={disabled}
/>
Original file line number Diff line number Diff line change
@@ -14,7 +14,7 @@ const IsPaidComponent = ({ disabled, errors, requestErrors, control, textFieldPr
<TextField
name={name}
fullWidth
id="is-paid"
id={name}
select
label="Compensation"
value={value}
Original file line number Diff line number Diff line change
@@ -30,6 +30,7 @@ const JobDurationComponent = ({ disabled, control }) => {
>
<Slider
name={name}
id={name}
value={value}
onChange={(_e, values) => onChange(values)}
onBlur={onBlur}
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ const JobStartDateComponent = ({ disabled, errors, requestErrors, control }) =>
margin="dense"
value={value}
label="Job Start Date"
id="startDate-input"
id={name}
name={name}
onChange={(_, value) => (onChange(value))}
onBlur={onBlur}
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ const JobTypeComponent = ({ disabled, errors, requestErrors, control, textFieldP
<TextField
name={name}
fullWidth
id="job_type"
id={name}
select
label="Job Type *"
value={value ? value : ""}
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ const LocationComponent = ({ disabled, errors, requestErrors, control }) => (
onChange={(_e, value) => onChange(value)}
onBlur={onBlur}
name={name}
id={name}
error={errors.location || requestErrors.location}
disabled={disabled}
/>
29 changes: 28 additions & 1 deletion src/components/Offers/Form/form-components/OfferForm.js
Original file line number Diff line number Diff line change
@@ -48,6 +48,19 @@ export const PAID_OPTIONS = [
];


const scrollToError = (errorArray) => {
if (Object.keys(errorArray).length !== 0) {
const element = document.getElementById(Object.keys(errorArray)[0]);
if (element?.scrollIntoView) {
element.scrollIntoView({ behavior: "smooth" });
}
if (element?.focus) {
element.focus();
}
}
};


const OfferForm = ({ context, title }) => {
const {
submit,
@@ -126,11 +139,20 @@ const OfferForm = ({ context, title }) => {
},
};

useEffect(() => {
scrollToError(errors);
}, [errors]);

useEffect(() => {
scrollToError(requestErrors);
}, [requestErrors]);

return (
success
? <Redirect to={`/offer/${offerId}`} push />
:
<div className={classes.formCard}>

{(state !== "APPROVED") && session.isLoggedIn &&
<Alert
type={"warning"}
@@ -141,6 +163,7 @@ const OfferForm = ({ context, title }) => {
}
</Alert>
}

<CardHeader title={!isMobile && title} />
<Content className={classes.formContent}>
<ConnectedLoginAlert
@@ -202,6 +225,7 @@ const OfferForm = ({ context, title }) => {
) => (
<MultiOptionAutocomplete
name={name}
id={name}
onBlur={onBlur}
error={errors.fields || requestErrors.fields}
disabled={formDisabled}
@@ -227,6 +251,7 @@ const OfferForm = ({ context, title }) => {
) => (
<MultiOptionAutocomplete
name={name}
id={name}
onBlur={onBlur}
error={errors.technologies || requestErrors.technologies}
disabled={formDisabled}
@@ -348,6 +373,7 @@ const OfferForm = ({ context, title }) => {
<MultiOptionTextField
values={contacts}
label="Contacts *"
id="contacts"
itemLabelPrefix="Contact #"
controllerName="contacts"
onAdd={appendContact}
@@ -363,6 +389,7 @@ const OfferForm = ({ context, title }) => {
<MultiOptionTextField
values={requirements}
label="Requirements *"
id="requirements"
itemLabelPrefix="Requirement #"
controllerName="requirements"
onAdd={appendRequirement}
@@ -401,7 +428,7 @@ const OfferForm = ({ context, title }) => {
type="submit"
data-testid="submit-offer"
>
Submit
Submit
</Button>
<div className={classes.requiredFields}>
<Typography>* Required fields</Typography>
Original file line number Diff line number Diff line change
@@ -14,7 +14,7 @@ const OwnerComponent = ({ disabled, errors, requestErrors, control }) => (
name={name}
value={value}
label="Owner ID *"
id="owner"
id={name}
error={!!errors.owner || !!requestErrors.owner}
inputRef={ref}
onBlur={onBlur}
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@ const PublicationDateComponent = ({ disabled, errors, requestErrors, control, da
margin="dense"
value={value}
label="Publication Date *"
id="publishDate-input"
id={name}
name={name}
disabled={disabled}
onChange={(_, value) => onChange(value)}
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@ const PublicationEndDateComponent = ({ fields, disabled, errors, requestErrors,
margin="dense"
value={value}
label="Publication End Date *"
id="publishEndDate-input"
id={name}
name={name}
disabled={disabled}
onChange={(_, value) => {
Original file line number Diff line number Diff line change
@@ -7,14 +7,15 @@ const TextEditorComponent = ({ disabled, errors, requestErrors, control }) => (
<Controller
name="descriptionText"
render={(
{ field: { onChange: onChangeDescriptionText } },
{ field: { onChange: onChangeDescriptionText, name } },
) => (
<Controller
name="description"
render={(
{ field: { onChange: onChangeDescription, value } },
) => (
<TextEditor
id={name}
onChangeDescription={onChangeDescription}
onChangeDescriptionText={onChangeDescriptionText}
error={!!errors?.descriptionText || !!requestErrors?.descriptionText}
Original file line number Diff line number Diff line change
@@ -12,9 +12,9 @@ const TitleComponent = ({ disabled, errors, requestErrors, control }) => (
) => (
<TextField
name={name}
id={name}
value={value}
label="Offer Title *"
id="title"
error={!!errors.title || !!requestErrors.title}
inputRef={ref}
onBlur={onBlur}
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ const VacanciesComponent = ({ disabled, errors, requestErrors, control }) => (
name={name}
value={value}
label="Vacancies"
id="vacancies"
id={name}
disabled={disabled}
error={!!errors?.vacancies || !!requestErrors.vacancies}
helperText={
4 changes: 2 additions & 2 deletions src/components/Offers/New/CreateOfferForm.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useCallback } from "react";
import { parseRequestErrors } from "../Form/OfferUtils";
import { parseApplyURL, parseRequestErrors } from "../Form/OfferUtils";
import { newOffer } from "../../../services/offerService";
import useOfferForm from "../../../hooks/useOfferForm";
import OfferForm from "../Form/form-components/OfferForm";
@@ -22,7 +22,7 @@ export const CreateOfferController = () => {
isPaid: data.isPaid === "none" ? undefined : data.isPaid,
jobStartDate: !data.jobStartDate ? undefined : data.jobStartDate,
owner: data.owner || params.company,
applyURL: data.applyURL || undefined,
applyURL: parseApplyURL(data.applyURL),
jobMinDuration,
jobMaxDuration,
})
78 changes: 40 additions & 38 deletions src/components/Offers/New/CreateOfferForm.spec.js
Original file line number Diff line number Diff line change
@@ -9,7 +9,6 @@ import CreateOfferPage from "../../../pages/CreateOfferPage";
import { MuiPickersUtilsProvider } from "@material-ui/pickers";
import DateFnsUtils from "@date-io/date-fns";
import { format } from "date-fns";
import { searchCities } from "../../../services/locationSearchService";
import { act } from "@testing-library/react";
import { DAY_IN_MS } from "../../../utils/TimeUtils";
import { PAID_OPTIONS } from "../Form/form-components/OfferForm";
@@ -314,41 +313,6 @@ describe("Create Offer Form", () => {

});


it("should fail validation if locations not following the regex", async () => {
useSession.mockImplementation(() => ({ isLoggedIn: true, data: { company: { name: "Company Name" } } }));
searchCities.mockImplementation(() => Promise.resolve({ city: "asd", country: "asd" }));

const wrapper = renderWithStoreAndTheme(
<BrowserRouter>
<MuiPickersUtilsProvider utils={DateFnsUtils}>
<CreateOfferWrapper>
<CreateOfferPage />
</CreateOfferWrapper>
</MuiPickersUtilsProvider>
</BrowserRouter>,
{ initialState, theme }
);

const input = screen.getByLabelText("Location *");

await act(async () => {
await fireEvent.focus(input);
await fireEvent.change(input, { target: { value: "invalid" } });
await fireEvent.blur(input);
});

expect(await wrapper.findDescriptionOf(input))
.toHaveTextContent("The location format must be <city>, <country>. Beware of extra spaces.");

await act(async () => {
await fireEvent.change(input, { target: { value: "city, country" } });
await fireEvent.blur(input);
});

expect(await wrapper.findDescriptionOf(wrapper.getByLabelText("Location *"))).toHaveTextContent("\u200B");
});

it("should fail validation if fields empty", async () => {
useSession.mockImplementation(() => ({ isLoggedIn: true, data: { company: { name: "Company Name" } } }));

@@ -419,7 +383,7 @@ describe("Create Offer Form", () => {
);

// Should work with label but somehow it wasn't being targeted
const input = screen.getByTestId("tech-selector");
const input = screen.getByTestId("technologies");

await act(async () => {

@@ -446,7 +410,7 @@ describe("Create Offer Form", () => {
{ initialState, theme }
);

const input = screen.getByTestId("tech-selector");
const input = screen.getByTestId("technologies");
fireEvent.mouseDown(input);

fireEvent.click(screen.getByText("React"));
@@ -706,6 +670,15 @@ describe("Create Offer Form", () => {

expect(await wrapper.findDescriptionOf(wrapper.getByLabelText("Application URL")))
.not.toHaveTextContent(HumanValidationReasons.BAD_APPLY_URL);

await act(() => {
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "valid@email.com" } });
fireEvent.blur(input);
});

expect(await wrapper.findDescriptionOf(input))
.not.toHaveTextContent(HumanValidationReasons.BAD_APPLY_URL);
});

it("should be visible advanced settings if form error in these publication date", async () => {
@@ -767,5 +740,34 @@ describe("Create Offer Form", () => {
expect(screen.queryByText("Publication End Date *")).toBeVisible();
expect(screen.queryByText("Hide offer")).toBeVisible();
});

it("should scroll when a field has an error", async () => {
useSession.mockImplementation(() => ({ isLoggedIn: true, data: { company: { name: "Company Name" } } }));

renderWithStoreAndTheme(
<BrowserRouter>
<MuiPickersUtilsProvider utils={DateFnsUtils}>
<CreateOfferWrapper>
<CreateOfferPage />
</CreateOfferWrapper>
</MuiPickersUtilsProvider>
</BrowserRouter>,
{ initialState, theme }
);

const scrollIntoViewMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;

const titleInput = screen.getByLabelText("Offer Title *");
const submitButton = screen.getByTestId("submit-offer");

await act(async () => {
await fireEvent.focus(titleInput);
await fireEvent.blur(titleInput);
await fireEvent.click(submitButton);
});

expect(scrollIntoViewMock).toBeCalledWith({ behavior: "smooth" });
});
});
});
2 changes: 0 additions & 2 deletions src/components/Offers/New/CreateOfferSchema.js
Original file line number Diff line number Diff line change
@@ -60,8 +60,6 @@ export default yup.object().shape({
.required(HumanValidationReasons.REQUIRED),
location: yup.string()
.nullable(true)
.matches(/^([a-zA-Z]+([\s-][a-zA-Z])*)+, ([a-zA-Z]+([\s-][a-zA-Z])*)+$/,
HumanValidationReasons.LOCATION_FORMAT)
.required(HumanValidationReasons.REQUIRED),
requirements: yup.array()
.min(...generateValidationRule("requirements", "minLength", HumanValidationReasons.OPTIONS_TOO_SHORT))
Original file line number Diff line number Diff line change
@@ -193,7 +193,7 @@ const ApplicationsReviewWidget = ({ addSnackbar, isMobile = false }) => {
<Grid container alignItems="center">
<Grid item xs={5}>
<Typography className={classes.collapsableTitles} variant="body1">
Actions
Actions
</Typography>
</Grid>
<Grid item xs={7} justifyContent="center">
@@ -214,7 +214,7 @@ const ApplicationsReviewWidget = ({ addSnackbar, isMobile = false }) => {
<div className={classes.payloadSection}>
{showActions && <Divider />}
<Typography className={classes.collapsableTitles} variant="body1">
Requested At
Requested At
</Typography>
<Typography variant="body2">
{row.fields.date.value}
Original file line number Diff line number Diff line change
@@ -824,8 +824,6 @@ describe("Application Review Widget", () => {

it("Should reset filters", async () => {
const applications = generateApplications(5);
console.log("Hello");
console.log(applications);

fetch.mockResponse(JSON.stringify({ applications }));

5 changes: 3 additions & 2 deletions src/components/utils/LocationPicker.js
Original file line number Diff line number Diff line change
@@ -17,7 +17,7 @@ const asyncThrottle = (func, wait) => {
});
};

const LocationPicker = ({ name, label, value, onChange, onBlur, error, disabled }) => {
const LocationPicker = ({ name, id, label, value, onChange, onBlur, error, disabled }) => {
const [inputValue, setInputValue] = useState("");
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
@@ -106,6 +106,7 @@ const LocationPicker = ({ name, label, value, onChange, onBlur, error, disabled
value={value}
inputValue={inputValue}
name={name}
id={id}
disabled={disabled}
onBlur={onBlur}
onChange={(e, newValue) => {
@@ -149,12 +150,12 @@ const LocationPicker = ({ name, label, value, onChange, onBlur, error, disabled
LocationPicker.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
id: PropTypes.string,
value: PropTypes.any,
onChange: PropTypes.func.isRequired,
onBlur: PropTypes.func.isRequired,
error: PropTypes.any,
disabled: PropTypes.bool,

};

export default LocationPicker;
5 changes: 3 additions & 2 deletions src/components/utils/TextEditor.js
Original file line number Diff line number Diff line change
@@ -122,7 +122,7 @@ EditorToolbar.propTypes = {
disabled: PropTypes.bool,
};

const TextEditor = ({ content, onChangeDescription, onChangeDescriptionText, error, helperText: additionalHelperText, disabled }) => {
const TextEditor = ({ id, content, onChangeDescription, onChangeDescriptionText, error, helperText: additionalHelperText, disabled }) => {
const editor = useEditor({
extensions: [
StarterKit,
@@ -177,7 +177,7 @@ const TextEditor = ({ content, onChangeDescription, onChangeDescriptionText, err
{!!editor &&
<FormControl margin="dense" fullWidth>
<EditorToolbar editor={editor} disabled={disabled} />
<EditorContent editor={editor} />
<EditorContent editor={editor} id={id} />
<FormHelperText error={error}>
{helperText}
</FormHelperText>
@@ -188,6 +188,7 @@ const TextEditor = ({ content, onChangeDescription, onChangeDescriptionText, err
};

TextEditor.propTypes = {
id: PropTypes.string,
content: PropTypes.any.isRequired,
onChangeDescription: PropTypes.func.isRequired,
onChangeDescriptionText: PropTypes.func.isRequired,
6 changes: 4 additions & 2 deletions src/components/utils/form/MultiOptionTextField.js
Original file line number Diff line number Diff line change
@@ -41,14 +41,15 @@ const MultiOptionTextField = ({
disabled,
textFieldProps,
addEntryBtnTestId,
id,
}) => {
const classes = useMultiOptionTextFieldStyle();
return (
<>
<Typography variant="h6">
{label}
</Typography>
<Box display="flex" flexDirection="column">
<Box id={id} display="flex" flexDirection="column">
{values.map(({ id }, i) => (
<Controller
key={id}
@@ -85,7 +86,7 @@ const MultiOptionTextField = ({
onClick={() => onAdd()}
className={classes.addEntryBtn}
>
Add Entry
Add Entry
</Button>
{errors ?
<FormHelperText error={!!errors}>
@@ -114,6 +115,7 @@ MultiOptionTextField.propTypes = {
disabled: PropTypes.bool,
textFieldProps: PropTypes.object,
addEntryBtnTestId: PropTypes.string,
id: PropTypes.string,
};

export default MultiOptionTextField;
4 changes: 2 additions & 2 deletions src/components/utils/offers/useFieldSelector.js
Original file line number Diff line number Diff line change
@@ -8,12 +8,12 @@ export default (value, setValues) => {
placeholder: "Fields",
multiple: true,
options: Object.keys(FIELD_OPTIONS),
id: "fields-selector",
id: "fields",
getOptionLabel: (option) => FIELD_OPTIONS[option],
onChange: useCallback((e, value) => value && setValues(value), [setValues]),
value: value,
inputProps: {
"data-testid": "fields-selector",
"data-testid": "fields",
},
};

4 changes: 2 additions & 2 deletions src/components/utils/offers/useTechSelector.js
Original file line number Diff line number Diff line change
@@ -8,12 +8,12 @@ export default (value, setValues) => {
placeholder: "Technologies",
multiple: true,
options: Object.keys(TECH_OPTIONS),
id: "tech-selector",
id: "technologies",
getOptionLabel: (option) => TECH_OPTIONS[option],
onChange: useCallback((e, value) => value && setValues(value), [setValues]),
value: value,
inputProps: {
"data-testid": "tech-selector",
"data-testid": "technologies",
},
};

3 changes: 3 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
export const INITIAL_API_HOSTNAME = process.env.REACT_APP_API_HOSTNAME || "http://localhost:8087";
export const INITIAL_LOCATION_SERVICE_HOSTNAME = process.env.REACT_APP_LOCATION_SERVICE_HOSTNAME || "https://ni.fe.up.pt/nijobs/locations";

const locallyStoredAPIHostname = localStorage.getItem("devTools.API_HOSTNAME");
const locallyStoredLocationServiceHostname = localStorage.getItem("devTools.LOCATION_SERVICE_HOSTNAME");

export default {
API_HOSTNAME: locallyStoredAPIHostname || INITIAL_API_HOSTNAME,
LOCATION_SERVICE_HOSTNAME: locallyStoredLocationServiceHostname || INITIAL_LOCATION_SERVICE_HOSTNAME,
};
Loading

0 comments on commit 23032fa

Please sign in to comment.