All files / client/src/views/all-program-items AllProgramItemsView.tsx

98.71% Statements 77/78
81.48% Branches 22/27
100% Functions 20/20
98.66% Lines 74/75

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259                                                                                  356x   356x   356x         2492x         356x 1185x 1185x 1185x 1185x 1185x   1185x 1185x 1185x 1846x       1185x 1185x 1185x   1185x 1185x       1185x   1185x         1185x 375x   3x     375x 2177x         1185x 415x 2220x   415x 415x   2217x 2217x 20x     415x     1185x   367x             1185x 333x         333x   327x 327x 327x     6x   12x     12x                         6x 6x     1185x 251x 242x   9x 21x   9x 9x                 9x 9x 9x       1185x 755x             755x   12x                             1185x         1185x   1185x         1185x     290x 290x 290x         1185x 243x 237x   6x 6x                                                      
import {
  ReactElement,
  useDeferredValue,
  useEffect,
  useMemo,
  useState,
} from "react";
import { useDebounce } from "use-debounce";
import { useSearchParams } from "react-router";
import { AllProgramItemsList } from "client/views/all-program-items/components/AllProgramItemsList";
import { usePreviousLocation } from "client/app/HistoryContext";
import { AppRoute } from "client/app/AppRoutes";
import { Loading } from "client/components/Loading";
import {
  ProgramItem,
  Language,
  Tag,
  AgeGroup,
} from "shared/types/models/programItem";
import { useAppDispatch, useAppSelector } from "client/utils/hooks";
import {
  selectActiveProgramItems,
  selectHiddenProgramItems,
  setActiveProgramTypes,
} from "client/views/admin/adminSlice";
import {
  SessionStorageValue,
  getSavedHideFull,
  getSavedSearchTerm,
  getSavedStartingTime,
  getSavedTags,
} from "client/utils/sessionStorage";
import { SearchAndFilterCard } from "client/views/all-program-items/components/SearchAndFilterCard";
import { getProgramTypeSelectOptions } from "client/utils/getProgramTypeSelectOptions";
import { ScrollToTopButton } from "client/components/ScrollToTopButton";
import { getProgramItemValidity } from "client/views/program-item/programItemUtils";
import {
  StartingTimeOption,
  getVisibleProgramItems,
} from "client/views/all-program-items/programListUtils";
 
export const MULTIPLE_WHITESPACES_REGEX = /\s\s+/g;
// Query param that selects a program type
const programTypeQueryParam = "programType";
// Query param that lists program items missing required info, like attendance limits
const invalidQueryParam = "invalid";
 
// Config-derived and constant, and must be referentially stable: the query
// param effect below depends on it, and dispatching setActiveProgramTypes
// re-renders this view, so an unstable value would loop the effect
const programTypePairs = getProgramTypeSelectOptions().map((type) => ({
  lowerCase: type.toLocaleLowerCase(),
  originalValue: type,
}));
 
export const AllProgramItemsView = (): ReactElement => {
  const [searchParams, setSearchParams] = useSearchParams();
  const programTypeQueryParamValue = searchParams.get(programTypeQueryParam);
  const showOnlyInvalidProgramItems = searchParams.has(invalidQueryParam);
  const dispatch = useAppDispatch();
  const previousLocation = usePreviousLocation();
 
  const activeProgramItems = useAppSelector(selectActiveProgramItems);
  const hiddenProgramItems = useAppSelector(selectHiddenProgramItems);
  const signups = useAppSelector(
    (state) => state.allProgramItems.directSignups,
  );
 
  const [selectedTags, setSelectedTags] =
    useState<(Tag | Language | AgeGroup)[]>(getSavedTags());
  const [loading, setLoading] = useState<boolean>(true);
  const [searchTerm, setSearchTerm] = useState<string>(getSavedSearchTerm());
  const [hideFullItems, setHideFullItems] =
    useState<boolean>(getSavedHideFull());
  const [filteredProgramItems, setFilteredProgramItems] = useState<
    readonly ProgramItem[]
  >([]);
  const [selectedStartingTime, setSelectedStartingTime] =
    useState<StartingTimeOption>(getSavedStartingTime());
 
  const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
 
  // Keep the original program item references stable (no per-item object
  // spread) so React.memo on ProgramItemEntry can bail out when the same item
  // persists across a program type change
  const activeVisibleProgramItems: readonly ProgramItem[] = useMemo(() => {
    const hiddenIds = new Set(
      hiddenProgramItems.map(
        (hiddenProgramItem) => hiddenProgramItem.programItemId,
      ),
    );
    return activeProgramItems.filter(
      (programItem) => !hiddenIds.has(programItem.programItemId),
    );
  }, [activeProgramItems, hiddenProgramItems]);
 
  // Track fullness separately from the program item objects (an O(1) id lookup)
  const fullProgramItemIds: ReadonlySet<string> = useMemo(() => {
    const signupCountByProgramItemId = new Map(
      signups.map((signup) => [signup.programItemId, signup.users.length]),
    );
    const fullIds = new Set<string>();
    for (const programItem of activeProgramItems) {
      const signupCount =
        signupCountByProgramItemId.get(programItem.programItemId) ?? -1;
      if (signupCount >= programItem.maxAttendance) {
        fullIds.add(programItem.programItemId);
      }
    }
    return fullIds;
  }, [activeProgramItems, signups]);
 
  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect
    setLoading(false);
  }, [
    /* effect dep */ activeProgramItems,
    /* effect dep */ hiddenProgramItems,
    /* effect dep */ signups,
  ]);
 
  useEffect(() => {
    sessionStorage.setItem(
      SessionStorageValue.ALL_PROGRAM_ITEMS_SEARCH_TERM,
      debouncedSearchTerm,
    );
 
    if (debouncedSearchTerm.length === 0) {
      // eslint-disable-next-line react-hooks/set-state-in-effect
      setFilteredProgramItems(activeVisibleProgramItems);
      setLoading(false);
      return;
    }
 
    const programItemsFilteredBySearchTerm = activeVisibleProgramItems.filter(
      (activeProgramItem) => {
        const cleanedSearchTerm = debouncedSearchTerm
          .toLocaleLowerCase()
          .trim();
        return (
          activeProgramItem.title
            .replaceAll(MULTIPLE_WHITESPACES_REGEX, " ")
            .toLocaleLowerCase()
            .includes(cleanedSearchTerm) ||
          activeProgramItem.gameSystem
            .replaceAll(MULTIPLE_WHITESPACES_REGEX, " ")
            .toLocaleLowerCase()
            .includes(cleanedSearchTerm)
        );
      },
    );
 
    setFilteredProgramItems(programItemsFilteredBySearchTerm);
    setLoading(false);
  }, [debouncedSearchTerm, activeVisibleProgramItems]);
 
  useEffect(() => {
    if (!programTypeQueryParamValue) {
      return;
    }
    const programTypePair = programTypePairs.find(
      (key) => key.lowerCase === programTypeQueryParamValue,
    );
    Eif (programTypePair) {
      dispatch(
        setActiveProgramTypes(
          programTypePair.originalValue === "all"
            ? []
            : [programTypePair.originalValue],
        ),
      );
    }
    // Drop the handled programType param but keep other params, like invalid
    setSearchParams((prev) => {
      prev.delete(programTypeQueryParam);
      return prev;
    });
  }, [dispatch, programTypeQueryParamValue, setSearchParams]);
 
  const programItemsToShow = useMemo(() => {
    const visibleProgramItems = getVisibleProgramItems(
      filteredProgramItems,
      selectedStartingTime,
      selectedTags,
      hideFullItems,
      fullProgramItemIds,
    );
    return showOnlyInvalidProgramItems
      ? visibleProgramItems.filter(
          (programItem) => !getProgramItemValidity(programItem).allValuesValid,
        )
      : visibleProgramItems;
  }, [
    filteredProgramItems,
    hideFullItems,
    selectedStartingTime,
    selectedTags,
    showOnlyInvalidProgramItems,
    fullProgramItemIds,
  ]);
 
  // Render the (expensive) list at lower priority so changing a filter keeps
  // the controls responsive instead of freezing the main thread while hundreds
  // of program items mount
  const deferredProgramItems = useDeferredValue(programItemsToShow);
 
  // While the deferred list is still catching up from an empty state (initial
  // load, or returning from a no-results filter) keep showing Loading, so the
  // stale empty value doesn't render a premature "no program items" message
  const isListPending = programItemsToShow !== deferredProgramItems;
  const showLoading =
    loading || (isListPending && deferredProgramItems.length === 0);
 
  // If the user just came back from a program item page, briefly highlight that
  // item in the list. Captured once on mount, before the previous location is
  // overwritten, then cleared after the highlight has had time to play
  const [highlightedProgramItemId, setHighlightedProgramItemId] = useState<
    string | null
  >(() => {
    const previousPathname = previousLocation?.pathname ?? "";
    const programItemPrefix = `${AppRoute.PROGRAM_ITEM}/`;
    return previousPathname.startsWith(programItemPrefix)
      ? previousPathname.slice(programItemPrefix.length)
      : null;
  });
 
  useEffect(() => {
    if (highlightedProgramItemId === null) {
      return;
    }
    const timer = setTimeout(() => setHighlightedProgramItemId(null), 1000);
    return () => clearTimeout(timer);
  }, [highlightedProgramItemId]);
 
  return (
    <>
      <SearchAndFilterCard
        selectedTags={selectedTags}
        setSelectedTags={setSelectedTags}
        selectedStartingTime={selectedStartingTime}
        setSelectedStartingTime={setSelectedStartingTime}
        searchTerm={searchTerm}
        setSearchTerm={setSearchTerm}
        hideFullItems={hideFullItems}
        setHideFullItems={setHideFullItems}
      />
      {showLoading ? (
        <Loading />
      ) : (
        <AllProgramItemsList
          programItems={deferredProgramItems}
          highlightedProgramItemId={highlightedProgramItemId}
        />
      )}
      <ScrollToTopButton />
    </>
  );
};