react-app/src/components/map/sidebar/MapSidebar.tsx

Summary

Maintainability
A
0 mins
Test Coverage
import { PropertyGeo } from '@/hooks/api/usePropertiesApi';
import { formatNumber, pidFormatter } from '@/utilities/formatters';
import { FilterList, ArrowCircleLeft, ArrowCircleRight } from '@mui/icons-material';
import { Box, Paper, Grid, IconButton, Typography, Icon, useTheme } from '@mui/material';
import sideBarIcon from '@/assets/icons/SidebarLeft-Linear.svg';
import sideBarClosedIcon from '@/assets/icons/SidebarLeft-Linear-White.svg';
import { Map } from 'leaflet';
import React, {
  CSSProperties,
  Dispatch,
  SetStateAction,
  useContext,
  useLayoutEffect,
  useState,
} from 'react';
import PropertyRow from '@/components/map/propertyRow/PropertyRow';
import { PropertyTypes } from '@/constants/propertyTypes';
import FilterControl from '@/components/map/controls/FilterControl';
import { LookupContext } from '@/contexts/lookupContext';

interface MapSidebarProps {
  properties: PropertyGeo[];
  map: React.MutableRefObject<Map>;
  setFilter: Dispatch<SetStateAction<object>>;
  sidebarOpen: boolean;
  setSidebarOpen: Dispatch<React.SetStateAction<boolean>>;
}

/**
 * Used alongside the Map to display a list of visible properties.
 *
 * @param {MapSidebarProps} props - The props object used for MapSidebar component.
 * @returns {JSX.Element} The MapSidebar component.
 */
const MapSidebar = (props: MapSidebarProps) => {
  const { properties, map, setFilter, sidebarOpen, setSidebarOpen } = props;
  const [propertiesInBounds, setPropertiesInBounds] = useState<PropertyGeo[]>(properties ?? []);
  const [pageIndex, setPageIndex] = useState<number>(0);
  const [filterOpen, setFilterOpen] = useState<boolean>(false);
  const theme = useTheme();
  const propertyPageSize = 20; // Affects paging size
  const sidebarWidth = 350;

  // Get related data for lookups
  const { getLookupValueById } = useContext(LookupContext);

  // Sets the properties that are in the map's bounds at current view. Resets the page index.
  const definePropertiesInBounds = () => {
    if (properties && properties.length) {
      const newBounds = map.current.getBounds();
      setPropertiesInBounds(
        properties.filter((property) =>
          newBounds.contains([property.geometry.coordinates[1], property.geometry.coordinates[0]]),
        ),
      );
      setPageIndex(0);
    }
  };

  // Event listeners. Must be this style because we are outside of MapContainer.
  // hook and return used to keep event listeners from stacking
  useLayoutEffect(() => {
    if (map.current) {
      map.current.addEventListener('zoomend', definePropertiesInBounds);
      map.current.addEventListener('moveend', definePropertiesInBounds);
    }

    return () => {
      if (map.current) {
        map.current.removeEventListener('zoomend', definePropertiesInBounds);
        map.current.removeEventListener('moveend', definePropertiesInBounds);
      }
    };
  });

  return (
    <>
      <Box
        id="map-sidebar"
        zIndex={1000}
        position={'fixed'}
        right={sidebarOpen ? 0 : '-400px'}
        height={'calc(100vh - 75px)'}
        component={Paper}
        width={sidebarWidth}
        overflow={'hidden'}
        display={'flex'}
        flexDirection={'column'}
        sx={{
          transition: 'ease-in-out 0.5s',
        }}
      >
        {/* Sidebar Header */}
        <Grid container height={50} sx={{ backgroundColor: 'rgb(221,221,221)' }}>
          <Grid item xs={2} display={'flex'} justifyContent={'center'} alignItems={'center'}>
            <IconButton onClick={() => setFilterOpen(!filterOpen)}>
              <FilterList />
            </IconButton>
          </Grid>
          <Grid item xs={8} display={'flex'} justifyContent={'center'} alignItems={'center'}>
            <IconButton
              size="small"
              onClick={() => {
                if (pageIndex > 0) {
                  setPageIndex(pageIndex - 1);
                }
              }}
            >
              <ArrowCircleLeft fontSize="small" />
            </IconButton>
            <Typography
              margin={'0 0.5em'}
              fontSize={'0.8em'}
            >{`${pageIndex + 1} of ${formatNumber(Math.max(Math.ceil(propertiesInBounds.length / propertyPageSize), 1))} (${formatNumber(propertiesInBounds.length)} items)`}</Typography>
            <IconButton
              size="small"
              onClick={() => {
                if (pageIndex + 1 < Math.ceil(propertiesInBounds.length / propertyPageSize)) {
                  setPageIndex(pageIndex + 1);
                }
              }}
            >
              <ArrowCircleRight fontSize="small" />
            </IconButton>
          </Grid>
          <Grid item xs={2} display={'flex'} justifyContent={'center'} alignItems={'center'}>
            <IconButton onClick={() => setSidebarOpen(false)}>
              <Icon sx={{ mb: '2px' }}>
                <img height={18} width={18} src={sideBarIcon} />
              </Icon>
            </IconButton>
          </Grid>
        </Grid>

        {/* List of Properties */}
        <Box overflow={'scroll'} height={'100%'} display={'flex'} flexDirection={'column'}>
          {propertiesInBounds
            .slice(pageIndex * propertyPageSize, pageIndex * propertyPageSize + propertyPageSize)
            .map((property) => (
              <PropertyRow
                key={`${property.properties.PropertyTypeId === PropertyTypes.BUILDING ? 'Building' : 'Land'}-${property.properties.Id}`}
                id={property.properties.Id}
                propertyTypeId={property.properties.PropertyTypeId}
                classificationId={property.properties.ClassificationId}
                title={
                  // Buildings get name, unless it's all numbers or empty, then get address
                  // Parcels use PID or PIN
                  property.properties.PropertyTypeId === PropertyTypes.BUILDING
                    ? property.properties.Name.match(/^\d*$/) || property.properties.Name == ''
                      ? property.properties.Address1
                      : property.properties.Name
                    : pidFormatter(property.properties.PID) ?? String(property.properties.PIN)
                }
                content={[
                  property.properties.Address1,
                  getLookupValueById(
                    'AdministrativeAreas',
                    property.properties.AdministrativeAreaId,
                  )?.Name ?? 'No Administrative Area',
                  getLookupValueById('Agencies', property.properties.AgencyId)?.Name ?? 'No Agency',
                ]}
                projectStatusId={property.properties.ProjectStatusId}
              />
            ))}
        </Box>
      </Box>
      {/* Sidebar button that is shown when sidebar is closed */}
      <Box
        id="sidebar-button"
        style={
          {
            transition: 'all 1s',
            position: 'fixed',
            top: '80px',
            right: sidebarOpen ? '-70px' : 0,
            width: '50px',
            height: '50px',
            borderTopLeftRadius: '50px',
            borderBottomLeftRadius: '50px',
            backgroundColor: theme.palette.blue.main,
            zIndex: 1000,
            display: 'flex',
            cursor: 'pointer',
          } as unknown as CSSProperties
        }
        onClick={() => setSidebarOpen(true)}
      >
        {/* All this just to get the SVG white */}
        <img
          style={{
            margin: 'auto',
            transform: `rotate(${!sidebarOpen ? '3.142rad' : '0'})`,
            transition: 'ease-in-out 0.5s',
            width: '40%',
            height: '40%',
            borderRadius: '100%',
          }}
          height={18}
          width={18}
          src={sideBarClosedIcon}
        />
      </Box>

      {/* Filter Container */}
      <Box
        id="map-filter-container"
        zIndex={999}
        position={'fixed'}
        right={filterOpen && sidebarOpen ? sidebarWidth : '-400px'}
        height={'calc(100vh - 75px)'}
        component={Paper}
        width={sidebarWidth}
        overflow={'hidden'}
        display={'flex'}
        flexDirection={'column'}
        sx={{
          transition: 'ease-in-out 0.5s',
        }}
      >
        <FilterControl setFilter={setFilter} />
      </Box>
    </>
  );
};

export default MapSidebar;