From f101b8bb03f31e630fe8a7e3283816cbae6ea1ab Mon Sep 17 00:00:00 2001 From: To Huynh <37560480+tohuynh@users.noreply.github.com> Date: Wed, 1 Dec 2021 18:21:54 -0800 Subject: [PATCH] feature/search-page (#120) * Install query-string * WIP, gather the event ids * WIP, render event card * Refactor list of cards into a cards container * Move the search_type into search page dir Add main search state type * Implement search page * Implement search container * Remove council members from search type Remove date range filter from home search bar * Remove search_type from project constants * Add link to card container * Use cards container in events container * Add empty search events page * Change to first-of-type * Fallback to false for option * Change the comittees in search events state to a record * Implement search events page * Implement search events container * Remove props from home search bar * Move the fetching search result logic into a react hook * Add search bar, search page title * Keep only the search state prop * Keep the top 50% style * Use the searbar, search page title * Change to h1 * Remove the initial get call to get events, and let the container get the events * Refactor page container as a flex container * Refactor the show more events into show more cards * Refactor the fetch events msg into fetch cards status * Set initial fetch state to true * Refactor batch size into a project constant * Refactor the data fetching logic into useFetchData * Remove unused hooks * Rewrite fetch hook in events container * Refactor into use search cards hook * Include the function creator in dep array * Install react-hooks linting * Move up the handle search code * Add page titles to /search and /events/search * Allow popup to close on click outside * handle search on popupclose * Merge fetch and filter/sort into one step * Change to size 6 * Remove margin from h1 * Refactor filters container * Combine the margin bottom * Remove not needed onOpen callback * Comment out /search page Route to /events/search from the landing page * Weighted relevance is default sorting algo Change the labels of sorting options --- .eslintrc.js | 1 + package-lock.json | 51 +++- package.json | 2 + src/app/App.tsx | 21 +- .../Filters/EventsFilter/EventsFilter.tsx | 21 +- .../Filters/FilterPopup/FilterPopup.tsx | 3 + .../FiltersContainer/FiltersContainer.tsx | 18 ++ .../Filters/FiltersContainer/index.ts | 1 + .../SelectTextFilterOptions.tsx | 2 +- .../Layout/HomeSearchBar/HomeSearchBar.tsx | 93 ++----- src/components/Layout/HomeSearchBar/index.tsx | 1 + src/components/Shared/FetchCardsStatus.tsx | 10 + src/components/Shared/PageContainer.tsx | 10 + src/components/Shared/SearchBar.tsx | 37 +++ src/components/Shared/SearchPageTitle.tsx | 26 ++ src/components/Shared/ShowMoreCards.tsx | 21 ++ src/constants/ProjectConstants.ts | 8 +- .../CardsContainer/CardsContainer.tsx | 54 ++++ src/containers/CardsContainer/index.ts | 1 + src/containers/CardsContainer/types.ts | 6 + .../EventsContainer/EventsContainer.tsx | 261 +++++++----------- src/containers/EventsContainer/types.ts | 2 - ...eEventsPagination.ts => useFetchEvents.ts} | 79 +++--- .../FetchDataContainer/FetchDataContainer.tsx | 2 +- src/containers/FetchDataContainer/index.ts | 1 + .../FetchDataContainer/useFetchData.ts | 62 ++++- .../SearchContainer/SearchContainer.tsx | 169 ++++++++++++ .../SearchContainer/SearchResultContainer.tsx | 69 +++++ src/containers/SearchContainer/index.ts | 1 + src/containers/SearchContainer/types.ts | 16 ++ .../SearchEventsContainer.tsx | 223 +++++++++++++++ src/containers/SearchEventsContainer/index.ts | 1 + src/containers/SearchEventsContainer/types.ts | 8 + src/hooks/useSearchCards.ts | 104 +++++++ src/index.ts | 4 + src/networking/EventSearchService.ts | 2 +- src/pages/EventPage/EventPage.tsx | 192 ++++++------- src/pages/EventsPage/EventsPage.tsx | 76 ++--- src/pages/PersonPage/PersonPage.tsx | 55 +--- .../SearchEventsPage/SearchEventsPage.tsx | 55 ++++ src/pages/SearchEventsPage/index.ts | 1 + src/pages/SearchEventsPage/types.ts | 7 + src/pages/SearchPage/SearchPage.tsx | 24 +- src/pages/SearchPage/types.ts | 9 + 44 files changed, 1276 insertions(+), 534 deletions(-) create mode 100644 src/components/Filters/FiltersContainer/FiltersContainer.tsx create mode 100644 src/components/Filters/FiltersContainer/index.ts create mode 100644 src/components/Shared/FetchCardsStatus.tsx create mode 100644 src/components/Shared/PageContainer.tsx create mode 100644 src/components/Shared/SearchBar.tsx create mode 100644 src/components/Shared/SearchPageTitle.tsx create mode 100644 src/components/Shared/ShowMoreCards.tsx create mode 100644 src/containers/CardsContainer/CardsContainer.tsx create mode 100644 src/containers/CardsContainer/index.ts create mode 100644 src/containers/CardsContainer/types.ts rename src/containers/EventsContainer/{useEventsPagination.ts => useFetchEvents.ts} (52%) create mode 100644 src/containers/FetchDataContainer/index.ts create mode 100644 src/containers/SearchContainer/SearchContainer.tsx create mode 100644 src/containers/SearchContainer/SearchResultContainer.tsx create mode 100644 src/containers/SearchContainer/index.ts create mode 100644 src/containers/SearchContainer/types.ts create mode 100644 src/containers/SearchEventsContainer/SearchEventsContainer.tsx create mode 100644 src/containers/SearchEventsContainer/index.ts create mode 100644 src/containers/SearchEventsContainer/types.ts create mode 100644 src/hooks/useSearchCards.ts create mode 100644 src/pages/SearchEventsPage/SearchEventsPage.tsx create mode 100644 src/pages/SearchEventsPage/index.ts create mode 100644 src/pages/SearchEventsPage/types.ts create mode 100644 src/pages/SearchPage/types.ts diff --git a/.eslintrc.js b/.eslintrc.js index 32e534e2..ff0c1e02 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,6 +4,7 @@ module.exports = { "plugin:@typescript-eslint/recommended", "prettier", "prettier/@typescript-eslint", + "plugin:react-hooks/recommended", ], "env": { "mocha": true diff --git a/package-lock.json b/package-lock.json index ee6ff06e..f3e694ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19646,8 +19646,7 @@ "decode-uri-component": { "version": "0.2.0", "resolved": "https://artifactory.corp.alleninstitute.org:443/artifactory/api/npm/npm-virtual/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", - "dev": true + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" }, "decompress": { "version": "4.2.1", @@ -20958,6 +20957,12 @@ } } }, + "eslint-plugin-react-hooks": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.3.0.tgz", + "integrity": "sha512-XslZy0LnMn+84NEG9jSGR6eGqaZB3133L8xewQo3fQagbQuGt7a63gf+P1NGKZavEYEC3UXaWEAA/AqDkuN6xA==", + "dev": true + }, "eslint-scope": { "version": "5.0.0", "resolved": "https://artifactory.corp.alleninstitute.org:443/artifactory/api/npm/npm-virtual/eslint-scope/-/eslint-scope-5.0.0.tgz", @@ -21810,6 +21815,11 @@ } } }, + "filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha1-mzERErxsYSehbgFsbF1/GeCAXFs=" + }, "finalhandler": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", @@ -28601,6 +28611,18 @@ "prepend-http": "^1.0.0", "query-string": "^4.1.0", "sort-keys": "^1.0.0" + }, + "dependencies": { + "query-string": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz", + "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=", + "dev": true, + "requires": { + "object-assign": "^4.1.0", + "strict-uri-encode": "^1.0.0" + } + } } }, "npm-conf": { @@ -31013,13 +31035,21 @@ "dev": true }, "query-string": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz", - "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=", - "dev": true, + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.0.1.tgz", + "integrity": "sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA==", "requires": { - "object-assign": "^4.1.0", - "strict-uri-encode": "^1.0.0" + "decode-uri-component": "^0.2.0", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "dependencies": { + "strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=" + } } }, "querystring": { @@ -33643,6 +33673,11 @@ "integrity": "sha1-NpS1gEVnpFjTyARYQqY1hjL2JlQ=", "dev": true }, + "split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==" + }, "split-string": { "version": "3.1.0", "resolved": "https://artifactory.corp.alleninstitute.org:443/artifactory/api/npm/npm-virtual/split-string/-/split-string-3.1.0.tgz", diff --git a/package.json b/package.json index 117daef9..101cae91 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "eslint": "~6.6", "eslint-config-prettier": "~6.7", "eslint-plugin-react": "^7.19.0", + "eslint-plugin-react-hooks": "^4.3.0", "gh-pages": "^3.1.0", "html-webpack-plugin": "^5.3.2", "husky": "~3.1", @@ -124,6 +125,7 @@ "lodash": "^4.17.21", "moment": "^2.24.0", "n-gram": "^2.0.1", + "query-string": "^7.0.1", "react-highlight-words": "^0.17.0", "react-localization": "^1.0.17", "react-responsive": "^8.2.0", diff --git a/src/app/App.tsx b/src/app/App.tsx index 5bfe12d3..7e44e408 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -3,21 +3,25 @@ import { HashRouter as Router, Route, Switch } from "react-router-dom"; import styled from "@emotion/styled"; import { useAppConfigContext } from "./AppConfigContext"; +import useDocumentTitle from "../hooks/useDocumentTitle"; import { Header } from "../components/Layout/Header"; import { Footer } from "../components/Layout/Footer"; import { HomePage } from "../pages/HomePage"; -import { SearchPage } from "../pages/SearchPage"; +/* import { SearchPage } from "../pages/SearchPage"; */ +import { SearchEventsPage } from "../pages/SearchEventsPage"; import { EventPage } from "../pages/EventPage"; import { EventsPage } from "../pages/EventsPage"; import { PersonPage } from "../pages/PersonPage"; import { PeoplePage } from "../pages/PeoplePage"; -import useDocumentTitle from "../hooks/useDocumentTitle"; + +import { SEARCH_TYPE } from "../pages/SearchPage/types"; + import { strings } from "../assets/LocalizedStrings"; +import { screenWidths } from "../styles/mediaBreakpoints"; import "@mozilla-protocol/core/protocol/css/protocol.css"; import "semantic-ui-css/semantic.min.css"; -import { screenWidths } from "../styles/mediaBreakpoints"; const FlexContainer = styled.div({ // The App takes up at least 100% of the view height @@ -54,13 +58,16 @@ function App() { - + {/* - - + */} + - + + + + diff --git a/src/components/Filters/EventsFilter/EventsFilter.tsx b/src/components/Filters/EventsFilter/EventsFilter.tsx index 1db9530a..3c95415e 100644 --- a/src/components/Filters/EventsFilter/EventsFilter.tsx +++ b/src/components/Filters/EventsFilter/EventsFilter.tsx @@ -1,8 +1,8 @@ import React, { FC, useState } from "react"; -import styled from "@emotion/styled"; import Body from "../../../models/Body"; +import { FiltersContainer } from "../FiltersContainer"; import { FilterPopup } from "../FilterPopup"; import { Filter } from "../useFilter"; import { SelectDateRange } from "../SelectDateRange"; @@ -10,21 +10,6 @@ import { SelectSorting } from "../SelectSorting"; import { SortOption } from "../SelectSorting/getSortingText"; import { SelectTextFilterOptions } from "../SelectTextFilterOptions"; -import { screenWidths } from "../../../styles/mediaBreakpoints"; - -const Filters = styled.div({ - display: "flex", - flexDirection: "column", - gap: 4, - [`@media (min-width:${screenWidths.tablet})`]: { - flexDirection: "row", - flexWrap: "wrap", - "& > div:last-of-type": { - marginLeft: "auto", - }, - }, -}); - interface EventsFilterProps { allBodies: Body[]; filters: Filter[]; @@ -57,7 +42,7 @@ const EventsFilter: FC = ({ }; return ( - +
= ({ />
-
+ ); }; diff --git a/src/components/Filters/FilterPopup/FilterPopup.tsx b/src/components/Filters/FilterPopup/FilterPopup.tsx index af2690c3..04b584ac 100644 --- a/src/components/Filters/FilterPopup/FilterPopup.tsx +++ b/src/components/Filters/FilterPopup/FilterPopup.tsx @@ -124,6 +124,8 @@ const FilterPopup: FunctionComponent = ({ return errors; }, [hasRequiredError, hasLimitError, name, limit]); + const onClose = () => setPopupIsOpen(false); + const togglePopupIsOpen = () => { setPopupIsOpen((prev) => !prev); }; @@ -147,6 +149,7 @@ const FilterPopup: FunctionComponent = ({ context={mountNodeRef.current || undefined} on="click" open={popupIsOpen} + onClose={onClose} pinned={true} offset={[0, -5]} position="bottom left" diff --git a/src/components/Filters/FiltersContainer/FiltersContainer.tsx b/src/components/Filters/FiltersContainer/FiltersContainer.tsx new file mode 100644 index 00000000..d459783b --- /dev/null +++ b/src/components/Filters/FiltersContainer/FiltersContainer.tsx @@ -0,0 +1,18 @@ +import styled from "@emotion/styled"; + +import { screenWidths } from "../../../styles/mediaBreakpoints"; + +const FiltersContainer = styled.div({ + display: "flex", + flexDirection: "column", + gap: 4, + [`@media (min-width:${screenWidths.tablet})`]: { + flexDirection: "row", + flexWrap: "wrap", + "& > div:last-of-type:not(:first-of-type), & > button:last-of-type": { + marginLeft: "auto", + }, + }, +}); + +export default FiltersContainer; diff --git a/src/components/Filters/FiltersContainer/index.ts b/src/components/Filters/FiltersContainer/index.ts new file mode 100644 index 00000000..cbd7c65d --- /dev/null +++ b/src/components/Filters/FiltersContainer/index.ts @@ -0,0 +1 @@ +export { default as FiltersContainer } from "./FiltersContainer"; diff --git a/src/components/Filters/SelectTextFilterOptions/SelectTextFilterOptions.tsx b/src/components/Filters/SelectTextFilterOptions/SelectTextFilterOptions.tsx index 8badd196..033bdb18 100644 --- a/src/components/Filters/SelectTextFilterOptions/SelectTextFilterOptions.tsx +++ b/src/components/Filters/SelectTextFilterOptions/SelectTextFilterOptions.tsx @@ -95,7 +95,7 @@ const SelectTextFilterOptions: FunctionComponent = type="checkbox" name={option.name} id={`form-checkbox-control-${option.name}`} - checked={state[option.name]} + checked={state[option.name] || false} disabled={option.disabled} onChange={onChange} /> diff --git a/src/components/Layout/HomeSearchBar/HomeSearchBar.tsx b/src/components/Layout/HomeSearchBar/HomeSearchBar.tsx index fe863e7b..ca93990c 100644 --- a/src/components/Layout/HomeSearchBar/HomeSearchBar.tsx +++ b/src/components/Layout/HomeSearchBar/HomeSearchBar.tsx @@ -2,13 +2,14 @@ import React, { ChangeEventHandler, FC, FormEventHandler, useState } from "react import { useHistory } from "react-router-dom"; import styled from "@emotion/styled"; -import { SEARCH_TYPE } from "../../../constants/ProjectConstants"; +import { SearchEventsState } from "../../../pages/SearchEventsPage/types"; +import { SEARCH_TYPE } from "../../../pages/SearchPage/types"; -import { FilterPopup } from "../../Filters/FilterPopup"; +/* import { FiltersContainer } from "../../Filters/FiltersContainer"; +import { FilterPopup } from "../../Filters/FilterPopup"; */ import useFilter from "../../Filters/useFilter"; import { FilterState } from "../../Filters/reducer"; -import { getDateText, SelectDateRange } from "../../Filters/SelectDateRange"; -import { getSelectedOptions, SelectTextFilterOptions } from "../../Filters/SelectTextFilterOptions"; +/* import { SelectTextFilterOptions } from "../../Filters/SelectTextFilterOptions"; */ import { screenWidths } from "../../../styles/mediaBreakpoints"; import { strings } from "../../../assets/LocalizedStrings"; @@ -59,48 +60,32 @@ const SearchExampleTopic = styled.p` } `; -const FiltersContainer = styled.div` - ${gridContainer} - @media (min-width: ${screenWidths.tablet}) { - /**Three columns template, with the last column taking up any free space*/ - grid-template-columns: auto auto 1fr; - } -`; +//const AdvancedOptionsBtn = styled.button` +// @media (min-width: ${screenWidths.tablet}) { +// /**Make the advanced options button appear last*/ +// order: 1; +// } +//`; -const AdvancedOptionsBtn = styled.button` - @media (min-width: ${screenWidths.tablet}) { - /**Make the advanced options button appear last*/ - order: 1; - /**Float the button to the right*/ - justify-self: end; - } -`; - -const searchTypeOptions = [ +export const searchTypeOptions = [ { - name: SEARCH_TYPE.MEETING, - label: "Meetings", + name: SEARCH_TYPE.EVENT, + label: "Events", disabled: false, }, { name: SEARCH_TYPE.LEGISLATION, - label: "Legislation", - disabled: false, - }, - { - name: SEARCH_TYPE.COUNCIL_MEMBER, - label: "Council Members", + label: "Legislations", disabled: false, }, ]; const intialSearchTyperFilterState = { - [SEARCH_TYPE.MEETING]: true, + [SEARCH_TYPE.EVENT]: true, [SEARCH_TYPE.LEGISLATION]: true, - [SEARCH_TYPE.COUNCIL_MEMBER]: true, }; -const getSearchTypeText = (checkboxes: FilterState, defaultText: string) => { +export const getSearchTypeText = (checkboxes: FilterState, defaultText: string) => { const selectedCheckboxes = Object.keys(checkboxes).filter((key) => checkboxes[key]); let textRep = defaultText; if (selectedCheckboxes.length === Object.keys(checkboxes).length) { @@ -119,14 +104,8 @@ const exampleSearchQuery = EXAMPLE_TOPICS[Math.floor(Math.random() * EXAMPLE_TOP const HomeSearchBar: FC = () => { const [searchQuery, setSearchQuery] = useState(""); - const [showFilters, setShowFilters] = useState(false); - const history = useHistory(); - const dateRangeFilter = useFilter({ - name: "Date", - initialState: { start: "", end: "" }, - defaultDataValue: "", - textRepFunction: getDateText, - }); + /* const [showFilters, setShowFilters] = useState(false); */ + const history = useHistory(); const searchTypeFilter = useFilter({ name: "Search Type", initialState: intialSearchTyperFilterState, @@ -138,15 +117,14 @@ const HomeSearchBar: FC = () => { const onSearch: FormEventHandler = (event) => { event.preventDefault(); const queryParams = `?q=${searchQuery.trim().replace(/\s+/g, "+")}`; - const selectedSearchTypes = getSelectedOptions(searchTypeFilter.state); history.push({ - pathname: "/search", + pathname: `/${SEARCH_TYPE.EVENT}/search`, search: queryParams, state: { query: searchQuery.trim(), - types: selectedSearchTypes, - committees: [], - dateRange: dateRangeFilter.state, + /* searchTypes: searchTypeFilter.state as Record, */ + committees: {}, + dateRange: {}, }, }); }; @@ -154,10 +132,10 @@ const HomeSearchBar: FC = () => { const onSearchChange: ChangeEventHandler = (event) => setSearchQuery(event.target.value); - const onClickFilters = () => setShowFilters((showFilters) => !showFilters); + /* const onClickFilters = () => setShowFilters((showFilters) => !showFilters); */ return ( - <> +
{
- + {/* { )}
-
- {showFilters && ( - - - - )} -
- - + */} + ); }; diff --git a/src/components/Layout/HomeSearchBar/index.tsx b/src/components/Layout/HomeSearchBar/index.tsx index bd63bd83..5ed82292 100644 --- a/src/components/Layout/HomeSearchBar/index.tsx +++ b/src/components/Layout/HomeSearchBar/index.tsx @@ -1 +1,2 @@ export { default as HomeSearchBar } from "./HomeSearchBar"; +export { searchTypeOptions, getSearchTypeText } from "./HomeSearchBar"; diff --git a/src/components/Shared/FetchCardsStatus.tsx b/src/components/Shared/FetchCardsStatus.tsx new file mode 100644 index 00000000..b08fa3b2 --- /dev/null +++ b/src/components/Shared/FetchCardsStatus.tsx @@ -0,0 +1,10 @@ +import styled from "@emotion/styled"; + +import { fontSizes } from "../../styles/fonts"; + +/**An element to display the status of fetching cards. */ +const FetchCardsStatus = styled.p({ + fontSize: fontSizes.font_size_6, +}); + +export default FetchCardsStatus; diff --git a/src/components/Shared/PageContainer.tsx b/src/components/Shared/PageContainer.tsx new file mode 100644 index 00000000..510d0f09 --- /dev/null +++ b/src/components/Shared/PageContainer.tsx @@ -0,0 +1,10 @@ +import styled from "@emotion/styled"; + +/**A flex container for pages that display cards - legislations, events */ +const PageContainer = styled.div({ + display: "flex", + flexDirection: "column", + gap: 32, +}); + +export default PageContainer; diff --git a/src/components/Shared/SearchBar.tsx b/src/components/Shared/SearchBar.tsx new file mode 100644 index 00000000..8c34ccef --- /dev/null +++ b/src/components/Shared/SearchBar.tsx @@ -0,0 +1,37 @@ +import React, { FC, Dispatch, SetStateAction, ChangeEventHandler, FormEventHandler } from "react"; + +export interface SearchBarProps { + placeholder: string; + query: string; + setQuery: Dispatch>; + handleSearch(): void; +} + +const SearchBar: FC = ({ + placeholder, + query, + setQuery, + handleSearch, +}: SearchBarProps) => { + const onSearchChange: ChangeEventHandler = (event) => + setQuery(event.target.value); + const onSearch: FormEventHandler = (event) => { + event.preventDefault(); + handleSearch(); + }; + + return ( +
+ +
+ ); +}; + +export default SearchBar; diff --git a/src/components/Shared/SearchPageTitle.tsx b/src/components/Shared/SearchPageTitle.tsx new file mode 100644 index 00000000..19f73ed5 --- /dev/null +++ b/src/components/Shared/SearchPageTitle.tsx @@ -0,0 +1,26 @@ +import styled from "@emotion/styled"; + +import { screenWidths } from "../../styles/mediaBreakpoints"; + +/**A container of an `h1` and a `SearchBar` for pages with title and seach bar. */ +const SearchPageTitle = styled.div({ + display: "flex", + flexDirection: "column", + rowGap: 32, + "& > h1, & form, & input": { + marginBottom: 0, + }, + "& input": { + width: "100%", + }, + [`@media (min-width:${screenWidths.tablet})`]: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + "& input": { + width: "auto", + }, + }, +}); + +export default SearchPageTitle; diff --git a/src/components/Shared/ShowMoreCards.tsx b/src/components/Shared/ShowMoreCards.tsx new file mode 100644 index 00000000..a88bbe9f --- /dev/null +++ b/src/components/Shared/ShowMoreCards.tsx @@ -0,0 +1,21 @@ +import styled from "@emotion/styled"; + +import { screenWidths } from "../../styles/mediaBreakpoints"; + +/**A container of the show more cards button */ +const ShowMoreCards = styled.div<{ isVisible: boolean }>((props) => ({ + visibility: props.isVisible ? "visible" : "hidden", + "& > button": { + width: "100%", + }, + "& .ui.loader": { + marginLeft: 16, + }, + [`@media (min-width:${screenWidths.tablet})`]: { + "& > button": { + width: "auto", + }, + }, +})); + +export default ShowMoreCards; diff --git a/src/constants/ProjectConstants.ts b/src/constants/ProjectConstants.ts index c11fada3..80ea616e 100644 --- a/src/constants/ProjectConstants.ts +++ b/src/constants/ProjectConstants.ts @@ -10,10 +10,6 @@ export enum MATTER_STATUS_DECISION { IN_PROGRESS = "In Progress", } -export enum SEARCH_TYPE { - MEETING = "meeting", - LEGISLATION = "legislation", - COUNCIL_MEMBER = "council-member", -} - export const SUPPORTED_LANGUAGES = ["en", "de", "es"]; + +export const FETCH_CARDS_BATCH_SIZE = 10; diff --git a/src/containers/CardsContainer/CardsContainer.tsx b/src/containers/CardsContainer/CardsContainer.tsx new file mode 100644 index 00000000..4edaf001 --- /dev/null +++ b/src/containers/CardsContainer/CardsContainer.tsx @@ -0,0 +1,54 @@ +import React, { FC } from "react"; +import { Link } from "react-router-dom"; +import styled from "@emotion/styled"; + +import { Card } from "./types"; + +import colors from "../../styles/colors"; +import { fontSizes } from "../../styles/fonts"; +import { screenWidths } from "../../styles/mediaBreakpoints"; + +const Container = styled.div({ + display: "flex", + flexDirection: "row", + flexWrap: "wrap", + rowGap: 64, + "& > div": { + width: "100%", + }, + [`@media (min-width:${screenWidths.tablet})`]: { + justifyContent: "space-between", + "& > div": { + width: "35%", + }, + }, +}); + +export interface CardsContainerProps { + cards: Card[]; +} + +const CardsContainer: FC = ({ cards }: CardsContainerProps) => { + return ( + + {cards.map(({ link, jsx }) => { + return ( +
+ + {jsx} + +
+ ); + })} +
+ ); +}; + +export default CardsContainer; diff --git a/src/containers/CardsContainer/index.ts b/src/containers/CardsContainer/index.ts new file mode 100644 index 00000000..288444ce --- /dev/null +++ b/src/containers/CardsContainer/index.ts @@ -0,0 +1 @@ +export { default as CardsContainer } from "./CardsContainer"; diff --git a/src/containers/CardsContainer/types.ts b/src/containers/CardsContainer/types.ts new file mode 100644 index 00000000..56b11fbb --- /dev/null +++ b/src/containers/CardsContainer/types.ts @@ -0,0 +1,6 @@ +import { ReactNode } from "react"; + +export interface Card { + link: string; + jsx: ReactNode; +} diff --git a/src/containers/EventsContainer/EventsContainer.tsx b/src/containers/EventsContainer/EventsContainer.tsx index 1a1b7de4..766b86be 100644 --- a/src/containers/EventsContainer/EventsContainer.tsx +++ b/src/containers/EventsContainer/EventsContainer.tsx @@ -1,18 +1,10 @@ -import React, { - FC, - useCallback, - useMemo, - useState, - ChangeEventHandler, - FormEventHandler, -} from "react"; -import styled from "@emotion/styled"; -import { Link, useHistory } from "react-router-dom"; +import React, { FC, useCallback, useMemo, useState } from "react"; +import { useHistory } from "react-router-dom"; import { Loader } from "semantic-ui-react"; import { useAppConfigContext } from "../../app"; -import { SEARCH_TYPE } from "../../constants/ProjectConstants"; import { ORDER_DIRECTION, OR_QUERY_LIMIT_NUM } from "../../networking/constants"; +import EventService from "../../networking/EventService"; import { MeetingCard } from "../../components/Cards/MeetingCard"; import useFilter from "../../components/Filters/useFilter"; @@ -23,72 +15,21 @@ import { getCheckboxText, getSelectedOptions, } from "../../components/Filters/SelectTextFilterOptions"; -import useEventsPagination from "./useEventsPagination"; +import FetchCardsStatus from "../../components/Shared/FetchCardsStatus"; +import PageContainer from "../../components/Shared/PageContainer"; +import SearchBar from "../../components/Shared/SearchBar"; +import SearchPageTitle from "../../components/Shared/SearchPageTitle"; +import ShowMoreCards from "../../components/Shared/ShowMoreCards"; +import { CardsContainer } from "../CardsContainer"; +import useFetchEvents, { FetchEventsActionType } from "./useFetchEvents"; import { EventsData } from "./types"; +import { SearchEventsState } from "../../pages/SearchEventsPage/types"; +import { SEARCH_TYPE } from "../../pages/SearchPage/types"; import { strings } from "../../assets/LocalizedStrings"; -import colors from "../../styles/colors"; -import { fontSizes } from "../../styles/fonts"; -import { screenWidths } from "../../styles/mediaBreakpoints"; +import { FETCH_CARDS_BATCH_SIZE } from "../../constants/ProjectConstants"; -const Container = styled.div({ - display: "flex", - flexDirection: "column", - gap: 32, -}); - -const SearchContainer = styled.div({ - display: "grid", - gap: 8, - gridTemplateColumns: "1fr", - justifyContent: "start", - alignItems: "start", - [`@media (min-width:${screenWidths.tablet})`]: { - gridTemplateColumns: "1fr auto", - }, -}); - -const Events = styled.div({ - display: "flex", - flexDirection: "row", - flexWrap: "wrap", - rowGap: 64, - "& > div": { - width: "100%", - }, - [`@media (min-width:${screenWidths.tablet})`]: { - justifyContent: "space-between", - "& > div": { - width: "35%", - }, - }, -}); - -const FetchEventsMsg = styled.p({ - fontSize: fontSizes.font_size_6, -}); - -interface ShowMoreEventsProps { - isVisible: boolean; -} -const ShowMoreEvents = styled.div((props) => ({ - visibility: props.isVisible ? "visible" : "hidden", - "& > button": { - width: "100%", - }, - "& .ui.loader": { - marginLeft: 16, - }, - [`@media (min-width:${screenWidths.tablet})`]: { - "& > button": { - width: "auto", - }, - }, -})); - -const FETCH_EVENTS_BATCH_SIZE = 10; - -const EventsContainer: FC = ({ bodies, events }: EventsData) => { +const EventsContainer: FC = ({ bodies }: EventsData) => { const { firebaseConfig } = useAppConfigContext(); const dateRangeFilter = useFilter({ @@ -114,112 +55,112 @@ const EventsContainer: FC = ({ bodies, events }: EventsData) => { textRepFunction: getSortingText, }); - const [state, dispatch] = useEventsPagination( - firebaseConfig, + const fetchEventsFunctionCreator = useCallback( + (batchSize: number, startAfterEventDate?: Date) => async () => { + const eventService = new EventService(firebaseConfig); + const events = await eventService.getEvents( + batchSize, + getSelectedOptions(committeeFilter.state), + { + start: dateRangeFilter.state.start ? new Date(dateRangeFilter.state.start) : undefined, + end: dateRangeFilter.state.end ? new Date(dateRangeFilter.state.end) : undefined, + }, + { + by: sortFilter.state.by, + order: sortFilter.state.order as ORDER_DIRECTION, + }, + startAfterEventDate + ); + const renderableEvents = await Promise.all( + events.map((event) => { + return eventService.getRenderableEvent(event); + }) + ); + return Promise.resolve(renderableEvents); + }, + [firebaseConfig, committeeFilter.state, dateRangeFilter.state, sortFilter.state] + ); + + const [state, dispatch] = useFetchEvents( { - batchSize: FETCH_EVENTS_BATCH_SIZE, - events: events, - fetchEvents: false, + batchSize: FETCH_CARDS_BATCH_SIZE, + events: [], + fetchEvents: true, showMoreEvents: false, - hasMoreEvents: events.length === FETCH_EVENTS_BATCH_SIZE, + hasMoreEvents: false, error: null, }, - getSelectedOptions(committeeFilter.state), - dateRangeFilter.state, - sortFilter.state + fetchEventsFunctionCreator ); + const history = useHistory(); + const [searchQuery, setSearchQuery] = useState(""); + const handleSearch = () => { + const queryParams = `?q=${searchQuery.trim().replace(/\s+/g, "+")}`; + + history.push({ + pathname: `/${SEARCH_TYPE.EVENT}/search`, + search: queryParams, + state: { + query: searchQuery.trim(), + committees: committeeFilter.state, + dateRange: dateRangeFilter.state, + }, + }); + }; + const handlePopupClose = useCallback(() => { - dispatch({ type: "FETCH_EVENTS", payload: true }); + dispatch({ type: FetchEventsActionType.FETCH_EVENTS, payload: true }); }, [dispatch]); const handleShowMoreEvents = useCallback( - () => dispatch({ type: "FETCH_EVENTS", payload: false }), + () => dispatch({ type: FetchEventsActionType.FETCH_EVENTS, payload: false }), [dispatch] ); const fetchEventsResult = useMemo(() => { if (state.fetchEvents) { - return ; + return ; } else if (state.error) { - return {state.error.toString()}; + return {state.error.toString()}; } else if (state.events.length === 0) { - return No events found.; + return No events found.; } else { - return ( - - {state.events.map((event, i) => { - const eventDateTimeStr = event.event_datetime?.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - }) as string; - return ( -
- - - -
- ); - })} -
- ); + const cards = state.events.map((event) => { + const eventDateTimeStr = event.event_datetime?.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }) as string; + return { + link: `/${SEARCH_TYPE.EVENT}/${event.id}`, + jsx: ( + + ), + }; + }); + return ; } }, [state.fetchEvents, state.error, state.events]); - const history = useHistory(); - const [searchQuery, setSearchQuery] = useState(""); - const onSearchChange: ChangeEventHandler = (event) => - setSearchQuery(event.target.value); - const onSearch: FormEventHandler = (event) => { - event.preventDefault(); - const queryParams = `?q=${searchQuery.trim().replace(/\s+/g, "+")}`; - - history.push({ - pathname: "/search", - search: queryParams, - state: { - query: searchQuery.trim(), - types: [SEARCH_TYPE.MEETING], - committees: getSelectedOptions(committeeFilter.state), - dateRange: dateRangeFilter.state, - }, - }); - }; - return ( - -

{strings.events}

-
- - - - -
+ + +

{strings.events}

+ +
= ({ bodies, events }: EventsData) => { handlePopupClose={handlePopupClose} /> {fetchEventsResult} - + - -
+ + ); }; diff --git a/src/containers/EventsContainer/types.ts b/src/containers/EventsContainer/types.ts index 8d50ea70..d35b7138 100644 --- a/src/containers/EventsContainer/types.ts +++ b/src/containers/EventsContainer/types.ts @@ -1,7 +1,5 @@ import Body from "../../models/Body"; -import { RenderableEvent } from "../../networking/EventService"; export interface EventsData { bodies: Body[]; - events: RenderableEvent[]; } diff --git a/src/containers/EventsContainer/useEventsPagination.ts b/src/containers/EventsContainer/useFetchEvents.ts similarity index 52% rename from src/containers/EventsContainer/useEventsPagination.ts rename to src/containers/EventsContainer/useFetchEvents.ts index 76461ded..ba9e2cf0 100644 --- a/src/containers/EventsContainer/useEventsPagination.ts +++ b/src/containers/EventsContainer/useFetchEvents.ts @@ -1,18 +1,22 @@ import { useEffect, useReducer, Dispatch } from "react"; -import { FirebaseConfig } from "../../app/AppConfigContext"; -import { ORDER_DIRECTION } from "../../networking/constants"; -import EventService, { RenderableEvent } from "../../networking/EventService"; +import { RenderableEvent } from "../../networking/EventService"; import { createError } from "../../utils/createError"; -export type Action = - | { type: "FAILURE"; payload: Error } - | { type: "SUCCESS"; payload: RenderableEvent[] } +export enum FetchEventsActionType { + FAILURE = "FAILURE", + SUCCESS = "SUCCESS", + FETCH_EVENTS = "FETCH_EVENTS", +} + +export type FetchEventsAction = + | { type: FetchEventsActionType.FAILURE; payload: Error } + | { type: FetchEventsActionType.SUCCESS; payload: RenderableEvent[] } //The payload is whether to fetch events with different filter parameters or fetch more events with same filter parameters. - | { type: "FETCH_EVENTS"; payload: boolean }; + | { type: FetchEventsActionType.FETCH_EVENTS; payload: boolean }; -export interface State { +export interface FetchEventsState { batchSize: number; events: RenderableEvent[]; //Is currently fetching events with different filter parameters? @@ -23,9 +27,12 @@ export interface State { error: Error | null; } -export function eventsPageReducer(state: State, action: Action): State { +export function fetchEventsReducer( + state: FetchEventsState, + action: FetchEventsAction +): FetchEventsState { switch (action.type) { - case "SUCCESS": { + case FetchEventsActionType.SUCCESS: { return { ...state, events: state.fetchEvents ? action.payload : [...state.events, ...action.payload], @@ -34,7 +41,7 @@ export function eventsPageReducer(state: State, action: Action): State { hasMoreEvents: action.payload.length === state.batchSize, }; } - case "FAILURE": { + case FetchEventsActionType.FAILURE: { return { ...state, error: action.payload, @@ -42,7 +49,7 @@ export function eventsPageReducer(state: State, action: Action): State { showMoreEvents: false, }; } - case "FETCH_EVENTS": { + case FetchEventsActionType.FETCH_EVENTS: { return { ...state, error: null, @@ -56,48 +63,33 @@ export function eventsPageReducer(state: State, action: Action): State { } } -export default function useEventsPagination( - firebaseConfig: FirebaseConfig, - initialState: State, - bodyIds: string[], - dateRange: Record, - sort: Record -): [State, Dispatch] { - const [state, dispatch] = useReducer(eventsPageReducer, initialState); +export default function useFetchEvents( + initialState: FetchEventsState, + fetchEventsFunctionCreator: ( + batchSize: number, + afterDate?: Date + ) => () => Promise +): [FetchEventsState, Dispatch] { + const [state, dispatch] = useReducer(fetchEventsReducer, initialState); useEffect(() => { let didCancel = false; const fetchEvents = async () => { try { - const eventService = new EventService(firebaseConfig); - const events = await eventService.getEvents( - state.batchSize, - bodyIds, - { - start: dateRange.start ? new Date(dateRange.start) : undefined, - end: dateRange.end ? new Date(dateRange.end) : undefined, - }, - { - by: sort.by, - order: sort.order as ORDER_DIRECTION, - }, + const startAfterEventDate = !state.fetchEvents && state.events.length > 0 ? state.events[state.events.length - 1].event_datetime - : undefined - ); - const renderableEvents = await Promise.all( - events.map((event) => { - return eventService.getRenderableEvent(event); - }) - ); + : undefined; + const fetch = fetchEventsFunctionCreator(state.batchSize, startAfterEventDate); + const renderableEvents = await fetch(); if (!didCancel) { - dispatch({ type: "SUCCESS", payload: renderableEvents }); + dispatch({ type: FetchEventsActionType.SUCCESS, payload: renderableEvents }); } } catch (e) { if (!didCancel) { const error = createError(e); - dispatch({ type: "FAILURE", payload: error }); + dispatch({ type: FetchEventsActionType.FAILURE, payload: error }); } } }; @@ -114,10 +106,7 @@ export default function useEventsPagination( state.events, state.fetchEvents, state.showMoreEvents, - firebaseConfig, - bodyIds, - dateRange, - sort, + fetchEventsFunctionCreator, dispatch, ]); diff --git a/src/containers/FetchDataContainer/FetchDataContainer.tsx b/src/containers/FetchDataContainer/FetchDataContainer.tsx index 790f3ec0..2a43073a 100644 --- a/src/containers/FetchDataContainer/FetchDataContainer.tsx +++ b/src/containers/FetchDataContainer/FetchDataContainer.tsx @@ -14,7 +14,7 @@ const FetchDataContainer: FC = ({ error, }: FetchDataContainerProps) => { if (isLoading) { - return ; + return ; } if (error) { // Display the error for now. diff --git a/src/containers/FetchDataContainer/index.ts b/src/containers/FetchDataContainer/index.ts new file mode 100644 index 00000000..b1c5c872 --- /dev/null +++ b/src/containers/FetchDataContainer/index.ts @@ -0,0 +1 @@ +export { default as FetchDataContainer } from "./FetchDataContainer"; diff --git a/src/containers/FetchDataContainer/useFetchData.ts b/src/containers/FetchDataContainer/useFetchData.ts index 9769d3a8..1591a7d8 100644 --- a/src/containers/FetchDataContainer/useFetchData.ts +++ b/src/containers/FetchDataContainer/useFetchData.ts @@ -1,25 +1,31 @@ -import { useReducer } from "react"; +import { useReducer, useEffect } from "react"; + +import { createError } from "../../utils/createError"; export interface FetchDataState { isLoading: boolean; data?: T; - error?: any; + error: Error | null; + //Should fetch data? + hasFetchRequest: boolean; } export enum FetchDataActionType { FETCH_INIT = "FETCH_INIT", FETCH_SUCCESS = "FETCH_SUCCESS", FETCH_FAILURE = "FETCH_FAILURE", + FETCH = "FETCH", } -export interface FetchDataAction { - type: FetchDataActionType; - payload?: any; -} +export type FetchDataAction = + | { type: FetchDataActionType.FETCH_INIT } + | { type: FetchDataActionType.FETCH_FAILURE; payload: Error } + | { type: FetchDataActionType.FETCH_SUCCESS; payload: T } + | { type: FetchDataActionType.FETCH }; const createFetchDataReducer = () => ( state: FetchDataState, - action: FetchDataAction + action: FetchDataAction ): FetchDataState => { switch (action.type) { case FetchDataActionType.FETCH_INIT: { @@ -33,7 +39,8 @@ const createFetchDataReducer = () => ( return { ...state, isLoading: false, - data: action.payload as T, + data: action.payload, + hasFetchRequest: false, }; } case FetchDataActionType.FETCH_FAILURE: { @@ -41,6 +48,13 @@ const createFetchDataReducer = () => ( ...state, isLoading: false, error: action.payload, + hasFetchRequest: false, + }; + } + case FetchDataActionType.FETCH: { + return { + ...state, + hasFetchRequest: true, }; } default: @@ -48,9 +62,39 @@ const createFetchDataReducer = () => ( } }; -export default function useFetchData(initialState: FetchDataState) { +export default function useFetchData( + initialState: FetchDataState, + fetchData: () => Promise +) { const fetchDataReducer = createFetchDataReducer(); const [state, dispatch] = useReducer(fetchDataReducer, initialState); + useEffect(() => { + let didCancel = false; + + const fetch = async () => { + try { + dispatch({ type: FetchDataActionType.FETCH_INIT }); + const data = await fetchData(); + if (!didCancel) { + dispatch({ type: FetchDataActionType.FETCH_SUCCESS, payload: data }); + } + } catch (e) { + if (!didCancel) { + const error = createError(e); + dispatch({ type: FetchDataActionType.FETCH_FAILURE, payload: error }); + } + } + }; + + if (state.hasFetchRequest) { + fetch(); + } + + return () => { + didCancel = true; + }; + }, [state.hasFetchRequest, fetchData]); + return { state, dispatch }; } diff --git a/src/containers/SearchContainer/SearchContainer.tsx b/src/containers/SearchContainer/SearchContainer.tsx new file mode 100644 index 00000000..06c29922 --- /dev/null +++ b/src/containers/SearchContainer/SearchContainer.tsx @@ -0,0 +1,169 @@ +import React, { FC, useCallback, useMemo, useState, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { Loader } from "semantic-ui-react"; + +import { useAppConfigContext } from "../../app"; +import useDocumentTitle from "../../hooks/useDocumentTitle"; +import EventSearchService from "../../networking/EventSearchService"; + +import { MeetingCard } from "../../components/Cards/MeetingCard"; +import { FiltersContainer } from "../../components/Filters/FiltersContainer"; +import { FilterPopup } from "../../components/Filters/FilterPopup"; +import useFilter from "../../components/Filters/useFilter"; +import { SelectTextFilterOptions } from "../../components/Filters/SelectTextFilterOptions"; +import { getSearchTypeText, searchTypeOptions } from "../../components/Layout/HomeSearchBar"; +import FetchCardsStatus from "../../components/Shared/FetchCardsStatus"; +import PageContainer from "../../components/Shared/PageContainer"; +import SearchBar from "../../components/Shared/SearchBar"; +import SearchPageTitle from "../../components/Shared/SearchPageTitle"; +import useFetchData, { FetchDataActionType } from "../FetchDataContainer/useFetchData"; +import SearchResultContainer from "./SearchResultContainer"; +import { SearchContainerData, SearchData } from "./types"; +import { SEARCH_TYPE } from "../../pages/SearchPage/types"; + +import { strings } from "../../assets/LocalizedStrings"; + +const SEARCH_RESULT_NUM = 4; + +const SearchContainer: FC = ({ searchState }: SearchContainerData) => { + const { firebaseConfig } = useAppConfigContext(); + + const queryRef = useRef(searchState.query); + const [query, setQuery] = useState(searchState.query); + const searchTypeFilter = useFilter({ + name: "Search Type", + initialState: searchState.searchTypes, + defaultDataValue: false, + textRepFunction: getSearchTypeText, + isRequired: true, + }); + + useDocumentTitle(queryRef.current ? `${strings.search} -- ${queryRef.current}` : strings.search); + + const fetchSearchData = useCallback(async () => { + const eventSearchService = new EventSearchService(firebaseConfig); + const matchingEventsPromise = searchTypeFilter.state.events + ? eventSearchService.searchEvents(query) + : Promise.resolve([]); + const [matchingEvents] = await Promise.all([matchingEventsPromise]); + //sort matching events by weighted relevance with order desc + matchingEvents.sort((a, b) => b.datetimeWeightedRelevance - a.datetimeWeightedRelevance); + //TODO: add matching legislations + + const renderableEventsPromise = Promise.all( + matchingEvents + .slice(0, SEARCH_RESULT_NUM) + .map((matchingEvent) => eventSearchService.getRenderableEvent(matchingEvent)) + ); + const [renderableEvents] = await Promise.all([renderableEventsPromise]); + //TODO: add renderable legislations + return Promise.resolve({ + event: { + isRequested: searchTypeFilter.state.events, + total: matchingEvents.length, + events: renderableEvents, + }, + }); + }, [query, searchTypeFilter.state, firebaseConfig]); + + const { state, dispatch } = useFetchData( + { + isLoading: false, + error: null, + hasFetchRequest: true, + data: { + event: { + isRequested: false, + total: 0, + events: [], + }, + }, + }, + fetchSearchData + ); + + const location = useLocation(); + const handleSearch = useCallback(() => { + queryRef.current = query; + const queryParams = `?q=${query.trim().replace(/\s+/g, "+")}`; + // # is because the react-router-dom BrowserRouter is used + history.pushState({}, "", `#${location.pathname}${queryParams}`); + dispatch({ type: FetchDataActionType.FETCH }); + }, [query, location, dispatch]); + + const eventCards = useMemo(() => { + if (!state.data?.event) { + return []; + } + return state.data.event.events.map((renderableEvent) => { + const eventDateTimeStr = renderableEvent.event.event_datetime?.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }) as string; + return { + link: `/${SEARCH_TYPE.EVENT}/${renderableEvent.event.id}`, + jsx: ( + + ), + }; + }); + }, [state.data?.event]); + + //TODO: add the legislation cards + + return ( + + +

Search Results

+ +
+ +
+ + + +
+
+ + {state.error && {state.error.toString()}} + +
+ ); +}; + +export default SearchContainer; diff --git a/src/containers/SearchContainer/SearchResultContainer.tsx b/src/containers/SearchContainer/SearchResultContainer.tsx new file mode 100644 index 00000000..b8da0a79 --- /dev/null +++ b/src/containers/SearchContainer/SearchResultContainer.tsx @@ -0,0 +1,69 @@ +import React, { FC } from "react"; +import { Link } from "react-router-dom"; +import styled from "@emotion/styled"; + +import { CardsContainer } from "../CardsContainer"; +import { Card } from "../CardsContainer/types"; +import { SEARCH_TYPE } from "../../pages/SearchPage/types"; + +import { screenWidths } from "../../styles/mediaBreakpoints"; +import { fontSizes } from "../../styles/fonts"; + +const Title = styled.div({ + marginBottom: 16, + display: "flex", + flexDirection: "column", + [`@media (min-width:${screenWidths.tablet})`]: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + "& > h1": { + marginBottom: 0, + }, + "& > :last-child": { + fontSize: fontSizes.font_size_6, + }, +}); + +export interface SearchResultContainerProps { + query: string; + isVisible: boolean; + searchType: SEARCH_TYPE; + total: number; + cards: Card[]; +} + +const SearchResultContainer: FC = ({ + query, + isVisible, + searchType, + total, + cards, +}: SearchResultContainerProps) => { + const queryParams = `?q=${query.trim().replace(/\s+/g, "+")}`; + if (!isVisible) { + return null; + } + + return ( +
+ + <h1 className="mzp-u-title-xs">{`${searchType[0].toUpperCase()}${searchType.slice(1)}`}</h1> + {total > 0 ? ( + <Link + to={{ + pathname: `/${searchType}/search`, + search: queryParams, + }} + >{`View all ${total} ${searchType} search results`}</Link> + ) : ( + <p>{`No ${searchType} found.`}</p> + )} + + +
+ ); +}; + +export default SearchResultContainer; diff --git a/src/containers/SearchContainer/index.ts b/src/containers/SearchContainer/index.ts new file mode 100644 index 00000000..98cdc97b --- /dev/null +++ b/src/containers/SearchContainer/index.ts @@ -0,0 +1 @@ +export { default as SearchContainer } from "./SearchContainer"; diff --git a/src/containers/SearchContainer/types.ts b/src/containers/SearchContainer/types.ts new file mode 100644 index 00000000..3415ed2f --- /dev/null +++ b/src/containers/SearchContainer/types.ts @@ -0,0 +1,16 @@ +import { RenderableEvent } from "../../networking/EventSearchService"; + +import { SearchState } from "../../pages/SearchPage/types"; + +export interface SearchContainerData { + searchState: SearchState; +} + +export interface SearchData { + event: { + isRequested: boolean; + total: number; + events: RenderableEvent[]; + }; + //TODO: add legislation result +} diff --git a/src/containers/SearchEventsContainer/SearchEventsContainer.tsx b/src/containers/SearchEventsContainer/SearchEventsContainer.tsx new file mode 100644 index 00000000..0dae3922 --- /dev/null +++ b/src/containers/SearchEventsContainer/SearchEventsContainer.tsx @@ -0,0 +1,223 @@ +import React, { FC, useCallback, useMemo, useState, useRef } from "react"; +import { orderBy } from "lodash"; +import { useLocation } from "react-router-dom"; +import { Loader } from "semantic-ui-react"; + +import { useAppConfigContext } from "../../app"; +import useDocumentTitle from "../../hooks/useDocumentTitle"; +import useSearchCards, { SearchCardsActionType } from "../../hooks/useSearchCards"; +import { ORDER_DIRECTION } from "../../networking/constants"; +import EventSearchService, { RenderableEvent } from "../../networking/EventSearchService"; + +import { MeetingCard } from "../../components/Cards/MeetingCard"; +import useFilter from "../../components/Filters/useFilter"; +import { EventsFilter } from "../../components/Filters/EventsFilter"; +import { getDateText } from "../../components/Filters/SelectDateRange"; +import { getSortingText } from "../../components/Filters/SelectSorting"; +import { + getCheckboxText, + getSelectedOptions, +} from "../../components/Filters/SelectTextFilterOptions"; +import FetchCardsStatus from "../../components/Shared/FetchCardsStatus"; +import PageContainer from "../../components/Shared/PageContainer"; +import SearchBar from "../../components/Shared/SearchBar"; +import SearchPageTitle from "../../components/Shared/SearchPageTitle"; +import ShowMoreCards from "../../components/Shared/ShowMoreCards"; +import { CardsContainer } from "../CardsContainer"; +import { SearchEventsContainerData } from "./types"; +import { SEARCH_TYPE } from "../../pages/SearchPage/types"; + +import { strings } from "../../assets/LocalizedStrings"; +import { FETCH_CARDS_BATCH_SIZE } from "../../constants/ProjectConstants"; + +const SearchEventsContainer: FC = ({ + searchEventsState, + bodies, +}: SearchEventsContainerData) => { + const { firebaseConfig } = useAppConfigContext(); + + const searchQueryRef = useRef(searchEventsState.query); + const [searchQuery, setSearchQuery] = useState(searchEventsState.query); + useDocumentTitle( + searchQueryRef.current ? `${strings.events} -- ${searchQueryRef.current}` : strings.events + ); + + const dateRangeFilter = useFilter({ + name: "Date", + initialState: searchEventsState.dateRange, + defaultDataValue: "", + textRepFunction: getDateText, + }); + const committeeFilter = useFilter({ + name: "Committee", + initialState: searchEventsState.committees, + defaultDataValue: false, + textRepFunction: getCheckboxText, + }); + const sortFilter = useFilter({ + name: "Sort", + initialState: { + by: "datetimeWeightedRelevance", + order: ORDER_DIRECTION.desc, + label: "Most relevant first", + }, + defaultDataValue: "", + textRepFunction: getSortingText, + }); + + const fetchEvents = useCallback(async () => { + const eventSearchService = new EventSearchService(firebaseConfig); + const matchingEvents = await eventSearchService.searchEvents(searchQuery); + const renderableEvents = await Promise.all( + matchingEvents.map((matchingEvent) => eventSearchService.getRenderableEvent(matchingEvent)) + ); + const bodyIds = getSelectedOptions(committeeFilter.state); + let filteredEvents = renderableEvents.filter(({ event }) => { + if (bodyIds.length) { + if (!event.body?.id) { + //exclude events without a body + return false; + } + if (!bodyIds.includes(event.body.id)) { + //exclude body not in bodyIds + return false; + } + } + + if (dateRangeFilter.state.start || dateRangeFilter.state.end) { + if (!event.event_datetime) { + //exclude events without a event_datetime + return false; + } + if ( + dateRangeFilter.state.start && + event.event_datetime < new Date(dateRangeFilter.state.start) + ) { + //exclude events before start date + return false; + } + if (dateRangeFilter.state.end) { + //exclude events after end date + const endDate = new Date(dateRangeFilter.state.end); + endDate.setDate(endDate.getDate() + 1); + if (event.event_datetime > endDate) { + return false; + } + } + } + + return true; + }); + + filteredEvents = orderBy( + filteredEvents, + [sortFilter.state.by], + [sortFilter.state.order as ORDER_DIRECTION] + ); + return Promise.resolve(filteredEvents); + }, [searchQuery, committeeFilter.state, dateRangeFilter.state, sortFilter.state, firebaseConfig]); + + const [state, dispatch] = useSearchCards( + { + batchSize: FETCH_CARDS_BATCH_SIZE, + visibleCount: 0, + cards: [], + fetchCards: true, + error: null, + }, + fetchEvents + ); + + const location = useLocation(); + const handleSearch = useCallback(() => { + searchQueryRef.current = searchQuery; + const queryParams = `?q=${searchQuery.trim().replace(/\s+/g, "+")}`; + //# is because the react-router-dom BrowserRouter is used + history.pushState({}, "", `#${location.pathname}${queryParams}`); + dispatch({ type: SearchCardsActionType.FETCH_CARDS }); + }, [searchQuery, location, dispatch]); + + const handleShowMoreEvents = useCallback( + () => dispatch({ type: SearchCardsActionType.SHOW_MORE_CARDS }), + [dispatch] + ); + + const fetchEventsResult = useMemo(() => { + if (state.fetchCards) { + return ; + } else if (state.error) { + return {state.error.toString()}; + } else if (state.cards.length === 0) { + return No events found.; + } else { + const cards = state.cards.slice(0, state.visibleCount).map((renderableEvent) => { + const eventDateTimeStr = renderableEvent.event.event_datetime?.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }) as string; + return { + link: `/${SEARCH_TYPE.EVENT}/${renderableEvent.event.id}`, + jsx: ( + + ), + }; + }); + return ( + <> + {`${state.cards.length} ${SEARCH_TYPE.EVENT}`} + + + ); + } + }, [state.fetchCards, state.error, state.cards, state.visibleCount]); + + const showMoreEvents = useMemo(() => { + return state.visibleCount < state.cards.length && !state.fetchCards; + }, [state]); + + return ( + + +

Event Search Results

+ +
+ + {fetchEventsResult} + + + +
+ ); +}; + +export default SearchEventsContainer; diff --git a/src/containers/SearchEventsContainer/index.ts b/src/containers/SearchEventsContainer/index.ts new file mode 100644 index 00000000..ef19d336 --- /dev/null +++ b/src/containers/SearchEventsContainer/index.ts @@ -0,0 +1 @@ +export { default as SearchEventsContainer } from "./SearchEventsContainer"; diff --git a/src/containers/SearchEventsContainer/types.ts b/src/containers/SearchEventsContainer/types.ts new file mode 100644 index 00000000..0b6eccb5 --- /dev/null +++ b/src/containers/SearchEventsContainer/types.ts @@ -0,0 +1,8 @@ +import Body from "../../models/Body"; + +import { SearchEventsState } from "../../pages/SearchEventsPage/types"; + +export interface SearchEventsContainerData { + searchEventsState: SearchEventsState; + bodies: Body[]; +} diff --git a/src/hooks/useSearchCards.ts b/src/hooks/useSearchCards.ts new file mode 100644 index 00000000..2459d628 --- /dev/null +++ b/src/hooks/useSearchCards.ts @@ -0,0 +1,104 @@ +import { useEffect, useReducer, Dispatch } from "react"; + +import { createError } from "../utils/createError"; + +export enum SearchCardsActionType { + FAILURE = "FAILURE", + FETCH_CARDS = "FETCH_CARDS", + FETCH_CARDS_SUCCESS = "FETCH_CARDS_SUCCESSS", + SHOW_MORE_CARDS = "SHOW_MORE_CARDS", +} + +export type SearchCardsAction = + | { type: SearchCardsActionType.FAILURE; payload: Error } + | { type: SearchCardsActionType.FETCH_CARDS } + | { type: SearchCardsActionType.FETCH_CARDS_SUCCESS; payload: T[] } + | { type: SearchCardsActionType.SHOW_MORE_CARDS }; + +export interface SearchCardsState { + batchSize: number; + //The number of visible cards + visibleCount: number; + //Filtered and sorted cards + cards: T[]; + //Is currently fetching cards? + fetchCards: boolean; + error: Error | null; +} + +const createSearchCardsReducer = () => ( + state: SearchCardsState, + action: SearchCardsAction +): SearchCardsState => { + switch (action.type) { + case SearchCardsActionType.FAILURE: { + return { + ...state, + error: action.payload, + fetchCards: false, + }; + } + case SearchCardsActionType.FETCH_CARDS: { + return { + ...state, + error: null, + fetchCards: true, + }; + } + case SearchCardsActionType.FETCH_CARDS_SUCCESS: { + const nextVisibleCount = Math.min(state.batchSize, action.payload.length); + return { + ...state, + cards: action.payload, + visibleCount: nextVisibleCount, + fetchCards: false, + }; + } + case SearchCardsActionType.SHOW_MORE_CARDS: { + const nextVisibleCount = Math.min(state.batchSize + state.visibleCount, state.cards.length); + return { + ...state, + visibleCount: nextVisibleCount, + }; + } + default: { + return state; + } + } +}; + +export default function useSearchCards( + initialState: SearchCardsState, + fetch: () => Promise +): [SearchCardsState, Dispatch>] { + const reducer = createSearchCardsReducer(); + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + let didCancel = false; + + const fetchCards = async () => { + try { + const cards = await fetch(); + if (!didCancel) { + dispatch({ type: SearchCardsActionType.FETCH_CARDS_SUCCESS, payload: cards }); + } + } catch (e) { + if (!didCancel) { + const error = createError(e); + dispatch({ type: SearchCardsActionType.FAILURE, payload: error }); + } + } + }; + + if (state.fetchCards) { + fetchCards(); + } + + return () => { + didCancel = true; + }; + }, [state.fetchCards, fetch, dispatch]); + + return [state, dispatch]; +} diff --git a/src/index.ts b/src/index.ts index 96d144fc..0f0e27c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,10 +37,14 @@ export { default as MeetingVotesTableRow } from "./components/Tables/MeetingVote //Containers export { EventContainer } from "./containers/EventContainer"; +export { EventsContainer } from "./containers/EventsContainer"; +export { SearchContainer } from "./containers/SearchContainer"; +export { SearchEventsContainer } from "./containers/SearchEventsContainer"; //Pages export { HomePage } from "./pages/HomePage"; export { SearchPage } from "./pages/SearchPage"; +export { SearchEventsPage } from "./pages/SearchEventsPage"; export { EventsPage } from "./pages/EventsPage"; export { EventPage } from "./pages/EventPage"; export { PeoplePage } from "./pages/PeoplePage"; diff --git a/src/networking/EventSearchService.ts b/src/networking/EventSearchService.ts index a97741ea..973733d5 100644 --- a/src/networking/EventSearchService.ts +++ b/src/networking/EventSearchService.ts @@ -45,7 +45,7 @@ class MatchingEvent { * Contains all information needed to created paginated requests for full event * information, along with minimal information used for display. */ -class RenderableEvent { +export class RenderableEvent { event: Event; pureRelevance: number; datetimeWeightedRelevance: number; diff --git a/src/pages/EventPage/EventPage.tsx b/src/pages/EventPage/EventPage.tsx index bc5be236..8be8a864 100644 --- a/src/pages/EventPage/EventPage.tsx +++ b/src/pages/EventPage/EventPage.tsx @@ -1,4 +1,4 @@ -import React, { FC, useEffect } from "react"; +import React, { FC, useCallback } from "react"; import { useParams } from "react-router-dom"; @@ -12,14 +12,10 @@ import VoteService from "../../networking/VoteService"; import TranscriptJsonService from "../../networking/TranscriptJsonService"; import Vote from "../../models/Vote"; -import useFetchData, { - FetchDataActionType, -} from "../../containers/FetchDataContainer/useFetchData"; -import FetchDataContainer from "../../containers/FetchDataContainer/FetchDataContainer"; import { EventContainer } from "../../containers/EventContainer"; import { SentenceWithSessionIndex, EventData } from "../../containers/EventContainer/types"; - -import { createError } from "../../utils/createError"; +import { FetchDataContainer } from "../../containers/FetchDataContainer"; +import useFetchData from "../../containers/FetchDataContainer/useFetchData"; const EventPage: FC = () => { // Get the id the the event, provided the route is `events/:id` @@ -27,11 +23,7 @@ const EventPage: FC = () => { // Get the app config context const { firebaseConfig } = useAppConfigContext(); - const { state: eventDataState, dispatch: eventDataDispatch } = useFetchData({ - isLoading: false, - }); - - useEffect(() => { + const fetchEventData = useCallback(async () => { const eventService = new EventService(firebaseConfig); const sessionService = new SessionService(firebaseConfig); const transcriptService = new TranscriptService(firebaseConfig); @@ -39,103 +31,87 @@ const EventPage: FC = () => { const eventMinutesItemFileService = new EventMinutesItemFileService(firebaseConfig); const voteService = new VoteService(firebaseConfig); const transcriptJsonService = new TranscriptJsonService(firebaseConfig); - - let didCancel = false; - - const fetchEventData = async () => { - eventDataDispatch({ type: FetchDataActionType.FETCH_INIT }); - - try { - // Get data from the event id - const eventPromise = eventService.getEventById(id); - const sessionsPromise = sessionService.getSessionsByEventId(id); - const eventMinutesItemsPromise = eventMinutesItemService.getEventMinutesItemsByEventId(id); - const votesPromise = voteService.getVotesByEventId(id); - const [event, sessions, eventMinutesItems, votes] = await Promise.all([ - eventPromise, - sessionsPromise, - eventMinutesItemsPromise, - votesPromise, - ]); - - // Get the event minutes items files from event minutes items - const eventMinutesItemsFilesPromise = Promise.all( - eventMinutesItems.map(({ id }) => - eventMinutesItemFileService.getEventMinutesItemFilesByEventMinutesItemId(id as string) - ) - ); - // Get the transcripts from the sessions - const transcriptsPromise = Promise.all( - sessions.map(({ id }) => transcriptService.getTranscriptBySessionId(id as string)) - ); - const [eventMinutesItemsFiles, transcripts] = await Promise.all([ - eventMinutesItemsFilesPromise, - transcriptsPromise, - ]); - - // Create transcript items from the transcripts - const transcriptJsons = await Promise.all( - transcripts.map((transcript) => { - return transcriptJsonService.download(transcript[0].file?.uri as string); - }) - ); - // Unpack sentences from all transcripts - const sentences: SentenceWithSessionIndex[] = []; - transcriptJsons.forEach((transcriptJson, sessionIndex) => { - transcriptJson?.sentences?.forEach((sentence) => { - sentences.push({ - session_index: sessionIndex, - index: sentences.length, - start_time: sentence.start_time, - end_time: sentence.end_time, - text: sentence.text, - speaker_name: sentence.speaker_name, - speaker_index: sentence.speaker_index, - speaker_id: undefined, - speaker_pictureSrc: undefined, - } as SentenceWithSessionIndex); - }); - }); - - // Create votes list for each event minutes item - const votesByEventMinutesItemDict = votes.reduce((dict, vote) => { - if (!Object.keys(dict).includes(vote.event_minutes_item_ref as string)) { - dict[vote.event_minutes_item_ref as string] = []; - } - dict[vote.event_minutes_item_ref as string].push(vote); - return dict; - }, {} as Record); - const votesByEventMinutesItem = eventMinutesItems.map((eventMinutesItem) => { - return votesByEventMinutesItemDict[eventMinutesItem.id as string] || []; - }); - - if (!didCancel) { - eventDataDispatch({ - type: FetchDataActionType.FETCH_SUCCESS, - payload: { - event, - sessions, - sentences, - eventMinutesItems, - eventMinutesItemsFiles, - votes: votesByEventMinutesItem, - }, - }); - } - } catch (err) { - if (!didCancel) { - const error = createError(err); - eventDataDispatch({ type: FetchDataActionType.FETCH_FAILURE, payload: error }); - } + // Get data from the event id + const eventPromise = eventService.getEventById(id); + const sessionsPromise = sessionService.getSessionsByEventId(id); + const eventMinutesItemsPromise = eventMinutesItemService.getEventMinutesItemsByEventId(id); + const votesPromise = voteService.getVotesByEventId(id); + const [event, sessions, eventMinutesItems, votes] = await Promise.all([ + eventPromise, + sessionsPromise, + eventMinutesItemsPromise, + votesPromise, + ]); + + // Get the event minutes items files from event minutes items + const eventMinutesItemsFilesPromise = Promise.all( + eventMinutesItems.map(({ id }) => + eventMinutesItemFileService.getEventMinutesItemFilesByEventMinutesItemId(id as string) + ) + ); + // Get the transcripts from the sessions + const transcriptsPromise = Promise.all( + sessions.map(({ id }) => transcriptService.getTranscriptBySessionId(id as string)) + ); + const [eventMinutesItemsFiles, transcripts] = await Promise.all([ + eventMinutesItemsFilesPromise, + transcriptsPromise, + ]); + + // Create transcript items from the transcripts + const transcriptJsons = await Promise.all( + transcripts.map((transcript) => { + return transcriptJsonService.download(transcript[0].file?.uri as string); + }) + ); + // Unpack sentences from all transcripts + const sentences: SentenceWithSessionIndex[] = []; + transcriptJsons.forEach((transcriptJson, sessionIndex) => { + transcriptJson?.sentences?.forEach((sentence) => { + sentences.push({ + session_index: sessionIndex, + index: sentences.length, + start_time: sentence.start_time, + end_time: sentence.end_time, + text: sentence.text, + speaker_name: sentence.speaker_name, + speaker_index: sentence.speaker_index, + speaker_id: undefined, + speaker_pictureSrc: undefined, + } as SentenceWithSessionIndex); + }); + }); + + // Create votes list for each event minutes item + const votesByEventMinutesItemDict = votes.reduce((dict, vote) => { + if (!Object.keys(dict).includes(vote.event_minutes_item_ref as string)) { + dict[vote.event_minutes_item_ref as string] = []; } - }; - - fetchEventData(); - - return () => { - didCancel = true; - }; - }, [id]); + dict[vote.event_minutes_item_ref as string].push(vote); + return dict; + }, {} as Record); + const votesByEventMinutesItem = eventMinutesItems.map((eventMinutesItem) => { + return votesByEventMinutesItemDict[eventMinutesItem.id as string] || []; + }); + + return Promise.resolve({ + event, + sessions, + sentences, + eventMinutesItems, + eventMinutesItemsFiles, + votes: votesByEventMinutesItem, + }); + }, [firebaseConfig, id]); + + const { state: eventDataState } = useFetchData( + { + isLoading: false, + error: null, + hasFetchRequest: true, + }, + fetchEventData + ); return ( diff --git a/src/pages/EventsPage/EventsPage.tsx b/src/pages/EventsPage/EventsPage.tsx index 9e524e14..b6a4397c 100644 --- a/src/pages/EventsPage/EventsPage.tsx +++ b/src/pages/EventsPage/EventsPage.tsx @@ -1,20 +1,14 @@ -import React, { FC, useEffect } from "react"; +import React, { FC, useCallback } from "react"; import { useAppConfigContext } from "../../app"; import useDocumentTitle from "../../hooks/useDocumentTitle"; import BodyService from "../../networking/BodyService"; -import EventService from "../../networking/EventService"; -import { ORDER_DIRECTION } from "../../networking/constants"; -import { EventsData } from "../../containers/EventsContainer/types"; -import useFetchData, { - FetchDataActionType, -} from "../../containers/FetchDataContainer/useFetchData"; -import FetchDataContainer from "../../containers/FetchDataContainer/FetchDataContainer"; import { EventsContainer } from "../../containers/EventsContainer"; - -import { createError } from "../../utils/createError"; +import { EventsData } from "../../containers/EventsContainer/types"; +import { FetchDataContainer } from "../../containers/FetchDataContainer"; +import useFetchData from "../../containers/FetchDataContainer/useFetchData"; import { strings } from "../../assets/LocalizedStrings"; @@ -22,59 +16,21 @@ const EventsPage: FC = () => { const { firebaseConfig } = useAppConfigContext(); useDocumentTitle(strings.events); - const { state: eventsDataState, dispatch: eventsDataDispatch } = useFetchData({ - isLoading: false, - }); - - useEffect(() => { - let didCancel = false; - const eventService = new EventService(firebaseConfig); + const fetchEventsData = useCallback(async () => { const bodyService = new BodyService(firebaseConfig); - - const fetchEventsData = async () => { - eventsDataDispatch({ type: FetchDataActionType.FETCH_INIT }); - - try { - const bodies = await bodyService.getAllBodies(); - const events = await eventService.getEvents( - 10, - [], - { start: undefined, end: undefined }, - { - by: "event_datetime", - order: ORDER_DIRECTION.desc, - } - ); - const renderableEvents = await Promise.all( - events.map((event) => { - return eventService.getRenderableEvent(event); - }) - ); - - if (!didCancel) { - eventsDataDispatch({ - type: FetchDataActionType.FETCH_SUCCESS, - payload: { - bodies, - events: renderableEvents, - }, - }); - } - } catch (err) { - if (!didCancel) { - const error = createError(err); - eventsDataDispatch({ type: FetchDataActionType.FETCH_FAILURE, payload: error }); - } - } - }; - - fetchEventsData(); - - return () => { - didCancel = true; - }; + const bodies = await bodyService.getAllBodies(); + return Promise.resolve({ bodies }); }, [firebaseConfig]); + const { state: eventsDataState } = useFetchData( + { + isLoading: false, + error: null, + hasFetchRequest: true, + }, + fetchEventsData + ); + return ( {eventsDataState.data && } diff --git a/src/pages/PersonPage/PersonPage.tsx b/src/pages/PersonPage/PersonPage.tsx index 5e007383..9006764b 100644 --- a/src/pages/PersonPage/PersonPage.tsx +++ b/src/pages/PersonPage/PersonPage.tsx @@ -1,61 +1,34 @@ -import React, { FC, useEffect } from "react"; +import React, { FC, useCallback } from "react"; import { useParams } from "react-router-dom"; import { useAppConfigContext } from "../../app"; import Person from "../../models/Person"; import PersonService from "../../networking/PersonService"; -import useFetchData, { - FetchDataActionType, -} from "../../containers/FetchDataContainer/useFetchData"; +import useFetchData from "../../containers/FetchDataContainer/useFetchData"; import FetchDataContainer from "../../containers/FetchDataContainer/FetchDataContainer"; import { PersonContainer } from "../../containers/PersonContainer"; -import { createError } from "../../utils/createError"; - const PersonPage: FC = () => { // Get the id the person, provided the route is `persons/:id` const { id } = useParams<{ id: string }>(); // Get the app config context const { firebaseConfig } = useAppConfigContext(); - // Initialize the state of fetching the person's data - const { state: personDataState, dispatch: personDataDispatch } = useFetchData({ - isLoading: false, - }); - - useEffect(() => { + const fetchPerson = useCallback(async () => { const personService = new PersonService(firebaseConfig); + return personService.getPersonById(id); + }, [id, firebaseConfig]); - let didCancel = false; - - const fetchPersonData = async () => { - personDataDispatch({ type: FetchDataActionType.FETCH_INIT }); - - try { - // Get data from the person id - const person = await personService.getPersonById(id); - - if (!didCancel) { - personDataDispatch({ - type: FetchDataActionType.FETCH_SUCCESS, - payload: person, - }); - } - } catch (err) { - if (!didCancel) { - const error = createError(err); - personDataDispatch({ type: FetchDataActionType.FETCH_FAILURE, payload: error }); - } - } - }; - - fetchPersonData(); - - return () => { - didCancel = true; - }; - }, [id]); + // Initialize the state of fetching the person's data + const { state: personDataState } = useFetchData( + { + isLoading: false, + error: null, + hasFetchRequest: true, + }, + fetchPerson + ); return ( diff --git a/src/pages/SearchEventsPage/SearchEventsPage.tsx b/src/pages/SearchEventsPage/SearchEventsPage.tsx new file mode 100644 index 00000000..bbccdf05 --- /dev/null +++ b/src/pages/SearchEventsPage/SearchEventsPage.tsx @@ -0,0 +1,55 @@ +import React, { FC, useCallback, useMemo } from "react"; +import { useLocation } from "react-router-dom"; +import queryString from "query-string"; + +import { useAppConfigContext } from "../../app"; +import BodyService from "../../networking/BodyService"; + +import { FetchDataContainer } from "../../containers/FetchDataContainer"; +import useFetchData from "../../containers/FetchDataContainer/useFetchData"; +import { SearchEventsContainer } from "../../containers/SearchEventsContainer"; +import { SearchEventsContainerData } from "../../containers/SearchEventsContainer/types"; +import { SearchEventsState } from "./types"; + +const SearchEventsPage: FC = () => { + const location = useLocation(); + const searchEventsState = useMemo(() => { + if (location.state) { + return location.state; + } + const { q } = queryString.parse(location.search); + return { + query: (q as string) || " ", + committees: {}, + dateRange: { start: "", end: "" }, + }; + }, [location]); + + const { firebaseConfig } = useAppConfigContext(); + + const fetchSearchEventsContainerData = useCallback(async () => { + const bodyService = new BodyService(firebaseConfig); + const bodies = await bodyService.getAllBodies(); + return Promise.resolve({ bodies, searchEventsState }); + }, [firebaseConfig, searchEventsState]); + + const { state: searchEventsDataState } = useFetchData( + { + isLoading: false, + error: null, + hasFetchRequest: true, + }, + fetchSearchEventsContainerData + ); + + return ( + + {searchEventsDataState.data && } + + ); +}; + +export default SearchEventsPage; diff --git a/src/pages/SearchEventsPage/index.ts b/src/pages/SearchEventsPage/index.ts new file mode 100644 index 00000000..4eba9b07 --- /dev/null +++ b/src/pages/SearchEventsPage/index.ts @@ -0,0 +1 @@ +export { default as SearchEventsPage } from "./SearchEventsPage"; diff --git a/src/pages/SearchEventsPage/types.ts b/src/pages/SearchEventsPage/types.ts new file mode 100644 index 00000000..669f0dc6 --- /dev/null +++ b/src/pages/SearchEventsPage/types.ts @@ -0,0 +1,7 @@ +import { FilterState } from "../../components/Filters/reducer"; + +export interface SearchEventsState { + query: string; + committees: FilterState; + dateRange: FilterState; +} diff --git a/src/pages/SearchPage/SearchPage.tsx b/src/pages/SearchPage/SearchPage.tsx index 2bf9dc16..ff109866 100644 --- a/src/pages/SearchPage/SearchPage.tsx +++ b/src/pages/SearchPage/SearchPage.tsx @@ -1,7 +1,27 @@ -import React, { FC } from "react"; +import React, { FC, useMemo } from "react"; +import { useLocation } from "react-router-dom"; +import queryString from "query-string"; + +import { SearchContainer } from "../../containers/SearchContainer"; +import { SEARCH_TYPE, SearchState } from "./types"; const SearchPage: FC = () => { - return
TODO
; + const location = useLocation(); + const searchState = useMemo(() => { + if (location.state) { + return location.state; + } + const { q } = queryString.parse(location.search); + return { + query: (q as string) || "", + searchTypes: { + [SEARCH_TYPE.EVENT]: true, + [SEARCH_TYPE.LEGISLATION]: true, + }, + }; + }, [location]); + + return ; }; export default SearchPage; diff --git a/src/pages/SearchPage/types.ts b/src/pages/SearchPage/types.ts new file mode 100644 index 00000000..94f4227b --- /dev/null +++ b/src/pages/SearchPage/types.ts @@ -0,0 +1,9 @@ +export enum SEARCH_TYPE { + EVENT = "events", + LEGISLATION = "legislations", +} + +export interface SearchState { + query: string; + searchTypes: Record; +}