diff --git a/CHANGELOG.md b/CHANGELOG.md index 12e005ab8..f11f4b067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] ### Added - Adjust marker icon on selecting a new facility on the vector tiles layer [#749](https://github.com/open-apparel-registry/open-apparel-registry/pull/749) +- Fetch next page of facilities while scrolling through sidebar list [#750](https://github.com/open-apparel-registry/open-apparel-registry/pull/750) ### Changed diff --git a/src/app/src/__tests__/utils.tests.js b/src/app/src/__tests__/utils.tests.js index d2c5aa961..1f2f1b655 100644 --- a/src/app/src/__tests__/utils.tests.js +++ b/src/app/src/__tests__/utils.tests.js @@ -61,8 +61,6 @@ const { makeUserProfileURL, makeProfileRouteLink, joinDataIntoCSVString, - caseInsensitiveIncludes, - sortFacilitiesAlphabeticallyByName, updateListWithLabels, makeSubmitFormOnEnterKeyPressFunction, makeFacilityListItemsRetrieveCSVItemsURL, @@ -75,6 +73,7 @@ const { claimFacilityContactInfoStepIsValid, claimFacilityFacilityInfoStepIsValid, anyListItemMatchesAreInactive, + pluralizeResultsCount, } = require('../util/util'); const { @@ -88,6 +87,7 @@ const { ENTER_KEY, facilityListItemStatusChoicesEnum, facilityListSummaryStatusMessages, + FACILITIES_REQUEST_PAGE_SIZE, } = require('../util/constants'); it('creates a route for checking facility list items', () => { @@ -140,8 +140,9 @@ it('creates an API URL for getting a single facility by OAR ID', () => { it('creates an API URL for getting facilities with a query string', () => { const qs = 'hello=world'; - const expectedMatch = '/api/facilities/?hello=world'; - expect(makeGetFacilitiesURLWithQueryString(qs)).toEqual(expectedMatch); + const expectedMatch = `/api/facilities/?hello=world&pageSize=${FACILITIES_REQUEST_PAGE_SIZE}`; + expect(makeGetFacilitiesURLWithQueryString(qs, FACILITIES_REQUEST_PAGE_SIZE)) + .toEqual(expectedMatch); }); it('gets the value from a React Select option object', () => { @@ -877,83 +878,6 @@ it('joins a 2-d array into a correctly escaped CSV string', () => { expect(joinDataIntoCSVString(escapedArray)).toBe(expectedEscapedArrayMatch); }); -it('checks whether one string includes another regardless of char case', () => { - const uppercaseTarget = 'HELLOWORLD'; - const lowercaseTest = 'world'; - const lowercaseTarget = 'helloworld'; - const uppercaseTest = 'WORLD'; - const uppercaseNonMatchTest = 'FOO'; - const lowercaseNonMatchTest = 'foo'; - - expect(caseInsensitiveIncludes(uppercaseTarget, lowercaseTest)).toBe(true); - expect(caseInsensitiveIncludes(lowercaseTarget, uppercaseTest)).toBe(true); - expect(caseInsensitiveIncludes(lowercaseTarget, lowercaseTest)).toBe(true); - expect(caseInsensitiveIncludes(uppercaseTarget, uppercaseTest)).toBe(true); - - expect(caseInsensitiveIncludes(uppercaseTarget, lowercaseNonMatchTest)).toBe(false); - expect(caseInsensitiveIncludes(lowercaseTarget, uppercaseNonMatchTest)).toBe(false); - expect(caseInsensitiveIncludes(lowercaseTarget, lowercaseNonMatchTest)).toBe(false); - expect(caseInsensitiveIncludes(uppercaseTarget, uppercaseNonMatchTest)).toBe(false); -}); - -it('sorts an array of facilities alphabetically by name without mutating the input', () => { - const inputData = [ - { - properties: { - name: 'hello World', - }, - }, - { - properties: { - name: 'FOO', - }, - }, - { - properties: { - name: 'Bar', - }, - }, - { - properties: { - name: 'baz', - }, - }, - ]; - - const expectedSortedData = [ - { - properties: { - name: 'Bar', - }, - }, - { - properties: { - name: 'baz', - }, - }, - { - properties: { - name: 'FOO', - }, - }, - { - properties: { - name: 'hello World', - }, - }, - ]; - - expect(isEqual( - sortFacilitiesAlphabeticallyByName(inputData), - expectedSortedData, - )).toBe(true); - - expect(isEqual( - inputData, - expectedSortedData, - )).toBe(false); -}); - it('updates a list of unlabeled values with the correct labels from a given source', () => { const source = [ { @@ -1348,3 +1272,11 @@ it('checks a facility list item to see whether any matches have been set to inac expect(anyListItemMatchesAreInactive(listItemWithInactiveMatches)).toBe(true); }); + +it('pluralizes a results count correclty, returning null if count is undefined or null', () => { + expect(pluralizeResultsCount(undefined)).toBeNull(); + expect(pluralizeResultsCount(null)).toBeNull(); + expect(pluralizeResultsCount(1)).toBe('1 result'); + expect(pluralizeResultsCount(0)).toBe('0 results'); + expect(pluralizeResultsCount(200)).toBe('200 results'); +}); diff --git a/src/app/src/actions/facilities.js b/src/app/src/actions/facilities.js index 1d7cd3c24..f3125dab9 100644 --- a/src/app/src/actions/facilities.js +++ b/src/app/src/actions/facilities.js @@ -6,6 +6,8 @@ import apiRequest from '../util/apiRequest'; import { fetchCurrentTileCacheKey } from './vectorTileLayer'; +import { FACILITIES_REQUEST_PAGE_SIZE } from '../util/constants'; + import { logErrorAndDispatchFailure, makeGetFacilitiesURLWithQueryString, @@ -24,7 +26,10 @@ export const failFetchSingleFacility = createAction('FAIL_FETCH_SINGLE_FACILITY' export const completeFetchSingleFacility = createAction('COMPLETE_FETCH_SINGLE_FACILITY'); export const resetSingleFacility = createAction('RESET_SINGLE_FACILITY'); -export function fetchFacilities(pushNewRoute = noop) { +export function fetchFacilities({ + pageSize = FACILITIES_REQUEST_PAGE_SIZE, + pushNewRoute = noop, +}) { return (dispatch, getState) => { dispatch(fetchCurrentTileCacheKey()); dispatch(startFetchFacilities()); @@ -36,7 +41,7 @@ export function fetchFacilities(pushNewRoute = noop) { const qs = createQueryStringFromSearchFilters(filters); return apiRequest - .get(makeGetFacilitiesURLWithQueryString(qs)) + .get(makeGetFacilitiesURLWithQueryString(qs, pageSize)) .then(({ data }) => { const responseHasOnlyOneFacility = get( data, @@ -65,6 +70,37 @@ export function fetchFacilities(pushNewRoute = noop) { }; } +export const startFetchNextPageOfFacilities = createAction('START_FETCH_NEXT_PAGE_OF_FACILITIES'); +export const failFetchNextPageOfFacilities = createAction('FAIL_FETCH_NEXT_PAGE_OF_FACILITIES'); +export const completeFetchNextPageOfFacilities = createAction('COMPLETE_FETCH_NEXT_PAGE_OF_FACILITIES'); + +export function fetchNextPageOfFacilities() { + return (dispatch, getState) => { + const { + facilities: { + facilities: { + nextPageURL, + }, + }, + } = getState(); + + if (!nextPageURL) { + return noop(); + } + + dispatch(startFetchNextPageOfFacilities()); + + return apiRequest + .get(nextPageURL) + .then(({ data }) => dispatch(completeFetchNextPageOfFacilities(data))) + .catch(err => dispatch(logErrorAndDispatchFailure( + err, + 'An error prevented fetching the next page of facilities', + failFetchNextPageOfFacilities, + ))); + }; +} + export function fetchSingleFacility(oarID = null) { return (dispatch) => { dispatch(startFetchSingleFacility()); diff --git a/src/app/src/actions/ui.js b/src/app/src/actions/ui.js index 58c41ce21..aa169d7e1 100644 --- a/src/app/src/actions/ui.js +++ b/src/app/src/actions/ui.js @@ -4,13 +4,13 @@ export const makeSidebarGuideTabActive = createAction('MAKE_SIDEBAR_GUIDE_TAB_AC export const makeSidebarSearchTabActive = createAction('MAKE_SIDEBAR_SEARCH_TAB_ACTIVE'); export const makeSidebarFacilitiesTabActive = createAction('MAKE_SIDEBAR_FACILITIES_TAB_ACTIVE'); -export const updateSidebarFacilitiesTabTextFilter = - createAction('UPDATE_SIDEBAR_FACILITIES_TAB_TEXT_FILTER'); -export const resetSidebarFacilitiesTabTextFilter = - createAction('RESET_SIDEBAR_FACILITIES_TAB_TEXT_FILTER'); - export const recordSearchTabResetButtonClick = createAction('RECORD_SEARCH_TAB_RESET_BUTTON_CLICK'); export const reportWindowResize = createAction('REPORT_WINDOW_RESIZE'); + +export const updateSidebarFacilitiesTabTextFilter = + createAction('UPDATE_SIDEBAR_FACILITIES_TAB_TEXT_FILTER'); +export const resetSidebarFacilitiesTabTextFilter = + createAction('RESET_SIDEBAR_FACILITIES_TAB_TEXT_FILTER'); diff --git a/src/app/src/components/FilterSidebar.jsx b/src/app/src/components/FilterSidebar.jsx index 44626c922..988a5b4cf 100644 --- a/src/app/src/components/FilterSidebar.jsx +++ b/src/app/src/components/FilterSidebar.jsx @@ -9,10 +9,13 @@ import { Route } from 'react-router-dom'; import FilterSidebarGuideTab from './FilterSidebarGuideTab'; import FilterSidebarSearchTab from './FilterSidebarSearchTab'; import FilterSidebarFacilitiesTab from './FilterSidebarFacilitiesTab'; +import NonVectorTileFilterSidebarFacilitiesTab from './NonVectorTileFilterSidebarFacilitiesTab'; +import FeatureFlag from './FeatureFlag'; import { filterSidebarTabsEnum, filterSidebarTabs, + VECTOR_TILE, } from '../util/constants'; import { @@ -136,7 +139,14 @@ class FilterSidebar extends Component { // in its `mapDispatchToProps` function. return ; case filterSidebarTabsEnum.facilities: - return ; + return ( + } + > + + + ); default: window.console.warn('invalid tab selection', activeFilterSidebarTab); return null; diff --git a/src/app/src/components/FilterSidebarFacilitiesTab.jsx b/src/app/src/components/FilterSidebarFacilitiesTab.jsx index a7c5a9864..1c9607080 100644 --- a/src/app/src/components/FilterSidebarFacilitiesTab.jsx +++ b/src/app/src/components/FilterSidebarFacilitiesTab.jsx @@ -17,13 +17,12 @@ import get from 'lodash/get'; import { toast } from 'react-toastify'; import InfiniteAnyHeight from 'react-infinite-any-height'; -import ControlledTextInput from './ControlledTextInput'; - import { makeSidebarSearchTabActive, - updateSidebarFacilitiesTabTextFilter, } from '../actions/ui'; +import { fetchNextPageOfFacilities } from '../actions/facilities'; + import { logDownload } from '../actions/logDownload'; import { facilityCollectionPropType } from '../util/propTypes'; @@ -35,17 +34,13 @@ import { import { makeFacilityDetailLink, - getValueFromEvent, - caseInsensitiveIncludes, - sortFacilitiesAlphabeticallyByName, + pluralizeResultsCount, } from '../util/util'; import COLOURS from '../util/COLOURS'; import { filterSidebarStyles } from '../util/styles'; -const SEARCH_TERM_INPUT = 'SEARCH_TERM_INPUT'; - const facilitiesTabStyles = Object.freeze({ noResultsTextStyles: Object.freeze({ margin: '30px', @@ -95,9 +90,9 @@ function FilterSidebarFacilitiesTab({ logDownloadError, user, returnToSearchTab, - filterText, - updateFilterText, handleDownload, + fetchNextPage, + isInfiniteLoading, }) { const [loginRequiredDialogIsOpen, setLoginRequiredDialogIsOpen] = useState(false); const [requestedDownload, setRequestedDownload] = useState(false); @@ -188,27 +183,9 @@ function FilterSidebarFacilitiesTab({ ); } - const filteredFacilities = filterText - ? facilities - .filter(({ - properties: { - address, - name, - country_name: countryName, - }, - }) => caseInsensitiveIncludes(address, filterText) - || caseInsensitiveIncludes(name, filterText) - || caseInsensitiveIncludes(countryName, filterText)) - : facilities; - - const orderedFacilities = - sortFacilitiesAlphabeticallyByName(filteredFacilities); - const facilitiesCount = get(data, 'count', null); - const headerDisplayString = facilitiesCount && (facilitiesCount !== filteredFacilities.length) - ? `Displaying ${filteredFacilities.length} facilities of ${facilitiesCount} results` - : `Displaying ${filteredFacilities.length} facilities`; + const headerDisplayString = pluralizeResultsCount(facilitiesCount); const LoginLink = props => ; const RegisterLink = props => ; @@ -242,19 +219,6 @@ function FilterSidebarFacilitiesTab({ -
- - -
); @@ -265,6 +229,15 @@ function FilterSidebarFacilitiesTab({ const resultListHeight = windowHeight - nonResultListComponentHeight; + const loadingElement = (facilities.length !== facilitiesCount) && ( + + + + + + + ); + return ( {listHeaderInsetComponent} @@ -272,8 +245,12 @@ function FilterSidebarFacilitiesTab({ dispatch(makeSidebarSearchTabActive()), - updateFilterText: e => - dispatch((updateSidebarFacilitiesTabTextFilter(getValueFromEvent(e)))), handleDownload: () => dispatch(logDownload()), + fetchNextPage: () => dispatch(fetchNextPageOfFacilities()), }; } diff --git a/src/app/src/components/FilterSidebarSearchTab.jsx b/src/app/src/components/FilterSidebarSearchTab.jsx index 69f2b97d9..7f85a14d0 100644 --- a/src/app/src/components/FilterSidebarSearchTab.jsx +++ b/src/app/src/components/FilterSidebarSearchTab.jsx @@ -6,6 +6,7 @@ import TextField from '@material-ui/core/TextField'; import Button from '@material-ui/core/Button'; import CircularProgress from '@material-ui/core/CircularProgress'; import ReactSelect from 'react-select'; +import get from 'lodash/get'; import FacilitySidebarSearchTabFacilitiesCount from './FacilitySidebarSearchTabFacilitiesCount'; @@ -35,6 +36,8 @@ import { makeSubmitFormOnEnterKeyPressFunction, } from '../util/util'; +import { FACILITIES_REQUEST_PAGE_SIZE } from '../util/constants'; + const filterSidebarSearchTabStyles = Object.freeze({ formStyle: Object.freeze({ width: '100%', @@ -72,6 +75,7 @@ function FilterSidebarSearchTab({ facilities, fetchingOptions, submitFormOnEnterKeyPress, + vectorTileFlagIsActive, }) { if (fetchingOptions) { return ( @@ -219,7 +223,7 @@ function FilterSidebarSearchTab({ color="primary" className="margin-left-16 blue-background" style={{ boxShadow: 'none' }} - onClick={searchForFacilities} + onClick={() => searchForFacilities(vectorTileFlagIsActive)} disabled={fetchingOptions} > Search @@ -255,6 +259,7 @@ FilterSidebarSearchTab.propTypes = { searchForFacilities: func.isRequired, facilities: facilityCollectionPropType, fetchingOptions: bool.isRequired, + vectorTileFlagIsActive: bool.isRequired, }; function mapStateToProps({ @@ -284,8 +289,12 @@ function mapStateToProps({ fetching: fetchingFacilities, }, }, + featureFlags, }) { + const vectorTileFlagIsActive = get(featureFlags, 'flags.vector_tile', false); + return { + vectorTileFlagIsActive, contributorOptions, contributorTypeOptions, countryOptions, @@ -316,7 +325,10 @@ function mapDispatchToProps(dispatch, { dispatch(recordSearchTabResetButtonClick()); return dispatch(resetAllFilters()); }, - searchForFacilities: () => dispatch(fetchFacilities(push)), + searchForFacilities: vectorTilesAreActive => dispatch(fetchFacilities({ + pageSize: vectorTilesAreActive ? FACILITIES_REQUEST_PAGE_SIZE : 500, + pushNewRoute: push, + })), submitFormOnEnterKeyPress: makeSubmitFormOnEnterKeyPressFunction( () => dispatch(fetchFacilities(push)), ), diff --git a/src/app/src/components/NonVectorTileFilterSidebarFacilitiesTab.jsx b/src/app/src/components/NonVectorTileFilterSidebarFacilitiesTab.jsx new file mode 100644 index 000000000..1d7e0ed64 --- /dev/null +++ b/src/app/src/components/NonVectorTileFilterSidebarFacilitiesTab.jsx @@ -0,0 +1,446 @@ +import React, { Fragment, useState, useEffect } from 'react'; +import { arrayOf, bool, func, number, string } from 'prop-types'; +import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogActions from '@material-ui/core/DialogActions'; +import Typography from '@material-ui/core/Typography'; +import Button from '@material-ui/core/Button'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import Divider from '@material-ui/core/Divider'; +import get from 'lodash/get'; +import { toast } from 'react-toastify'; +import InfiniteAnyHeight from 'react-infinite-any-height'; +import includes from 'lodash/includes'; +import lowerCase from 'lodash/lowerCase'; + +import ControlledTextInput from './ControlledTextInput'; + +import { + makeSidebarSearchTabActive, + updateSidebarFacilitiesTabTextFilter, +} from '../actions/ui'; + +import { logDownload } from '../actions/logDownload'; + +import { facilityCollectionPropType } from '../util/propTypes'; + +import { + authLoginFormRoute, + authRegisterFormRoute, +} from '../util/constants'; + +import { + makeFacilityDetailLink, + getValueFromEvent, +} from '../util/util'; + +import COLOURS from '../util/COLOURS'; + +import { filterSidebarStyles } from '../util/styles'; + +const SEARCH_TERM_INPUT = 'SEARCH_TERM_INPUT'; + +const caseInsensitiveIncludes = (target, test) => + includes(lowerCase(target), lowerCase(test)); + +const sortFacilitiesAlphabeticallyByName = data => data + .slice() + .sort(( + { + properties: { + name: firstFacilityName, + }, + }, + { + properties: { + name: secondFacilityName, + }, + }, + ) => { + const a = lowerCase(firstFacilityName); + const b = lowerCase(secondFacilityName); + + if (a === b) { + return 0; + } + + return (a < b) ? -1 : 1; + }); + +const facilitiesTabStyles = Object.freeze({ + noResultsTextStyles: Object.freeze({ + margin: '30px', + }), + linkStyles: Object.freeze({ + display: 'flex', + textDecoration: 'none', + }), + listItemStyles: Object.freeze({ + wordWrap: 'anywhere', + }), + listHeaderStyles: Object.freeze({ + backgroundColor: COLOURS.WHITE, + padding: '0.25rem', + maxHeight: '130px', + }), + listStyles: Object.freeze({ + /* overflowY: 'scroll', + position: 'fixed', + // sum heights of navbar, tab bar, panel header, and free text search control + top: 'calc(64px + 48px + 130px + 110px)', + bottom: '47px', + width: '33%', */ + }), + listBottomPaddingStyles: Object.freeze({ + height: '200px', + }), + titleRowStyles: Object.freeze({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '6px 1rem', + }), + listHeaderTextSearchStyles: Object.freeze({ + padding: '6px 1rem', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }), +}); + +function NonVectorTileFilterSidebarFacilitiesTab({ + fetching, + data, + error, + windowHeight, + logDownloadError, + user, + returnToSearchTab, + filterText, + updateFilterText, + handleDownload, +}) { + const [loginRequiredDialogIsOpen, setLoginRequiredDialogIsOpen] = useState(false); + const [requestedDownload, setRequestedDownload] = useState(false); + + useEffect(() => { + if (requestedDownload && logDownloadError) { + toast('A problem prevented downloading the facilities'); + setRequestedDownload(false); + } + }, [logDownloadError, requestedDownload]); + + if (fetching) { + return ( +
+
+ +
+
+ ); + } + + if (error) { + return ( +
+
+ + An error prevented fetching facilities + + + + +
+
+ ); + } + + const facilities = get(data, 'features', []); + + if (!facilities.length) { + return ( +
+
+ + No facilities to display + + + + +
+
+ ); + } + + const filteredFacilities = filterText + ? facilities + .filter(({ + properties: { + address, + name, + country_name: countryName, + }, + }) => caseInsensitiveIncludes(address, filterText) + || caseInsensitiveIncludes(name, filterText) + || caseInsensitiveIncludes(countryName, filterText)) + : facilities; + + const orderedFacilities = + sortFacilitiesAlphabeticallyByName(filteredFacilities); + + const facilitiesCount = get(data, 'count', null); + + const headerDisplayString = facilitiesCount && (facilitiesCount !== filteredFacilities.length) + ? `Displaying ${filteredFacilities.length} facilities of ${facilitiesCount} results` + : `Displaying ${filteredFacilities.length} facilities`; + + const LoginLink = props => ; + const RegisterLink = props => ; + + const listHeaderInsetComponent = ( +
+ +
+ {headerDisplayString} + +
+
+
+ + +
+
+ ); + + const nonResultListComponentHeight = ( + Array.from(document.getElementsByClassName('results-height-subtract')) + .reduce((sum, x) => sum + x.offsetHeight, 0) + ); + + const resultListHeight = windowHeight - nonResultListComponentHeight; + + return ( + + {listHeaderInsetComponent} +
+ + ( + + + + + + + + )) + } + /> + +
+ + { loginRequiredDialogIsOpen ? ( + <> + + Log In To Download + + + + You must log in with an Open Apparel Registry + account before downloading your search results. + + + + + + + + + ) : ( +
+ )} +
+
+ ); +} + +NonVectorTileFilterSidebarFacilitiesTab.defaultProps = { + data: null, + error: null, + logDownloadError: null, +}; + +NonVectorTileFilterSidebarFacilitiesTab.propTypes = { + data: facilityCollectionPropType, + fetching: bool.isRequired, + error: arrayOf(string), + windowHeight: number.isRequired, + logDownloadError: arrayOf(string), + returnToSearchTab: func.isRequired, + filterText: string.isRequired, + updateFilterText: func.isRequired, + handleDownload: func.isRequired, +}; + +function mapStateToProps({ + auth: { + user: { + user, + }, + }, + facilities: { + facilities: { + data, + error, + fetching, + }, + }, + ui: { + facilitiesSidebarTabSearch: { + filterText, + }, + window: { + innerHeight: windowHeight, + }, + }, + logDownload: { + error: logDownloadError, + }, +}) { + return { + data, + error, + fetching, + filterText, + user, + logDownloadError, + windowHeight, + }; +} + +function mapDispatchToProps(dispatch) { + return { + returnToSearchTab: () => dispatch(makeSidebarSearchTabActive()), + updateFilterText: e => + dispatch((updateSidebarFacilitiesTabTextFilter(getValueFromEvent(e)))), + handleDownload: () => dispatch(logDownload()), + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(NonVectorTileFilterSidebarFacilitiesTab); diff --git a/src/app/src/reducers/FacilitiesReducer.js b/src/app/src/reducers/FacilitiesReducer.js index b356a0003..4d01f6872 100644 --- a/src/app/src/reducers/FacilitiesReducer.js +++ b/src/app/src/reducers/FacilitiesReducer.js @@ -13,6 +13,9 @@ import { failFetchSingleFacility, completeFetchSingleFacility, resetSingleFacility, + startFetchNextPageOfFacilities, + failFetchNextPageOfFacilities, + completeFetchNextPageOfFacilities, } from '../actions/facilities'; import { @@ -33,6 +36,8 @@ const initialState = Object.freeze({ data: null, fetching: false, error: null, + nextPageURL: null, + isInfiniteLoading: false, }), singleFacility: Object.freeze({ data: null, @@ -100,6 +105,30 @@ export default createReducer({ fetching: { $set: false }, error: { $set: null }, data: { $set: payload }, + nextPageURL: { $set: get(payload, 'next', null) }, + }, + }), + [startFetchNextPageOfFacilities]: state => update(state, { + facilities: { + isInfiniteLoading: { $set: true }, + }, + }), + [failFetchNextPageOfFacilities]: state => update(state, { + facilities: { + isInfiniteLoading: { $set: false }, + }, + }), + [completeFetchNextPageOfFacilities]: (state, payload) => update(state, { + facilities: { + fetching: { $set: false }, + error: { $set: null }, + data: { + features: { + $push: get(payload, 'features', []), + }, + }, + nextPageURL: { $set: get(payload, 'next', null) }, + isInfiniteLoading: { $set: false }, }, }), [resetFacilities]: state => update(state, { diff --git a/src/app/src/util/constants.js b/src/app/src/util/constants.js index 6ea5dd526..1bb70a392 100644 --- a/src/app/src/util/constants.js +++ b/src/app/src/util/constants.js @@ -1,5 +1,7 @@ export const OTHER = 'Other'; +export const FACILITIES_REQUEST_PAGE_SIZE = 25; + // This choices must be kept in sync with the identical list // kept in the Django API's models.py file export const contributorTypeOptions = Object.freeze([ diff --git a/src/app/src/util/util.js b/src/app/src/util/util.js index 457ea380a..cfc10b61a 100644 --- a/src/app/src/util/util.js +++ b/src/app/src/util/util.js @@ -10,6 +10,7 @@ import negate from 'lodash/negate'; import omitBy from 'lodash/omitBy'; import isEmpty from 'lodash/isEmpty'; import isNumber from 'lodash/isNumber'; +import isNil from 'lodash/isNil'; import values from 'lodash/values'; import flow from 'lodash/flow'; import noop from 'lodash/noop'; @@ -18,8 +19,6 @@ import startsWith from 'lodash/startsWith'; import head from 'lodash/head'; import replace from 'lodash/replace'; import trimEnd from 'lodash/trimEnd'; -import includes from 'lodash/includes'; -import lowerCase from 'lodash/lowerCase'; import range from 'lodash/range'; import ceil from 'lodash/ceil'; import toInteger from 'lodash/toInteger'; @@ -93,7 +92,7 @@ export const makeGetCountriesURL = () => '/api/countries/'; export const makeGetFacilitiesURL = () => '/api/facilities/'; export const makeGetFacilityByOARIdURL = oarId => `/api/facilities/${oarId}/`; -export const makeGetFacilitiesURLWithQueryString = qs => `/api/facilities/?${qs}`; +export const makeGetFacilitiesURLWithQueryString = (qs, pageSize) => `/api/facilities/?${qs}&pageSize=${pageSize}`; export const makeClaimFacilityAPIURL = oarId => `/api/facilities/${oarId}/claim/`; export const makeSplitFacilityAPIURL = oarID => `/api/facilities/${oarID}/split/`; export const makePromoteFacilityMatchAPIURL = oarID => `/api/facilities/${oarID}/promote/`; @@ -485,33 +484,6 @@ export const joinDataIntoCSVString = data => data ); }, ''); -export const caseInsensitiveIncludes = (target, test) => - includes(lowerCase(target), lowerCase(test)); - -export const sortFacilitiesAlphabeticallyByName = data => data - .slice() - .sort(( - { - properties: { - name: firstFacilityName, - }, - }, - { - properties: { - name: secondFacilityName, - }, - }, - ) => { - const a = lowerCase(firstFacilityName); - const b = lowerCase(secondFacilityName); - - if (a === b) { - return 0; - } - - return (a < b) ? -1 : 1; - }); - // Given a list where each item is like { label: 'ABCD', value: 123 }, and // a payload which is a list of items like { label: '123', value: 123 }, // returns a list of items from the payload with their labels replaced with @@ -632,3 +604,15 @@ export const claimFacilityFacilityInfoStepIsValid = ({ ]); export const anyListItemMatchesAreInactive = ({ matches }) => some(matches, ['is_active', false]); + +export const pluralizeResultsCount = (count) => { + if (isNil(count)) { + return null; + } + + if (count === 1) { + return '1 result'; + } + + return `${count} results`; +}; diff --git a/src/django/api/views.py b/src/django/api/views.py index 7777cd566..890e6cc91 100644 --- a/src/django/api/views.py +++ b/src/django/api/views.py @@ -558,7 +558,8 @@ def list(self, request): queryset = Facility \ .objects \ - .filter_by_query_params(request.query_params) + .filter_by_query_params(request.query_params) \ + .order_by('name') page_queryset = self.paginate_queryset(queryset)