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

97.46% Statements 77/79
82.14% Branches 23/28
100% Functions 20/20
98.68% Lines 75/76

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 260 261 262 263 264 265 266 267 268 269                                                                                    608x   608x   608x         3040x         608x 1808x 1808x 1808x 1808x 1808x   1808x 1808x 1808x 1808x 2864x       1808x 1808x 1808x   1808x 1808x       1808x   1808x         1808x 578x   3x     578x 7038x         1808x 629x 7101x   629x 629x   7092x 7092x 57x     629x     1808x   562x             1808x 520x         520x   510x 510x 510x     10x   26x     26x                         10x 10x     1808x 417x 408x   9x 21x   9x 9x                 9x 9x 9x       1808x 1129x               1129x   30x                               1808x               1808x   1808x               1808x     475x 475x 475x         1808x 408x 396x   12x 12x                                                      
import {
  ReactElement,
  useDeferredValue,
  useEffect,
  useMemo,
  useState,
} from "react";
import { useSearchParams } from "react-router";
import { useDebounce } from "use-debounce";
import {
  AgeGroup,
  Language,
  ProgramItem,
  Tag,
} from "shared/types/models/programItem";
import { getProgramItemValidity } from "shared/utils/getProgramItemValidity";
import { usePreviousLocation } from "client/app/historyContext";
import { AppRoute } from "client/app/routes";
import { Loading } from "client/components/Loading";
import { ScrollToTopButton } from "client/components/ScrollToTopButton";
import { getProgramTypeSelectOptions } from "client/utils/getProgramTypeSelectOptions";
import { useAppDispatch, useAppSelector } from "client/utils/hooks";
import {
  SessionStorageValue,
  getSavedHideFull,
  getSavedSearchTerm,
  getSavedStartingTime,
  getSavedTags,
} from "client/utils/sessionStorage";
import { useTimeNow } from "client/utils/useTimeNow";
import {
  selectActiveProgramItems,
  selectHiddenProgramItems,
  setActiveProgramTypes,
} from "client/views/admin/adminSlice";
import { AllProgramItemsList } from "client/views/all-program-items/components/AllProgramItemsList";
import { SearchAndFilterCard } from "client/views/all-program-items/components/SearchAndFilterCard";
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 timeNow = useTimeNow();
  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 the memoized list row 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,
      timeNow,
    );
    return showOnlyInvalidProgramItems
      ? visibleProgramItems.filter(
          (programItem) => !getProgramItemValidity(programItem).allValuesValid,
        )
      : visibleProgramItems;
  }, [
    filteredProgramItems,
    hideFullItems,
    selectedStartingTime,
    selectedTags,
    showOnlyInvalidProgramItems,
    fullProgramItemIds,
    timeNow,
  ]);
 
  // 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.
  // Only when there is something to catch up to: the visible set is recomputed
  // on every clock tick, and an empty one replaced by another empty one would
  // otherwise flash the spinner over a legitimate no-results message.
  const isListPending = programItemsToShow !== deferredProgramItems;
  const showLoading =
    loading ||
    (isListPending &&
      deferredProgramItems.length === 0 &&
      programItemsToShow.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 />
    </>
  );
};