diff --git a/website/src/styles/main.scss b/website/src/styles/main.scss index de3700646f..b0b577fa0b 100644 --- a/website/src/styles/main.scss +++ b/website/src/styles/main.scss @@ -16,6 +16,7 @@ @import 'utils/workload'; @import 'utils/themes'; @import 'utils/scrollable'; +@import 'utils/deferred'; // Layout @import 'layout/site'; diff --git a/website/src/styles/utils/deferred.scss b/website/src/styles/utils/deferred.scss new file mode 100644 index 0000000000..15eb009a24 --- /dev/null +++ b/website/src/styles/utils/deferred.scss @@ -0,0 +1,11 @@ +// Source: https://reactjs.org/docs/concurrent-mode-patterns.html#delaying-a-pending-indicator +.deferred { + visibility: hidden; + animation: 0s linear 0.2s forwards makeVisible; + + @keyframes makeVisible { + to { + visibility: visible; + } + } +} diff --git a/website/src/views/components/LoadingOverlay.scss b/website/src/views/components/LoadingOverlay.scss new file mode 100644 index 0000000000..a5b9300caf --- /dev/null +++ b/website/src/views/components/LoadingOverlay.scss @@ -0,0 +1,9 @@ +.loadingOverlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 100; + background: var(--body-bg-70); +} diff --git a/website/src/views/components/LoadingOverlay.tsx b/website/src/views/components/LoadingOverlay.tsx new file mode 100644 index 0000000000..ae5b7c5504 --- /dev/null +++ b/website/src/views/components/LoadingOverlay.tsx @@ -0,0 +1,19 @@ +import classnames from 'classnames'; +import type { FC } from 'react'; +import styles from './LoadingOverlay.scss'; + +type Props = { + deferred?: boolean; +}; + +const LoadingOverlay: FC = ({ children, deferred }) => ( +
+ {children} +
+); + +export default LoadingOverlay; diff --git a/website/src/views/components/SearchBox.tsx b/website/src/views/components/SearchBox.tsx index 76b1d14b11..9aed369071 100644 --- a/website/src/views/components/SearchBox.tsx +++ b/website/src/views/components/SearchBox.tsx @@ -15,19 +15,21 @@ import styles from './SearchBox.scss'; export type Props = { className?: string; - throttle: number; - isLoading: boolean; + throttle?: number; + isLoading?: boolean; value: string | null; placeholder?: string; + /** Called when the search box value changes */ onChange: (value: string) => void; - onSearch: () => void; + /** Called when a search should be triggered, potentially debounced by `throttle` milliseconds. */ + onSearch?: () => void; onBlur?: () => void; }; const SearchBox: FC = ({ className, throttle, - isLoading, + isLoading = false, value, placeholder, onChange, @@ -55,7 +57,7 @@ const SearchBox: FC = ({ debounce( () => { isDirty.current = false; - onSearch(); + onSearch?.(); }, throttle, { leading: false }, diff --git a/website/src/views/modules/ModuleFinderContainer/ModuleFinderContainer.scss b/website/src/views/modules/ModuleFinderContainer/ModuleFinderContainer.scss index ed2e5299fa..7b10bd4578 100644 --- a/website/src/views/modules/ModuleFinderContainer/ModuleFinderContainer.scss +++ b/website/src/views/modules/ModuleFinderContainer/ModuleFinderContainer.scss @@ -25,13 +25,3 @@ div.modulesPageContainer { text-align: right; color: var(--gray); } - -.loadingOverlay { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 100; - background: var(--body-bg-70); -} diff --git a/website/src/views/modules/ModuleFinderContainer/ModuleFinderContainer.tsx b/website/src/views/modules/ModuleFinderContainer/ModuleFinderContainer.tsx index 7cc9e5fa98..bb29355de7 100644 --- a/website/src/views/modules/ModuleFinderContainer/ModuleFinderContainer.tsx +++ b/website/src/views/modules/ModuleFinderContainer/ModuleFinderContainer.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import type { FC } from 'react'; import { Hits, HitsStats, @@ -12,9 +12,10 @@ import { } from 'searchkit'; import classnames from 'classnames'; -import { ElasticSearchResult } from 'types/vendor/elastic-search'; -import { ModuleInformation } from 'types/modules'; +import type { ElasticSearchResult } from 'types/vendor/elastic-search'; +import type { ModuleInformation } from 'types/modules'; +import LoadingOverlay from 'views/components/LoadingOverlay'; import ModuleFinderSidebar from 'views/modules/ModuleFinderSidebar'; import ModuleSearchBox from 'views/modules/ModuleSearchBox'; import ModuleFinderNoHits from 'views/errors/ModuleFinderNoHits'; @@ -38,7 +39,7 @@ const searchkit = new SearchkitManager(esHostUrl, { const pageHead = Modules; -const ModuleInformationListComponent: React.FC = ({ hits }) => ( +const ModuleInformationListComponent: FC = ({ hits }) => (
    {hits.map((hit) => { const result = hit as ElasticSearchResult; @@ -55,7 +56,7 @@ const ModuleInformationListComponent: React.FC = ({ hits }) => (
); -const ModuleFinderContainer: React.FC = () => ( +const ModuleFinderContainer: FC = () => (
{pageHead} @@ -73,7 +74,7 @@ const ModuleFinderContainer: React.FC = () => ( /> -
+ diff --git a/website/src/views/venues/AvailabilitySearch.test.tsx b/website/src/views/venues/AvailabilitySearch.test.tsx index 2a4f3b401a..6257641d6f 100644 --- a/website/src/views/venues/AvailabilitySearch.test.tsx +++ b/website/src/views/venues/AvailabilitySearch.test.tsx @@ -1,6 +1,6 @@ import { defaultSearchOptions } from 'views/venues/AvailabilitySearch'; -describe('defaultSearchOptions', () => { +describe(defaultSearchOptions, () => { test('should the nearest slots during school hours', () => { // Monday expect(defaultSearchOptions(new Date('2018-01-15T12:30:00'))).toMatchObject({ diff --git a/website/src/views/venues/VenuesContainer.tsx b/website/src/views/venues/VenuesContainer.tsx index 27cb240b52..fdbba9cddf 100644 --- a/website/src/views/venues/VenuesContainer.tsx +++ b/website/src/views/venues/VenuesContainer.tsx @@ -1,4 +1,11 @@ -import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { + FC, + unstable_useDeferredValue as useDeferredValue, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { useHistory, useLocation, useParams } from 'react-router-dom'; import Loadable, { LoadingComponentProps } from 'react-loadable'; // eslint-disable-next-line import/no-extraneous-dependencies @@ -6,7 +13,7 @@ import { Location, locationsAreEqual } from 'history'; import classnames from 'classnames'; import axios from 'axios'; import qs from 'query-string'; -import { isEqual, mapValues, noop, pick, size } from 'lodash'; +import { isEqual, mapValues, pick, size } from 'lodash'; import type { TimePeriod, Venue, VenueDetailList, VenueSearchOptions } from 'types/venues'; import type { Subtract } from 'types/utils'; @@ -14,6 +21,7 @@ import type { Subtract } from 'types/utils'; import deferComponentRender from 'views/hocs/deferComponentRender'; import ApiError from 'views/errors/ApiError'; import Warning from 'views/errors/Warning'; +import LoadingOverlay from 'views/components/LoadingOverlay'; import LoadingSpinner from 'views/components/LoadingSpinner'; import SearchBox from 'views/components/SearchBox'; import { Clock } from 'react-feather'; @@ -46,18 +54,13 @@ export const VenuesContainerComponent: FC = ({ venues }) => { const location = useLocation(); const matchParams = useParams(); - // Search state - const [ - /** Value of the controlled search box; updated real-time */ - searchQuery, - setSearchQuery, - ] = useState(() => qs.parse(location.search).q || ''); - /** Actual string to search with; deferred update */ - const deferredSearchQuery = searchQuery; // TODO: Redundant now. Use React.useDeferredValue after we adopt concurrent mode + const [searchQuery, setSearchQuery] = useState(() => qs.parse(location.search).q || ''); + const [isAvailabilityEnabled, setIsAvailabilityEnabled] = useState(() => { const params = qs.parse(location.search); return !!(params.time && params.day && params.duration); }); + const [searchOptions, setSearchOptions] = useState(() => { const params = qs.parse(location.search); // Extract searchOptions from the query string if they are present @@ -69,6 +72,14 @@ export const VenuesContainerComponent: FC = ({ venues }) => { }); const [pristineSearchOptions, setPristineSearchOptions] = useState(() => !isAvailabilityEnabled); + const deferredSearchQuery = useDeferredValue(searchQuery); + const deferredIsAvailabilityEnabled = useDeferredValue(isAvailabilityEnabled); + const deferredSearchOptions = useDeferredValue(searchOptions); + const isPending = + searchQuery !== deferredSearchQuery || + isAvailabilityEnabled !== deferredIsAvailabilityEnabled || + searchOptions !== deferredSearchOptions; + // TODO: Check if this actually does anything useful useEffect(() => { VenueLocation.preload(); @@ -78,9 +89,9 @@ export const VenuesContainerComponent: FC = ({ venues }) => { setIsAvailabilityEnabled(!isAvailabilityEnabled); if (pristineSearchOptions && !isAvailabilityEnabled) { // Only reset search options if the user has never changed it, and if the - // search box is being opened. By resetting the option when the box is opened, - // the time when the box is opened will be used, instead of the time when the - // page is loaded + // search box is being opened. By resetting the option when the box is + // opened, the time when the box is opened will be used, instead of the + // time when the page is loaded. setSearchOptions(defaultSearchOptions()); } }, [isAvailabilityEnabled, pristineSearchOptions]); @@ -96,14 +107,21 @@ export const VenuesContainerComponent: FC = ({ venues }) => { ); const highlightPeriod = useMemo(() => { - if (!isAvailabilityEnabled) return undefined; + if (!deferredIsAvailabilityEnabled) return undefined; return { - day: searchOptions.day, - startTime: convertIndexToTime(searchOptions.time * 2), - endTime: convertIndexToTime(2 * (searchOptions.time + searchOptions.duration)), + day: deferredSearchOptions.day, + startTime: convertIndexToTime(deferredSearchOptions.time * 2), + endTime: convertIndexToTime( + 2 * (deferredSearchOptions.time + deferredSearchOptions.duration), + ), }; - }, [isAvailabilityEnabled, searchOptions.day, searchOptions.duration, searchOptions.time]); + }, [ + deferredIsAvailabilityEnabled, + deferredSearchOptions.day, + deferredSearchOptions.duration, + deferredSearchOptions.time, + ]); const selectedVenue = useMemo( () => (matchParams.venue ? decodeURIComponent(matchParams.venue) : undefined), @@ -114,7 +132,7 @@ export const VenuesContainerComponent: FC = ({ venues }) => { useEffect(() => { let query: Partial = {}; if (deferredSearchQuery) query.q = deferredSearchQuery; - if (isAvailabilityEnabled) query = { ...query, ...searchOptions }; + if (deferredIsAvailabilityEnabled) query = { ...query, ...deferredSearchOptions }; const search = qs.stringify(query); const pathname = venuePage(selectedVenue); @@ -132,17 +150,19 @@ export const VenuesContainerComponent: FC = ({ venues }) => { } }, [ debouncedHistory, + deferredIsAvailabilityEnabled, + deferredSearchOptions, deferredSearchQuery, history, - isAvailabilityEnabled, - searchOptions, selectedVenue, ]); const matchedVenues = useMemo(() => { const matched = searchVenue(venues, deferredSearchQuery); - return isAvailabilityEnabled ? filterAvailability(matched, searchOptions) : matched; - }, [isAvailabilityEnabled, searchOptions, deferredSearchQuery, venues]); + return deferredIsAvailabilityEnabled + ? filterAvailability(matched, deferredSearchOptions) + : matched; + }, [deferredIsAvailabilityEnabled, deferredSearchOptions, deferredSearchQuery, venues]); const matchedVenueNames = useMemo(() => matchedVenues.map(([venue]) => venue), [matchedVenues]); function renderSearch() { @@ -152,18 +172,14 @@ export const VenuesContainerComponent: FC = ({ venues }) => {