ahbeng/NUSMods

View on GitHub
website/src/views/venues/VenuesContainer.tsx

Summary

Maintainability
A
0 mins
Test Coverage
import { Component } from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import Loadable, { LoadingComponentProps } from 'react-loadable';
import classnames from 'classnames';
import axios, { AxiosResponse } from 'axios';
import produce from 'immer';
import qs from 'query-string';
import { isEqual, mapValues, pick, size } from 'lodash';

import { Clock, Map } from 'react-feather';
import type {
  TimePeriod,
  Venue,
  VenueDetailList,
  VenueLocationMap,
  VenueSearchOptions,
} from 'types/venues';
import type { Subtract } from 'types/utils';
import type { WithBreakpoint } from 'views/hocs/makeResponsive';

import deferComponentRender from 'views/hocs/deferComponentRender';
import ApiError from 'views/errors/ApiError';
import Warning from 'views/errors/Warning';
import LoadingSpinner from 'views/components/LoadingSpinner';
import SearchBox from 'views/components/SearchBox';
import { venuePage } from 'views/routes/paths';
import Modal from 'views/components/Modal';
import Title from 'views/components/Title';
import NoFooter from 'views/layout/NoFooter';
import MapContext from 'views/components/map/MapContext';
import makeResponsive from 'views/hocs/makeResponsive';

import config from 'config';
import nusmods from 'apis/nusmods';
import HistoryDebouncer from 'utils/HistoryDebouncer';
import { clampClassDuration, filterAvailability, searchVenue, sortVenues } from 'utils/venues';
import { breakpointDown } from 'utils/css';
import { defer } from 'utils/react';
import { convertIndexToTime } from 'utils/timify';

import withVenueLocations from 'views/components/map/withVenueLocations';
import AvailabilitySearch, { defaultSearchOptions } from './AvailabilitySearch';
import VenueList from './VenueList';
import VenueDetails from './VenueDetails';
import VenueLocation from './VenueLocation';
import styles from './VenuesContainer.scss';

export type Params = {
  q: string;
  venue: string;
};

type LoadedProps = { venues: VenueDetailList; venueLocations: VenueLocationMap };
type Props = RouteComponentProps<Params> & LoadedProps & WithBreakpoint;

type State = {
  // View state
  isMapExpanded: boolean;

  // Search state
  searchBoxValue: string; // Value of the controlled search box; updated real-time
  searchTerm: string; // Actual string to search with; deferred update
  isAvailabilityEnabled: boolean;
  searchOptions: VenueSearchOptions;
  pristineSearchOptions: boolean;
};

const pageHead = <Title>Venues</Title>;

export class VenuesContainerComponent extends Component<Props, State> {
  history: HistoryDebouncer;

  constructor(props: Props) {
    super(props);

    const { location, history } = props;
    const params = qs.parse(location.search);

    // Extract searchOptions from the query string if they are present
    const isAvailabilityEnabled = !!(params.time && params.day && params.duration);
    const searchOptions = isAvailabilityEnabled
      ? (mapValues(pick(params, ['time', 'day', 'duration']), (i) => {
          if (!i) return NaN;
          if (Array.isArray(i)) return parseInt(i[0], 10);
          return parseInt(i, 10);
        }) as VenueSearchOptions)
      : defaultSearchOptions();

    this.history = new HistoryDebouncer(history);
    let searchTerm = params.q || '';
    if (Array.isArray(searchTerm)) [searchTerm] = searchTerm;
    this.state = {
      searchOptions,
      isAvailabilityEnabled,
      isMapExpanded: false,
      searchTerm,
      searchBoxValue: searchTerm,
      // eslint-disable-next-line react/no-unused-state
      pristineSearchOptions: !isAvailabilityEnabled,
    };
  }

  override componentDidMount() {
    VenueLocation.preload();
  }

  override componentDidUpdate(_prevProps: Props, prevState: State) {
    // Update URL if any of these props have changed
    const { searchOptions, searchTerm, isAvailabilityEnabled } = this.state;

    if (isAvailabilityEnabled !== prevState.isAvailabilityEnabled) {
      this.updateURL(false);
    } else if (searchOptions !== prevState.searchOptions || searchTerm !== prevState.searchTerm) {
      this.updateURL();
    }
  }

  onFindFreeRoomsClicked = () => {
    this.setState(
      produce((draft) => {
        const { pristineSearchOptions, isAvailabilityEnabled } = draft;
        draft.isAvailabilityEnabled = !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
        if (pristineSearchOptions && !isAvailabilityEnabled) {
          draft.searchOptions = defaultSearchOptions();
        }
      }),
    );
  };

  onClearVenueSelect = () =>
    this.props.history.push({
      ...this.props.history.location,
      pathname: venuePage(),
    });

  onSearchBoxChange = (searchBoxValue: string) => {
    this.setState({ searchBoxValue });
  };

  onSearch = () => {
    defer(() => this.setState((prevState) => ({ searchTerm: prevState.searchBoxValue.trim() })));
  };

  onAvailabilityUpdate = (searchOptions: VenueSearchOptions) => {
    if (!isEqual(searchOptions, this.state.searchOptions)) {
      this.setState({
        searchOptions: clampClassDuration(searchOptions),
        // eslint-disable-next-line react/no-unused-state
        pristineSearchOptions: false, // user changed searchOptions
      });
    }
  };

  onToggleMapExpanded = (isMapExpanded: boolean) => {
    this.setState({ isMapExpanded });
  };

  updateURL = (debounce = true) => {
    const { searchTerm, isAvailabilityEnabled, searchOptions } = this.state;
    let query: Partial<Params> = {};

    if (searchTerm) query.q = searchTerm;
    if (isAvailabilityEnabled) query = { ...query, ...searchOptions };

    const pathname = venuePage(this.selectedVenue());
    const history = debounce ? this.history : this.props.history;
    history.push({
      ...this.props.location,
      search: qs.stringify(query),
      pathname,
    });
  };

  getHighlightPeriod(): TimePeriod | undefined {
    const { isAvailabilityEnabled, searchOptions } = this.state;
    if (!isAvailabilityEnabled) return undefined;

    return {
      day: searchOptions.day,
      startTime: convertIndexToTime(searchOptions.time * 2),
      endTime: convertIndexToTime(2 * (searchOptions.time + searchOptions.duration)),
    };
  }

  selectedVenue(): Venue | null {
    const { venue } = this.props.match.params;
    if (!venue) return null;
    return decodeURIComponent(venue);
  }

  renderSearch() {
    const { searchBoxValue, isAvailabilityEnabled, searchOptions } = this.state;

    return (
      <div className={styles.venueSearch}>
        <h3>Venue Search</h3>

        <SearchBox
          className={styles.searchBox}
          throttle={0}
          useInstantSearch
          isLoading={false}
          value={searchBoxValue}
          placeholder="e.g. LT27"
          onChange={this.onSearchBoxChange}
          onSearch={this.onSearch}
        />

        <button
          className={classnames(
            'btn btn-block btn-svg',
            styles.availabilityToggle,
            isAvailabilityEnabled ? 'btn-primary' : 'btn-outline-primary',
          )}
          onClick={this.onFindFreeRoomsClicked}
          type="button"
        >
          <Clock className="svg" /> Find free rooms
        </button>

        {isAvailabilityEnabled && (
          <div className={styles.availabilitySearch}>
            <AvailabilitySearch
              isEnabled={isAvailabilityEnabled}
              searchOptions={searchOptions}
              onUpdate={this.onAvailabilityUpdate}
            />
          </div>
        )}
      </div>
    );
  }

  renderNoResult(unfilteredCount: number) {
    const { isAvailabilityEnabled } = this.state;

    return (
      <>
        <Warning message="No matching venues found" />
        {!!unfilteredCount && isAvailabilityEnabled && (
          <p className="text-center text-muted">
            {unfilteredCount === 1
              ? 'There is a venue that is not shown because it is not free'
              : `There are ${unfilteredCount} venues that are not shown because they are not free`}
            <br />
            <button
              type="button"
              className="btn btn-link"
              onClick={() => this.setState({ isAvailabilityEnabled: false })}
            >
              Show all rooms
            </button>
          </p>
        )}
      </>
    );
  }

  renderSelectedVenue(matchedVenues: VenueDetailList) {
    const selectedVenue = this.selectedVenue();
    const { venues } = this.props;

    if (!venues || !selectedVenue) return null;

    // Find the index of the current venue on the list of matched venues so
    // we can obtain the previous and next item
    const lowercaseSelectedVenue = selectedVenue.toLowerCase();
    const venueIndex = matchedVenues.findIndex(
      ([venue]) => venue.toLowerCase() === lowercaseSelectedVenue,
    );

    // The selected item may not be in the list of matched venues (if the user
    // changed their search options afterwards), in which case we look for it in all
    // venues
    if (venueIndex === -1) {
      const venueDetail = venues.find(([venue]) => venue.toLowerCase() === lowercaseSelectedVenue);
      if (!venueDetail) return null;
      const [venue, availability] = venueDetail;
      return (
        <VenueDetails
          venue={venue}
          availability={availability}
          highlightPeriod={this.getHighlightPeriod()}
        />
      );
    }

    const [venue, availability] = matchedVenues[venueIndex];
    const [previous] = matchedVenues[venueIndex - 1] || ([] as string[]);
    const [next] = matchedVenues[venueIndex + 1] || ([] as string[]);

    return (
      <VenueDetails
        venue={venue}
        availability={availability}
        next={next}
        previous={previous}
        highlightPeriod={this.getHighlightPeriod()}
      />
    );
  }

  override render() {
    const selectedVenue = this.selectedVenue();
    const { searchTerm, isAvailabilityEnabled, isMapExpanded, searchOptions } = this.state;
    const { venues, venueLocations } = this.props;

    let matchedVenues = searchVenue(venues, searchTerm, venueLocations);
    const unfilteredCount = size(matchedVenues);

    if (isAvailabilityEnabled) {
      matchedVenues = filterAvailability(matchedVenues, searchOptions);
    }

    return (
      <div className={classnames('page-container', styles.pageContainer)}>
        {pageHead}

        <div className={styles.venuesList}>
          {this.renderSearch()}

          {size(matchedVenues) === 0 ? (
            this.renderNoResult(unfilteredCount)
          ) : (
            <VenueList
              venues={matchedVenues.map(([venue]) => venue)}
              venueLocations={venueLocations}
              selectedVenue={selectedVenue}
              query={searchTerm}
            />
          )}
        </div>

        <MapContext.Provider value={{ toggleMapExpanded: this.onToggleMapExpanded }}>
          {this.props.matchBreakpoint ? (
            <Modal
              isOpen={selectedVenue != null}
              onRequestClose={this.onClearVenueSelect}
              className={styles.venueDetailModal}
              fullscreen
            >
              <button
                type="button"
                className={classnames('btn btn-outline-primary btn-block', styles.closeButton)}
                onClick={this.onClearVenueSelect}
              >
                Back to Venues
              </button>
              {this.renderSelectedVenue(matchedVenues)}
            </Modal>
          ) : (
            <>
              <div
                className={classnames(styles.venueDetail, {
                  [styles.mapExpanded]: isMapExpanded,
                })}
              >
                {selectedVenue == null ? (
                  <div className={styles.noVenueSelected}>
                    <Map />
                    <p>Select a venue on the left to see its timetable</p>
                  </div>
                ) : (
                  this.renderSelectedVenue(matchedVenues)
                )}
              </div>
              <NoFooter />
            </>
          )}
        </MapContext.Provider>
      </div>
    );
  }
}

// Explicitly declare top level components for React hot reloading to work.
const ResponsiveVenuesContainer = makeResponsive(VenuesContainerComponent, breakpointDown('sm'));
const RoutedVenuesContainer = withRouter(ResponsiveVenuesContainer);
const RoutedVenuesContainerWithLocations = withVenueLocations(() =>
  Promise.resolve(RoutedVenuesContainer),
);
const AsyncVenuesContainer = Loadable.Map<Subtract<Props, LoadedProps>, { venues: AxiosResponse }>({
  loader: {
    venues: () => axios.get(nusmods.venuesUrl(config.semester)),
  },
  loading: (props: LoadingComponentProps) => {
    if (props.error) {
      return <ApiError dataName="venue information" retry={props.retry} />;
    }

    if (props.pastDelay) {
      return <LoadingSpinner />;
    }

    return null;
  },
  render(loaded, props) {
    return (
      <RoutedVenuesContainerWithLocations venues={sortVenues(loaded.venues.data)} {...props} />
    );
  },
});

export default deferComponentRender(AsyncVenuesContainer);

export function preload() {
  AsyncVenuesContainer.preload();
  VenueLocation.preload();
}