diff --git a/src/actions/searchOffersActions.js b/src/actions/searchOffersActions.js index 10e839ae..751cdb0f 100644 --- a/src/actions/searchOffersActions.js +++ b/src/actions/searchOffersActions.js @@ -7,6 +7,7 @@ export const OfferSearchTypes = Object.freeze({ SET_JOB_FIELDS: "SET_JOB_FIELDS", SET_JOB_TECHS: "SET_JOB_TECHS", SET_OFFERS_SEARCH_RESULT: "SET_OFFERS_SEARCH_RESULT", + SET_SEARCH_QUERY_TOKEN: "SET_SEARCH_QUERY_TOKEN", SET_OFFERS_LOADING: "SET_OFFERS_LOADING", SET_SEARCH_OFFERS_ERROR: "SET_SEARCH_OFFERS_ERROR", SET_JOB_DURATION_TOGGLE: "SET_JOB_DURATION_TOGGLE", @@ -21,9 +22,15 @@ export const setLoadingOffers = (loading) => ({ loading, }); -export const setSearchOffers = (offers) => ({ +export const setSearchOffers = (offers, accumulate) => ({ type: OfferSearchTypes.SET_OFFERS_SEARCH_RESULT, offers, + accumulate, +}); + +export const setSearchQueryToken = (queryToken) => ({ + type: OfferSearchTypes.SET_SEARCH_QUERY_TOKEN, + queryToken, }); export const setOffersFetchError = (error) => ({ diff --git a/src/components/HomePage/SearchArea/AdvancedSearch/AdvancedSearchDesktop.spec.js b/src/components/HomePage/SearchArea/AdvancedSearch/AdvancedSearchDesktop.spec.js index a86cdfee..25b067f1 100644 --- a/src/components/HomePage/SearchArea/AdvancedSearch/AdvancedSearchDesktop.spec.js +++ b/src/components/HomePage/SearchArea/AdvancedSearch/AdvancedSearchDesktop.spec.js @@ -2,13 +2,14 @@ import React from "react"; import AdvancedSearchDesktop from "./AdvancedSearchDesktop"; import JobOptions from "../../../utils/offers/JobOptions"; -import { render, screen } from "../../../../test-utils"; +import { renderWithStoreAndTheme, screen } from "../../../../test-utils"; import { AdvancedSearchControllerContext, AdvancedSearchController } from "../SearchArea"; import { fireEvent } from "@testing-library/dom"; import useComponentController from "../../../../hooks/useComponentController"; import FieldOptions from "../../../utils/offers/FieldOptions"; import TechOptions from "../../../utils/offers/TechOptions"; import { INITIAL_JOB_DURATION, INITIAL_JOB_TYPE } from "../../../../reducers/searchOffersReducer"; +import { createTheme } from "@material-ui/core/styles"; const AdvancedSearchWrapper = ({ children, enableAdvancedSearchDefault, showJobDurationSlider, setShowJobDurationSlider, jobMinDuration = INITIAL_JOB_DURATION, @@ -36,13 +37,18 @@ const AdvancedSearchWrapper = ({ }; describe("AdvancedSearchDesktop", () => { + + const theme = createTheme(); + const initialState = {}; + describe("render", () => { it("should render a job selector with all job types", () => { - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); fireEvent.mouseDown(screen.getByLabelText("Job Type")); @@ -60,7 +66,7 @@ describe("AdvancedSearchDesktop", () => { it("should toggle job duration slider (on)", () => { const setShowJobDurationSliderMock = jest.fn(); - render( + renderWithStoreAndTheme( { setShowJobDurationSlider={setShowJobDurationSliderMock} > - + , + { initialState, theme } ); expect(screen.getByText("Job Duration: 1 - 2 months")).not.toBeVisible(); @@ -81,7 +88,7 @@ describe("AdvancedSearchDesktop", () => { it("should toggle job duration slider (off)", () => { const setShowJobDurationSliderMock = jest.fn(); - render( + renderWithStoreAndTheme( { setShowJobDurationSlider={setShowJobDurationSliderMock} > - + , + { initialState, theme } ); @@ -101,10 +109,11 @@ describe("AdvancedSearchDesktop", () => { it("should render a fields selector with all field types", () => { - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); fireEvent.mouseDown(screen.getByLabelText("Fields", { selector: "input" })); @@ -126,10 +135,11 @@ describe("AdvancedSearchDesktop", () => { it("should render a technologies selector with all technology types", () => { - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); fireEvent.mouseDown(screen.getByLabelText("Technologies", { selector: "input" })); @@ -151,12 +161,13 @@ describe("AdvancedSearchDesktop", () => { it("should disable reset button if no advanced field is set", () => { - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); expect(screen.getByRole("button", { name: "Reset Advanced Fields" })).toBeDisabled(); @@ -164,13 +175,14 @@ describe("AdvancedSearchDesktop", () => { it("should enable reset button if some advanced field is set", () => { - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); expect(screen.getByRole("button", { name: "Reset Advanced Fields" })).not.toBeDisabled(); @@ -184,14 +196,15 @@ describe("AdvancedSearchDesktop", () => { const setFieldsMock = jest.fn(); - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); expect(screen.getAllByTestId("chip-option", {})).toHaveLength(1); @@ -213,14 +226,15 @@ describe("AdvancedSearchDesktop", () => { const setTechsMock = jest.fn(); - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); expect(screen.getAllByTestId("chip-option", {})).toHaveLength(1); @@ -241,7 +255,7 @@ describe("AdvancedSearchDesktop", () => { it("should call resetAdvancedSearch when Reset button is clicked", () => { const resetFn = jest.fn(); - render( + renderWithStoreAndTheme( {}} @@ -253,7 +267,8 @@ describe("AdvancedSearchDesktop", () => { technologies={[Object.keys(TechOptions)[0]]} // Must have something set to be able to click reset > - + , + { initialState, theme } ); fireEvent.click(screen.getByRole("button", { name: "Reset Advanced Fields" })); diff --git a/src/components/HomePage/SearchArea/AdvancedSearch/AdvancedSearchMobile.js b/src/components/HomePage/SearchArea/AdvancedSearch/AdvancedSearchMobile.js index 38690e49..4e448da0 100644 --- a/src/components/HomePage/SearchArea/AdvancedSearch/AdvancedSearchMobile.js +++ b/src/components/HomePage/SearchArea/AdvancedSearch/AdvancedSearchMobile.js @@ -176,21 +176,19 @@ const AdvancedSearchMobile = () => { }; AdvancedSearchMobile.propTypes = { - open: PropTypes.bool.isRequired, - close: PropTypes.func.isRequired, - searchValue: PropTypes.string.isRequired, - submitForm: PropTypes.func.isRequired, - setSearchValue: PropTypes.func.isRequired, - resetAdvancedSearch: PropTypes.func.isRequired, - FieldsSelectorProps: PropTypes.object.isRequired, - TechsSelectorProps: PropTypes.object.isRequired, - JobTypeSelectorProps: PropTypes.object.isRequired, - JobDurationSwitchProps: PropTypes.object.isRequired, - ResetButtonProps: PropTypes.object.isRequired, - JobDurationSliderText: PropTypes.string.isRequired, - JobDurationCollapseProps: PropTypes.object.isRequired, - JobDurationSwitchLabel: PropTypes.string.isRequired, - JobDurationSliderProps: PropTypes.object.isRequired, + searchValue: PropTypes.string, + submitForm: PropTypes.func, + setSearchValue: PropTypes.func, + resetAdvancedSearch: PropTypes.func, + FieldsSelectorProps: PropTypes.object, + TechsSelectorProps: PropTypes.object, + JobTypeSelectorProps: PropTypes.object, + JobDurationSwitchProps: PropTypes.object, + ResetButtonProps: PropTypes.object, + JobDurationSliderText: PropTypes.string, + JobDurationCollapseProps: PropTypes.object, + JobDurationSwitchLabel: PropTypes.string, + JobDurationSliderProps: PropTypes.object, onMobileClose: PropTypes.func, }; diff --git a/src/components/HomePage/SearchArea/AdvancedSearch/AdvancedSearchMobile.spec.js b/src/components/HomePage/SearchArea/AdvancedSearch/AdvancedSearchMobile.spec.js index aad5c619..69cbf5b0 100644 --- a/src/components/HomePage/SearchArea/AdvancedSearch/AdvancedSearchMobile.spec.js +++ b/src/components/HomePage/SearchArea/AdvancedSearch/AdvancedSearchMobile.spec.js @@ -8,7 +8,8 @@ import { INITIAL_JOB_DURATION, INITIAL_JOB_TYPE } from "../../../../reducers/sea import { fireEvent } from "@testing-library/dom"; import FieldOptions from "../../../utils/offers/FieldOptions"; import TechOptions from "../../../utils/offers/TechOptions"; -import { render, screen } from "../../../../test-utils"; +import { renderWithStoreAndTheme, screen } from "../../../../test-utils"; +import { createTheme } from "@material-ui/core/styles"; const AdvancedSearchWrapper = ({ children, enableAdvancedSearchDefault, showJobDurationSlider, setShowJobDurationSlider, jobMinDuration = INITIAL_JOB_DURATION, @@ -38,13 +39,18 @@ const AdvancedSearchWrapper = ({ }; describe("AdvancedSearchMobile", () => { + + const theme = createTheme(); + const initialState = {}; + describe("render", () => { it("should render a dialog title with a button to close", () => { - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); expect(screen.getByText("Advanced Search")).toBeInTheDocument(); @@ -53,10 +59,11 @@ describe("AdvancedSearchMobile", () => { }); it("should render a SearchBar", () => { - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); expect(screen.getByLabelText("Search")).toBeInTheDocument(); @@ -64,10 +71,11 @@ describe("AdvancedSearchMobile", () => { it("should render a job selector with all job types", () => { - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); fireEvent.mouseDown(screen.getByLabelText("Job Type")); @@ -85,7 +93,7 @@ describe("AdvancedSearchMobile", () => { it("should toggle job duration slider (on)", () => { const setShowJobDurationSliderMock = jest.fn(); - render( + renderWithStoreAndTheme( { setShowJobDurationSlider={setShowJobDurationSliderMock} > - + , + { initialState, theme } ); expect(screen.getByText("Job Duration: 1 - 2 months")).not.toBeVisible(); @@ -106,7 +115,7 @@ describe("AdvancedSearchMobile", () => { it("should toggle job duration slider (off)", () => { const setShowJobDurationSliderMock = jest.fn(); - render( + renderWithStoreAndTheme( { setShowJobDurationSlider={setShowJobDurationSliderMock} > - + , + { initialState, theme } ); @@ -126,10 +136,11 @@ describe("AdvancedSearchMobile", () => { it("should render a fields selector with all field types", () => { - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); fireEvent.mouseDown(screen.getByLabelText("Fields", { selector: "input" })); @@ -151,10 +162,11 @@ describe("AdvancedSearchMobile", () => { it("should render a technologies selector with all technology types", () => { - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); fireEvent.mouseDown(screen.getByLabelText("Technologies", { selector: "input" })); @@ -176,12 +188,13 @@ describe("AdvancedSearchMobile", () => { it("should disable reset button if no advanced field is set", () => { - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); expect(screen.getByRole("button", { name: "Reset" })).toBeDisabled(); @@ -189,13 +202,14 @@ describe("AdvancedSearchMobile", () => { it("should enable reset button if some advanced field is set", () => { - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); expect(screen.getByRole("button", { name: "Reset" })).not.toBeDisabled(); @@ -208,14 +222,15 @@ describe("AdvancedSearchMobile", () => { const setFieldsMock = jest.fn(); - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); expect(screen.getAllByTestId("chip-option", {})).toHaveLength(1); @@ -237,14 +252,15 @@ describe("AdvancedSearchMobile", () => { const setTechsMock = jest.fn(); - render( + renderWithStoreAndTheme( - + , + { initialState, theme } ); expect(screen.getAllByTestId("chip-option", {})).toHaveLength(1); @@ -265,7 +281,7 @@ describe("AdvancedSearchMobile", () => { it("should call resetAdvancedSearch when Reset button is clicked", () => { const resetFn = jest.fn(); - render( + renderWithStoreAndTheme( {}} @@ -278,7 +294,8 @@ describe("AdvancedSearchMobile", () => { technologies={[Object.keys(TechOptions)[0]]} // Must have something set to be able to click reset > - + , + { initialState, theme } ); fireEvent.click(screen.getByRole("button", { name: "Reset" })); diff --git a/src/components/HomePage/SearchArea/SearchArea.js b/src/components/HomePage/SearchArea/SearchArea.js index 90cc93e5..b57c355c 100644 --- a/src/components/HomePage/SearchArea/SearchArea.js +++ b/src/components/HomePage/SearchArea/SearchArea.js @@ -2,7 +2,7 @@ import React, { useCallback } from "react"; import PropTypes from "prop-types"; import { connect } from "react-redux"; -import { searchOffers } from "../../../services/offerService"; +import { SearchResultsConstants } from "../SearchResultsArea/SearchResultsWidget/SearchResultsUtils"; import { setSearchValue, setJobDuration, @@ -25,13 +25,14 @@ import AdvancedOptionsToggle from "./AdvancedOptionsToggle"; import AdvancedSearchMobile from "./AdvancedSearch/AdvancedSearchMobile"; import AdvancedSearchDesktop from "./AdvancedSearch/AdvancedSearchDesktop"; import useComponentController from "../../../hooks/useComponentController"; +import useOffersSearcher from "../SearchResultsArea/SearchResultsWidget/useOffersSearcher"; export const AdvancedSearchControllerContext = React.createContext({}); export const AdvancedSearchController = ({ enableAdvancedSearchDefault, showJobDurationSlider, setShowJobDurationSlider, jobMinDuration, jobMaxDuration, setJobDuration, jobType, setJobType, fields, setFields, technologies, setTechs, - resetAdvancedSearchFields, onSubmit, searchValue, setSearchValue, searchOffers, onMobileClose, + resetAdvancedSearchFields, onSubmit, searchValue, setSearchValue, onMobileClose, }) => { const advancedSearchProps = useAdvancedSearch({ @@ -50,19 +51,21 @@ export const AdvancedSearchController = ({ resetAdvancedSearchFields, }); + const { search: searchOffers } = useOffersSearcher({ + value: searchValue, + jobMinDuration: showJobDurationSlider && jobMinDuration, + jobMaxDuration: showJobDurationSlider && jobMaxDuration, + jobType, + fields, + technologies, + }); + const submitForm = useCallback((e) => { if (e) e.preventDefault(); - searchOffers({ - value: searchValue, - jobMinDuration: showJobDurationSlider && jobMinDuration, - jobMaxDuration: showJobDurationSlider && jobMaxDuration, - jobType, - fields, - technologies, - }); + searchOffers(SearchResultsConstants.INITIAL_LIMIT); if (onSubmit) onSubmit(); - }, [fields, jobMaxDuration, jobMinDuration, jobType, onSubmit, searchOffers, searchValue, showJobDurationSlider, technologies]); + }, [onSubmit, searchOffers]); return { ...advancedSearchProps, @@ -79,7 +82,7 @@ export const AdvancedSearchController = ({ }; }; -export const SearchArea = ({ onSubmit, searchOffers, searchValue, +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 }) => { @@ -97,7 +100,7 @@ export const SearchArea = ({ onSubmit, searchOffers, searchValue, { enableAdvancedSearchDefault, showJobDurationSlider, setShowJobDurationSlider, jobMinDuration, jobMaxDuration, setJobDuration, jobType, setJobType, fields, setFields, technologies, setTechs, - resetAdvancedSearchFields, onSubmit, searchValue, setSearchValue, searchOffers, onMobileClose, + resetAdvancedSearchFields, onSubmit, searchValue, setSearchValue, onMobileClose, }, AdvancedSearchControllerContext ); @@ -107,12 +110,14 @@ export const SearchArea = ({ onSubmit, searchOffers, searchValue,
{!useDesktop() ? - + : - + } ({ }); export const mapDispatchToProps = (dispatch) => ({ - searchOffers: (filters) => dispatch(searchOffers(filters)), setSearchValue: (value) => dispatch(setSearchValue(value)), setJobDuration: (_, value) => dispatch(setJobDuration(...value)), setJobType: (e) => dispatch(setJobType(e.target.value)), diff --git a/src/components/HomePage/SearchArea/SearchArea.spec.js b/src/components/HomePage/SearchArea/SearchArea.spec.js index dfb962f2..fa56d418 100644 --- a/src/components/HomePage/SearchArea/SearchArea.spec.js +++ b/src/components/HomePage/SearchArea/SearchArea.spec.js @@ -8,139 +8,61 @@ import { setTechs, setShowJobDurationSlider, } from "../../../actions/searchOffersActions"; -import SearchBar from "./SearchBar"; -import SubmitSearchButton from "./SubmitSearchButton"; -import { - Paper, - Fab, - createTheme, -} from "@material-ui/core"; -import AdvancedSearchDesktop from "./AdvancedSearch/AdvancedSearchDesktop"; -import AdvancedSearchMobile from "./AdvancedSearch/AdvancedSearchMobile"; -import { mountWithTheme } from "../../../test-utils"; -import AdvancedOptionsToggle from "./AdvancedOptionsToggle"; +import { createTheme } from "@material-ui/core"; +import { renderWithStoreAndTheme, screen, fireEvent, act } from "../../../test-utils"; describe("SearchArea", () => { let onSubmit; const theme = createTheme(); + const initialState = {}; beforeEach(() => { onSubmit = jest.fn(); }); describe("render", () => { - it("should render a paper", () => { - expect( - mountWithTheme( - , - theme - ).find(Paper).exists() - ).toBe(true); - }); - - it("should render a form", () => { - expect(mountWithTheme( - , - theme - ).find("form").first().prop("id")).toEqual("search_form"); - }); - - it("should render a SearchBar", () => { - const searchBar = mountWithTheme( + it("should render a Paper, a Form, a Search Bar, a Search Button and Advanced Options Button", () => { + renderWithStoreAndTheme( , - theme - ).find(SearchBar).first(); - expect(searchBar.exists()).toBe(true); - }); - - it("should render an Advanced Search Area", () => { - const wrapper = mountWithTheme( - , - theme + { initialState, theme } ); - expect(wrapper.find(AdvancedSearchDesktop).exists() || wrapper.find(AdvancedSearchMobile).exists()).toBe(true); - }); - it("should render a SearchButton", () => { - const searchArea = mountWithTheme( - , - theme - ); - const button = searchArea.find(SubmitSearchButton).first(); - expect(button.exists()).toBe(true); - }); - - it("should render an Advanced Options Button with the correct icon", () => { - const searchValue = "test"; - const setSearchValue = () => {}; - const submitSearchForm = () => {}; - - const wrapper = mountWithTheme( - , - theme - ); - expect(wrapper.find(AdvancedOptionsToggle).exists()).toBe(true); + expect(screen.getByTestId("search-area-paper")).toBeInTheDocument(); + expect(screen.getByTestId("search_form")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Search" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Search" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Toggle Advanced Search" })).toBeInTheDocument(); }); }); describe("interaction", () => { - it("should call onSubmit callback on form submit", () => { - const addSnackbar = () => {}; - const searchOffersMock = jest.fn(); - - // Simulate request success - fetch.mockResponse(JSON.stringify({ mockData: true })); - - const form = mountWithTheme( - , - theme - ).find("form#search_form").first(); - - form.simulate("submit", { - preventDefault: () => {}, - }); - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(searchOffersMock).toHaveBeenCalledTimes(1); - }); - - it("should call searchOffers and onSubmit callback on search button click", () => { + it("should call onSubmit callback on search button click", async () => { const searchValue = "test"; const setSearchValue = () => {}; - const searchOffers = jest.fn(); const onSubmit = jest.fn(); const addSnackbar = () => {}; // Simulate request success fetch.mockResponse(JSON.stringify({ mockData: true })); - const wrapper = mountWithTheme( + renderWithStoreAndTheme( , - theme + { initialState, theme } ); - wrapper.find(Fab).simulate("click", { preventDefault: () => {} }); - expect(searchOffers).toHaveBeenCalledTimes(1); + const searchButton = screen.getByRole("button", { name: "Search" }); + + await act(async () => { + await fireEvent.click(searchButton); + }); + expect(onSubmit).toHaveBeenCalledTimes(1); }); diff --git a/src/components/HomePage/SearchResultsArea/Offer/OfferItem.js b/src/components/HomePage/SearchResultsArea/Offer/OfferItem.js index 142cf37a..2a83c5b4 100644 --- a/src/components/HomePage/SearchResultsArea/Offer/OfferItem.js +++ b/src/components/HomePage/SearchResultsArea/Offer/OfferItem.js @@ -67,7 +67,7 @@ const OfferItem = ({ offer, offerIdx, selectedOfferIdx, setSelectedOfferIdx, loa {loading ? - + : ( + + {dividerOnTop && } + + + + + + + +); + +LoadingOfferItem.propTypes = { + dividerOnTop: PropTypes.bool, +}; + +export default LoadingOfferItem; diff --git a/src/components/HomePage/SearchResultsArea/SearchResultsWidget/OfferItemsContainer.js b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/OfferItemsContainer.js index eece57d2..51d9b46a 100644 --- a/src/components/HomePage/SearchResultsArea/SearchResultsWidget/OfferItemsContainer.js +++ b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/OfferItemsContainer.js @@ -1,19 +1,14 @@ -import React from "react"; +import React, { useCallback, useState, useEffect } from "react"; import PropTypes from "prop-types"; -import Offer from "../Offer/Offer"; -import OfferItem from "../Offer/OfferItem"; - -import { - Button, - Divider, - List, - ListItem, - makeStyles, -} from "@material-ui/core"; +import clsx from "clsx"; +import { Button, Divider, List, ListItem, makeStyles } from "@material-ui/core"; +import { Tune } from "@material-ui/icons"; +import OfferItem from "../Offer/OfferItem"; import useSearchResultsWidgetStyles from "./searchResultsWidgetStyles"; -import { Tune } from "@material-ui/icons"; -import clsx from "clsx"; +import LoadingOfferItem from "./LoadingOfferItem"; +import Offer from "../Offer/Offer"; +import { SearchResultsConstants } from "./SearchResultsUtils"; const useAdvancedSearchButtonStyles = makeStyles((theme) => ({ root: { @@ -52,65 +47,130 @@ ToggleFiltersButton.propTypes = { enabled: PropTypes.bool, }; -const OfferItemsContainer = ({ offers, loading, selectedOfferIdx, setSelectedOfferIdx, showSearchFilters, toggleShowSearchFilters }) => { +const OfferItemsContainer = ({ + initialOffersLoading, + selectedOfferIdx, + setSelectedOfferIdx, + showSearchFilters, + toggleShowSearchFilters, + offers, + moreOffersLoading, + loadMoreOffers, + searchQueryToken, +}) => { const classes = useSearchResultsWidgetStyles(); - if (loading) return ( -
- - - - - - - - -
- ); + const [offerResultsWrapperNode, setOfferResultsWrapperNode] = useState(null); + const [scrollPercentage, setScrollPercentage] = useState(0); + const [hasScroll, setHasScroll] = useState(undefined); + + const isVerticalScrollable = useCallback((node) => { + const overflowY = window.getComputedStyle(node)["overflow-y"]; + return (overflowY === "scroll" || overflowY === "auto") && node.scrollHeight > node.clientHeight; + }, []); + + const refetchTriggerRef = useCallback((node) => { + if (node) setOfferResultsWrapperNode(node.parentElement); + }, []); + + const onScroll = useCallback(() => { + if (offerResultsWrapperNode) { + setScrollPercentage( + 100 * offerResultsWrapperNode.scrollTop + / (offerResultsWrapperNode.scrollHeight - offerResultsWrapperNode.clientHeight) + ); + + } + }, [offerResultsWrapperNode]); + + useEffect(() => { + if (!offerResultsWrapperNode) return; + + offerResultsWrapperNode.addEventListener("scroll", onScroll); + // eslint-disable-next-line consistent-return + return () => offerResultsWrapperNode.removeEventListener("scroll", onScroll); + }, [offerResultsWrapperNode, onScroll]); + + useEffect(() => { + if (!offerResultsWrapperNode) return; + + setHasScroll(isVerticalScrollable(offerResultsWrapperNode)); + }, [isVerticalScrollable, offerResultsWrapperNode, offers, initialOffersLoading, scrollPercentage, moreOffersLoading]); + + useEffect(() => { + if (initialOffersLoading) { + setHasScroll(undefined); + setScrollPercentage(0); + } + }, [initialOffersLoading]); + + useEffect(() => { + + if (initialOffersLoading || moreOffersLoading) { + return; + } + + if (scrollPercentage > 80 || hasScroll === false) loadMoreOffers(searchQueryToken, SearchResultsConstants.FETCH_NEW_OFFERS_LIMIT); + }, [hasScroll, initialOffersLoading, loadMoreOffers, moreOffersLoading, scrollPercentage, searchQueryToken]); const handleOfferSelection = (...args) => { toggleShowSearchFilters(false); setSelectedOfferIdx(...args); }; + if (initialOffersLoading) + return ( +
+ +
+ ); + return ( -
+
toggleShowSearchFilters()} /> - {offers.map((offer, i) => ( - - {i !== 0 && } - - - ))} +
+ {offers?.map((offer, i) => ( +
+ {i !== 0 && } + +
+ ))} +
+ {moreOffersLoading && }
); }; OfferItemsContainer.propTypes = { - offers: PropTypes.arrayOf(PropTypes.instanceOf(Offer)), - loading: PropTypes.bool, + initialOffersLoading: PropTypes.bool, + moreOffersLoading: PropTypes.bool, selectedOfferIdx: PropTypes.number, setSelectedOfferIdx: PropTypes.func.isRequired, showSearchFilters: PropTypes.bool, toggleShowSearchFilters: PropTypes.func.isRequired, + offers: PropTypes.arrayOf(PropTypes.instanceOf(Offer)), + loadMoreOffers: PropTypes.func, + searchQueryToken: PropTypes.string, }; + export default OfferItemsContainer; diff --git a/src/components/HomePage/SearchResultsArea/SearchResultsWidget/OfferItemsContainer.spec.js b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/OfferItemsContainer.spec.js index 947d904f..bc52bd10 100644 --- a/src/components/HomePage/SearchResultsArea/SearchResultsWidget/OfferItemsContainer.spec.js +++ b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/OfferItemsContainer.spec.js @@ -1,5 +1,6 @@ -import { getByRole, getByText, render, screen } from "@testing-library/react"; +import { getByRole, getByText, screen } from "@testing-library/react"; import React from "react"; +import { render } from "../../../../test-utils"; import Offer from "../Offer/Offer"; import OfferItemsContainer from "./OfferItemsContainer"; @@ -9,9 +10,10 @@ describe("OfferItemsContainer", () => { it("should show loading state when loading", () => { render( {}} toggleShowSearchFilters={() => {}} + loadMoreOffers={() => {}} /> ); expect(screen.getAllByTestId("offer-item-loading")).toHaveLength(3); @@ -46,10 +48,12 @@ describe("OfferItemsContainer", () => { render( {}} toggleShowSearchFilters={() => {}} - />); + loadMoreOffers={() => {}} + /> + ); const items = await screen.findAllByTestId("offer-item"); expect(items).toHaveLength(2); expect(getByText(items[0], offers[0].title)).toBeInTheDocument(); diff --git a/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsDesktop.js b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsDesktop.js index 591ebd20..9058ecd1 100644 --- a/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsDesktop.js +++ b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsDesktop.js @@ -10,8 +10,9 @@ import { WorkOff } from "@material-ui/icons"; import clsx from "clsx"; import { SearchResultsControllerContext } from "./SearchResultsWidget"; -const OffersList = ({ noOffers, classes, offers, selectedOfferIdx, offersLoading, setSelectedOfferIdx, - showSearchFilters, toggleShowSearchFilters, +const OffersList = ({ + noOffers, classes, selectedOfferIdx, offersLoading, setSelectedOfferIdx, + showSearchFilters, toggleShowSearchFilters, offers, moreOffersLoading, loadMoreOffers, searchQueryToken, }) => ( @@ -23,13 +24,16 @@ const OffersList = ({ noOffers, classes, offers, selectedOfferIdx, offersLoading
: } { handleAdminEnableOffer, showSearchFilters, toggleShowSearchFilters, + moreOffersLoading, + loadMoreOffers, + searchQueryToken, } = useContext(SearchResultsControllerContext); const classes = useSearchResultsWidgetStyles(); @@ -148,12 +158,15 @@ const SearchResultsDesktop = () => { {showSearchFilters ? ( +const OffersList = ({ + noOffers, classes, offersLoading, showOfferDetails, showSearchFilters, + toggleShowSearchFilters, offers, moreOffersLoading, loadMoreOffers, searchQueryToken, +}) => ( {noOffers ? @@ -22,12 +25,15 @@ const OffersList = ({ noOffers, classes, offers, offersLoading, showOfferDetails : } @@ -43,11 +49,14 @@ OffersList.propTypes = { noOffersColumn: PropTypes.string.isRequired, offerItemsContainer: PropTypes.string.isRequired, }), - offers: PropTypes.arrayOf(PropTypes.instanceOf(Offer)), offersLoading: PropTypes.bool, showOfferDetails: PropTypes.func.isRequired, showSearchFilters: PropTypes.bool.isRequired, toggleShowSearchFilters: PropTypes.func.isRequired, + offers: PropTypes.arrayOf(PropTypes.instanceOf(Offer)), + moreOffersLoading: PropTypes.bool, + loadMoreOffers: PropTypes.func, + searchQueryToken: PropTypes.string, }; export const OfferViewer = ({ @@ -118,6 +127,9 @@ const SearchResultsMobile = () => { handleAdminEnableOffer, showSearchFilters, toggleShowSearchFilters, + moreOffersLoading, + loadMoreOffers, + searchQueryToken, } = useContext(SearchResultsControllerContext); const showOfferDetails = (offerIdx) => { @@ -141,11 +153,14 @@ const SearchResultsMobile = () => { {showSearchFilters ? { describe("render", () => { it("Should render offers if present", () => { - const context = { offers }; + const context = { offers, loadMoreOffers: () => {} }; render( @@ -89,6 +89,7 @@ describe("SearchResultsMobile", () => { setSelectedOfferIdx: setSelectedOfferIdxMock, selectedOfferIdx: 0, toggleShowSearchFilters: () => {}, + loadMoreOffers: () => {}, }; renderWithStoreAndTheme( diff --git a/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsUtils.js b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsUtils.js new file mode 100644 index 00000000..64949b6e --- /dev/null +++ b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsUtils.js @@ -0,0 +1,4 @@ +export const SearchResultsConstants = { + INITIAL_LIMIT: 15, + FETCH_NEW_OFFERS_LIMIT: 10, +}; diff --git a/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsWidget.js b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsWidget.js index f15e7966..7b0b7e0a 100644 --- a/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsWidget.js +++ b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsWidget.js @@ -22,17 +22,21 @@ import { disableOffer, hideOffer, } from "../../../../actions/searchOffersActions"; +import useOffersSearcher from "./useOffersSearcher"; +import { SearchResultsConstants } from "./SearchResultsUtils"; export const SearchResultsControllerContext = React.createContext({}); const SearchResultsController = ({ + offers, offersSearchError, offersLoading, - offers, hideOffer, disableOffer, companyEnableOffer, adminEnableOffer, + searchFilters, + searchQueryToken, }) => { const [selectedOfferIdx, setSelectedOfferIdx] = useState(null); @@ -42,6 +46,8 @@ const SearchResultsController = ({ if (offersLoading) setSelectedOfferIdx(null); }, [offersLoading]); + const { loadMoreOffers, moreOffersLoading } = useOffersSearcher(searchFilters); + const handleDisableOffer = useCallback(({ offer, adminReason, onSuccess, onError }) => { disableOfferService(offer._id, adminReason).then(() => { disableOffer(selectedOfferIdx, adminReason); @@ -112,6 +118,9 @@ const SearchResultsController = ({ handleAdminEnableOffer, showSearchFilters, toggleShowSearchFilters, + moreOffersLoading, + searchQueryToken, + loadMoreOffers: () => loadMoreOffers(searchQueryToken, SearchResultsConstants.FETCH_NEW_OFFERS_LIMIT), }, }, }; @@ -125,6 +134,8 @@ export const SearchResultsWidget = React.forwardRef(({ disableOffer, companyEnableOffer, adminEnableOffer, + searchFilters, + searchQueryToken, }, ref) => { const classes = useSearchResultsWidgetStyles(); @@ -138,6 +149,8 @@ export const SearchResultsWidget = React.forwardRef(({ disableOffer, companyEnableOffer, adminEnableOffer, + searchFilters, + searchQueryToken, }, SearchResultsControllerContext); return ( @@ -170,12 +183,23 @@ SearchResultsWidget.propTypes = { disableOffer: PropTypes.func, companyEnableOffer: PropTypes.func, adminEnableOffer: PropTypes.func, + searchFilters: PropTypes.object, + searchQueryToken: PropTypes.string, }; -const mapStateToProps = (state) => ({ - offers: state.offerSearch.offers, - offersLoading: state.offerSearch.loading, - offersSearchError: state.offerSearch.error, +const mapStateToProps = ({ offerSearch }) => ({ + offers: offerSearch.offers, + offersLoading: offerSearch.loading, + offersSearchError: offerSearch.error, + searchFilters: { + value: offerSearch.value, + jobMinDuration: offerSearch.jobMinDuration, + jobMaxDuration: offerSearch.jobMaxDuration, + jobType: offerSearch.jobType, + fields: offerSearch.fields, + technologies: offerSearch.technologies, + }, + searchQueryToken: offerSearch.queryToken, }); const mapDispatchToProps = (dispatch) => ({ diff --git a/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsWidget.spec.js b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsWidget.spec.js index 842856fb..ffc9f8dd 100644 --- a/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsWidget.spec.js +++ b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/SearchResultsWidget.spec.js @@ -1,9 +1,11 @@ import React from "react"; import SearchResultsWidget, { SearchResultsControllerContext } from "./SearchResultsWidget"; -import { renderWithStoreAndTheme, screen } from "../../../../test-utils"; +import { renderWithStoreAndTheme, screen, fireEvent, act } from "../../../../test-utils"; import { createTheme } from "@material-ui/core/styles"; import Offer from "../Offer/Offer"; -import { fireEvent } from "@testing-library/dom"; +import { searchOffers } from "../../../../services/offerService"; + +jest.mock("../../../../services/offerService"); describe("SearchResults", () => { const theme = createTheme(); @@ -38,6 +40,8 @@ describe("SearchResults", () => { }, }; + afterEach(() => jest.clearAllMocks()); + it("should display OfferItemsContainer", () => { renderWithStoreAndTheme( @@ -139,7 +143,15 @@ describe("SearchResults", () => { it("should search with updated filters and hide filters on fetch", async () => { - fetch.mockResponse(JSON.stringify(initialState.offerSearch.offers)); + searchOffers.mockImplementation(({ queryToken }) => { + let offers = []; + if (queryToken === null) + offers = initialState.offerSearch.offers; + return { + updatedQueryToken: "123", + results: offers, + }; + }); renderWithStoreAndTheme( @@ -157,16 +169,68 @@ describe("SearchResults", () => { } ); - fireEvent.click(screen.getByRole("button", { name: "Adjust Filters" })); + await act(async () => { + await fireEvent.click(screen.getByRole("button", { name: "Adjust Filters" })); + }); expect(screen.getAllByTestId("offer-item")).toHaveLength(1); - fireEvent.submit(screen.getByRole("form")); + + await act(async () => { + await fireEvent.submit(screen.getByLabelText("Search Area")); + }); // must wait response from server, otherwise it will be 'loading', hence the await + find expect(await screen.findAllByTestId("offer-item")).toHaveLength(2); expect(screen.getByRole("button", { name: "Adjust Filters" })).toBeInTheDocument(); expect(screen.queryByLabelText("Search", { selector: "input" })).not.toBeInTheDocument(); + }); + + it("should fetch initial offers and load more until there are no more", async () => { + + // TODO: discover why the last loadMoreOffers was called with the first mock implementation + + searchOffers + .mockImplementationOnce(() => ({ + updatedQueryToken: "123", + results: [], + })) + .mockImplementationOnce(() => ({ + updatedQueryToken: "456", + results: [initialState.offerSearch.offers[0]], + })) + .mockImplementationOnce(() => ({ + updatedQueryToken: "90", + results: [initialState.offerSearch.offers[1]], + })); + renderWithStoreAndTheme( + + + , + { + initialState: { + ...initialState, + offerSearch: { + ...initialState.offerSearch, + offers: [initialState.offerSearch.offers[0]], + }, + }, + theme, + } + ); + + await act(async () => { + await fireEvent.click(screen.getByRole("button", { name: "Adjust Filters" })); + }); + + await act(async () => { + await fireEvent.submit(screen.getByLabelText("Search Area")); + }); + + await new Promise((r) => setTimeout(r, 2000)); + + // must wait response from server, otherwise it will be 'loading', hence the await + find + expect(await screen.findAllByTestId("offer-item")).toHaveLength(2); }); }); diff --git a/src/components/HomePage/SearchResultsArea/SearchResultsWidget/useOffersSearcher.js b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/useOffersSearcher.js new file mode 100644 index 00000000..b45a951f --- /dev/null +++ b/src/components/HomePage/SearchResultsArea/SearchResultsWidget/useOffersSearcher.js @@ -0,0 +1,96 @@ +import { useCallback, useState, useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { + resetOffersFetchError, + setLoadingOffers, + setOffersFetchError, + setSearchOffers, + setSearchQueryToken, +} from "../../../../actions/searchOffersActions"; +import { + searchOffers, + BadResponseException, + NetworkFailureException, +} from "../../../../services/offerService"; + +export default (filters) => { + + const dispatch = useDispatch(); + const searchQueryToken = useSelector((state) => state.offerSearch.queryToken); + + const [hasMoreOffers, setHasMoreOffers] = useState(true); + const [moreOffersFetchError, setMoreOffersFetchError] = useState(null); + const [moreOffersLoading, setMoreOffersLoading] = useState(false); + + // The "search" and "loadMoreOffers" functions do not share the same state; + // When we run "setHasMoreOffers(false)" on the "loadMoreOffers" function, + // the "search" function does not know that the "hasMoreOffers" variable has changed; + // In the same way, when we run "setHasMoreOffers(true) on the "search" function, + // the "loadMoreOffers" function does not know that the "hasMoreOffers" variable has changed; + // Then, when the "loadMoreOffers" function is executed after a previous execution where the + // "hasMoreOffers" variable became "false", the state of this variable is still false, which + // prevents the fetching of new offers. + // Knowing that, I needed a way to set the "hasMoreOffers" variable to "true" when the previous fact + // happens: setting the "hasMoreOffers" to true when the "queryToken" (which is stored in redux) changes + useEffect(() => { + setHasMoreOffers(true); + }, [searchQueryToken]); + + // isInitialRequest = true on the first time the search request is made + // the following request will have isInitialRequest = false + const loadOffers = useCallback((isInitialRequest) => async (queryToken, limit) => { + + if (isInitialRequest) { + dispatch(resetOffersFetchError()); + dispatch(setLoadingOffers(true)); + setHasMoreOffers(true); + } else { + if (!hasMoreOffers) return; + + setMoreOffersFetchError(null); + setMoreOffersLoading(true); + } + + try { + const { updatedQueryToken, results } = await searchOffers({ queryToken, limit, ...filters }); + + dispatch(setSearchQueryToken(updatedQueryToken)); + dispatch(setSearchOffers(results, !isInitialRequest)); + + if (results.length === 0) setHasMoreOffers(false); + + if (isInitialRequest) { + dispatch(setLoadingOffers(false)); + } else { + setMoreOffersLoading(false); + } + + } catch (e) { + if (e instanceof BadResponseException) { + if (isInitialRequest) { + dispatch(setOffersFetchError(e.error)); + dispatch(setLoadingOffers(false)); + } else { + setMoreOffersFetchError(e.error); + setMoreOffersLoading(false); + } + } else if (e instanceof NetworkFailureException) { + if (isInitialRequest) { + dispatch(setOffersFetchError(e.error)); + dispatch(setLoadingOffers(false)); + } else { + setMoreOffersFetchError(e.error); + setMoreOffersLoading(false); + } + } + } + + }, [dispatch, filters, hasMoreOffers]); + + return { + search: (...args) => loadOffers(true)(null, ...args), + loadMoreOffers: (queryToken, ...args) => loadOffers(false)(queryToken, ...args), + moreOffersLoading, + moreOffersFetchError, + }; +}; diff --git a/src/reducers/searchOffersReducer.js b/src/reducers/searchOffersReducer.js index cc246a3a..c07b18f1 100644 --- a/src/reducers/searchOffersReducer.js +++ b/src/reducers/searchOffersReducer.js @@ -24,9 +24,20 @@ export default (state = initialState, action) => { switch (action.type) { case OfferSearchTypes.SET_OFFERS_SEARCH_RESULT: + { + let offers = action.offers; + if (action.accumulate) { + offers = [...state.offers, ...offers]; + } return { ...state, - offers: action.offers, + offers, + }; + } + case OfferSearchTypes.SET_SEARCH_QUERY_TOKEN: + return { + ...state, + queryToken: action.queryToken, }; case OfferSearchTypes.SET_OFFERS_LOADING: return { diff --git a/src/reducers/searchOffersReducer.spec.js b/src/reducers/searchOffersReducer.spec.js index a2621afc..f213ae62 100644 --- a/src/reducers/searchOffersReducer.spec.js +++ b/src/reducers/searchOffersReducer.spec.js @@ -155,7 +155,7 @@ describe("Search Offers Reducer", () => { adminReason: null, }), new Offer({ - _id: "id1", + _id: "id2", title: "position1", owner: "company_id", ownerName: "company1", @@ -170,7 +170,7 @@ describe("Search Offers Reducer", () => { adminReason: null, }), new Offer({ - _id: "id1", + _id: "id3", title: "position1", owner: "company_id", ownerName: "company1", diff --git a/src/services/offerService.js b/src/services/offerService.js index 802a8128..8dd2ee88 100644 --- a/src/services/offerService.js +++ b/src/services/offerService.js @@ -1,4 +1,3 @@ -import { setLoadingOffers, setSearchOffers, setOffersFetchError, resetOffersFetchError } from "../actions/searchOffersActions"; import Offer from "../components/HomePage/SearchResultsArea/Offer/Offer"; import config from "../config"; import { parseFiltersToURL, buildCancelableRequest } from "../utils"; @@ -15,14 +14,25 @@ const OFFER_HIDE_METRIC_ID = "offer/hide"; const OFFER_DISABLE_METRIC_ID = "offer/disable"; const OFFER_ENABLE_METRIC_ID = "offer/enable"; +export class BadResponseException extends Error { + constructor(error) { + super("Bad Response"); + this.error = error; + } +} -export const searchOffers = (filters) => buildCancelableRequest( - measureTime(TIMED_ACTIONS.OFFER_SEARCH, async (dispatch, { signal }) => { - dispatch(resetOffersFetchError()); - dispatch(setLoadingOffers(true)); +export class NetworkFailureException extends Error { + constructor(error) { + super("Network Failure"); + this.error = error; + } +} +export const searchOffers = buildCancelableRequest( + measureTime(TIMED_ACTIONS.OFFER_SEARCH, async ({ queryToken, limit, ...filters }, { signal }) => { + let isErrorRegistered = false; try { - const query = parseFiltersToURL(filters); + const query = parseFiltersToURL(queryToken ? { queryToken, limit } : { ...filters, limit }); const res = await fetch(`${API_HOSTNAME}/offers?${query}`, { method: "GET", credentials: "include", @@ -31,39 +41,42 @@ export const searchOffers = (filters) => buildCancelableRequest( const json = await res.json(); if (!res.ok) { - dispatch(setOffersFetchError({ - cause: ErrorTypes.BAD_RESPONSE, - error: res.status, - })); - dispatch(setLoadingOffers(false)); - + isErrorRegistered = true; createErrorEvent( OFFER_SEARCH_METRIC_ID, ErrorTypes.BAD_RESPONSE, json.errors, res.status ); - return; + throw new BadResponseException({ + cause: ErrorTypes.BAD_RESPONSE, + error: res.status, + }); } - dispatch(setSearchOffers(json.map((offerData) => new Offer(offerData)))); - dispatch(setLoadingOffers(false)); + const offers = json.results; + const updatedQueryToken = json.queryToken; sendSearchReport(filters, `/offers?${query}`); createEvent(EVENT_TYPES.SUCCESS(OFFER_SEARCH_METRIC_ID, query)); - } catch (error) { - dispatch(setOffersFetchError({ - cause: ErrorTypes.NETWORK_FAILURE, - error, - })); - dispatch(setLoadingOffers(false)); + return { + updatedQueryToken, + results: offers.map((offerData) => new Offer(offerData)), + }; - createErrorEvent( - OFFER_SEARCH_METRIC_ID, - ErrorTypes.BAD_RESPONSE, - Array.isArray(error) ? error : [{ msg: Constants.UNEXPECTED_ERROR_MESSAGE }], - ); + } catch (error) { + if (!isErrorRegistered) { + createErrorEvent( + OFFER_SEARCH_METRIC_ID, + ErrorTypes.BAD_RESPONSE, + Array.isArray(error) ? error : [{ msg: Constants.UNEXPECTED_ERROR_MESSAGE }], + ); + throw new NetworkFailureException({ + cause: ErrorTypes.NETWORK_FAILURE, + error, + }); + } else throw error; } }) );