tutorbookapp/tutorbook

View on GitHub
components/calendar/index.tsx

Summary

Maintainability
C
1 day
Test Coverage
import {
  FormEvent,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import axios, { AxiosError } from 'axios';
import useSWR, { mutate } from 'swr';
import { Snackbar } from '@rmwc/snackbar';
import { dequal } from 'dequal/lite';
import to from 'await-to-js';

import DialogContent from 'components/dialog';

import { Meeting, MeetingAction, MeetingJSON } from 'lib/model/meeting';
import useClickOutside, { ClickContext } from 'lib/hooks/click-outside';
import { APIErrorJSON } from 'lib/model/error';
import { ListMeetingsRes } from 'lib/api/routes/meetings/list';
import { MeetingsQuery } from 'lib/model/query/meetings';
import { Position } from 'lib/model/position';
import { useOrg } from 'lib/context/org';
import useSingle from 'lib/hooks/single';
import useURLParamSync from 'lib/hooks/url-param-sync';
import { useUser } from 'lib/context/user';

import { CalendarStateContext, DialogPage } from './state';
import CreatePage from './dialog/create-page';
import DialogSurface from './dialog/surface';
import DisplayPage from './dialog/display-page';
import EditPage from './dialog/edit-page';
import FiltersSheet from './filters-sheet';
import Header from './header';
import RecurDialog from './recur-dialog';
import SearchBar from './search-bar';
import WeeklyDisplay from './weekly-display';
import styles from './calendar.module.scss';

const initialEditData = new Meeting();

export interface CalendarProps {
  org?: boolean;
  user?: boolean;
}

export default function Calendar({
  org: byOrg,
  user: byUser,
}: CalendarProps): JSX.Element {
  const [filtersOpen, setFiltersOpen] = useState<boolean>(false);
  const [mutatedIds, setMutatedIds] = useState<Set<number>>(new Set());
  const [query, setQuery] = useState<MeetingsQuery>(new MeetingsQuery());

  useURLParamSync(query, setQuery, MeetingsQuery, byOrg ? ['o'] : ['pl']);

  const { org } = useOrg();
  const { user } = useUser();
  const { data } = useSWR<ListMeetingsRes>(
    (byOrg && query.org) || (byUser && query.people.length)
      ? query.endpoint
      : null,
    {
      revalidateOnFocus: !mutatedIds.size,
      revalidateOnReconnect: !mutatedIds.size,
    }
  );
  const meetings = useMemo(
    () => data?.meetings.map((m) => Meeting.fromJSON(m)) || [],
    [data?.meetings]
  );

  useEffect(() => {
    setQuery((prev) => {
      if (!byOrg || !org || org.id === prev.org) return prev;
      return new MeetingsQuery({ ...prev, org: org.id });
    });
  }, [byOrg, org]);
  useEffect(() => {
    setQuery((prev) => {
      if (!byUser || !user) return prev;
      if (dequal(prev.people, [user.id])) return prev;
      return new MeetingsQuery({ ...prev, people: [user.id] });
    });
  }, [byUser, user]);

  const [rnd, setRnd] = useState<boolean>(false);
  const [dialog, setDialog] = useState<boolean>(false);
  const [dragging, setDragging] = useState<boolean>(false);
  const [dialogPage, setDialogPage] = useState<DialogPage>(DialogPage.Display);
  const [recurDelete, setRecurDelete] = useState<boolean>(false);
  const [recurEdit, setRecurEdit] = useState<boolean>(false);
  const [action, setAction] = useState<MeetingAction>('future');

  // Cancel an unintentional RND drag by simply clicking the "Esc" key.
  // TODO: Perhaps figure out a way to include the dialog closing animation.
  useEffect(() => {
    function onKeyUp(evt: KeyboardEvent) {
      if (!['Esc', 'Escape'].includes(evt.key) && evt.keyCode !== 27) return;
      setDragging(false);
      setDialog(false);
      setRnd(false);
    }
    document.addEventListener('keyup', onKeyUp);
    return () => document.removeEventListener('keyup', onKeyUp);
  }, []);

  const mutateMeeting = useCallback(
    async (mutated: Meeting, hasBeenUpdated: boolean, sentToAPI: Meeting) => {
      // Don't locally update meetings that have yet to be created.
      if (mutated.id === 0) return;
      setMutatedIds((prev) => {
        const mutatedMeetingIds = new Set(prev);
        if (!hasBeenUpdated) mutatedMeetingIds.add(sentToAPI.id);
        if (hasBeenUpdated) mutatedMeetingIds.delete(sentToAPI.id);
        return mutatedMeetingIds;
      });
      // TODO: Remove meeting if it is no longer within the `query` dates (but
      // note we still want to show the loading indicator in the `Preview`).
      const idx = meetings.findIndex((m) => m.id === sentToAPI.id);
      const updated =
        idx < 0
          ? [...meetings, mutated]
          : [...meetings.slice(0, idx), mutated, ...meetings.slice(idx + 1)];
      if (dequal(updated, meetings)) return;
      // Note: If we ever need to use the `hits` property, we'll have to update
      // this callback function to properly cache and reuse the previous value.
      const json = updated.map((m) => m.toJSON());
      await mutate(query.endpoint, { meetings: json }, hasBeenUpdated);
      // Remove the RND once there is a meeting item to replace it.
      if (idx < 0) setRnd(false);
    },
    [query.endpoint, meetings]
  );

  const original = useRef<Meeting>(initialEditData);
  const updateMeetingRemote = useCallback(
    async (updated: Meeting) => {
      if (updated.id === 0) {
        const { data: createdMeeting } = await axios.post<MeetingJSON>(
          '/api/meetings',
          updated.toJSON()
        );
        return Meeting.fromJSON(createdMeeting);
      }
      const url = `/api/meetings/${updated.id}`;
      const { data: updatedMeeting } = await axios.put<MeetingJSON>(url, {
        ...updated.toJSON(),
        options: { action, original: original.current.toJSON() },
      });
      return Meeting.fromJSON(updatedMeeting);
    },
    [action]
  );

  // TODO: Having a single editing state is good for simplicity and most uses.
  // However, if a user were to drag an RND and then view another meeting while
  // that RND is still updating, we would run into issues...
  const {
    data: editing,
    setData: setEditing,
    onSubmit: onEditSubmit,
    loading: editLoading,
    setLoading: setEditLoading,
    checked: editChecked,
    setChecked: setEditChecked,
    error: editError,
    setError: setEditError,
  } = useSingle<Meeting>(initialEditData, updateMeetingRemote, mutateMeeting);

  // Reset loading/checked/error state when dialog closes so we don't show
  // snackbars for messages already shown in the dialog.
  useEffect(() => {
    if (dialog) return;
    setEditLoading(false);
    setEditChecked(false);
    setEditError('');
  }, [dialog, setEditLoading, setEditChecked, setEditError]);

  // Save the meeting state before an edit so that our back-end can modify recur
  // rules properly (adding the correct `UNTIL` exceptions).
  useEffect(() => {
    if (editing.id !== original.current.id) original.current = editing;
  }, [editing]);

  // Sync the editing state with our SWR meetings state. If a meeting is updated
  // elsewhere, we want the editing state to reflect those updates.
  useEffect(() => {
    setEditing((prev) => {
      if (prev?.id === 0) return prev;
      const idx = meetings.findIndex((m) => m.id === prev?.id);
      if (idx < 0) {
        setDialog(false); // TODO: Animate the dialog closed before removing.
        return prev;
      }
      if (dequal(meetings[idx], prev)) return prev;
      return meetings[idx];
    });
  }, [setEditing, meetings]);

  // TODO: Update offset when the `MDCDialog` adds body scroll lock (which makes
  // the body scrollbar disappear, moving the `fixed` position values).
  const [offset, setOffset] = useState<Position>({ x: 0, y: 0 });
  const [width, setWidth] = useState<number>(0);
  const [editingLeftPercent, setEditingLeftPercent] = useState<number>(0);
  const [editingWidthPercent, setEditingWidthPercent] = useState<number>(0);

  const onEditStop = useCallback(
    (evt?: FormEvent) => {
      if (evt) evt.preventDefault();
      if (editing.parentId) return setRecurEdit(true);
      return onEditSubmit();
    },
    [editing.parentId, onEditSubmit]
  );

  // TODO: Clicking outside the dialog doesn't animate it closed. Instead, it
  // completely removes the dialog from the React tree (and thus also the DOM).
  // This prevents expensive updates when animating the filter sheet open, but
  // it also gets rid of the nice closing animation...
  const clickContextValue = useClickOutside(() => setDialog(false), dialog);
  const calendarState = useMemo(
    () => ({
      editing,
      setEditing,
      onEditStop,
      editingLeftPercent,
      setEditingLeftPercent,
      editingWidthPercent,
      setEditingWidthPercent,
      rnd,
      setRnd,
      dialog,
      setDialog,
      setDialogPage,
      dragging,
      setDragging,
      start: query.from,
    }),
    [
      editing,
      setEditing,
      onEditStop,
      editingLeftPercent,
      setEditingLeftPercent,
      editingWidthPercent,
      setEditingWidthPercent,
      rnd,
      setRnd,
      dialog,
      setDialog,
      dragging,
      setDragging,
      query.from,
    ]
  );

  const [deleteError, setDeleteError] = useState<string>('');
  const onDeleteSubmit = useCallback(async () => {
    setDeleteError('');
    setEditChecked(false);
    setEditLoading(true);
    const url = `/api/meetings/${editing.parentId || editing.id}`;
    const options = { action, deleting: original.current.toJSON() };
    const [err] = await to(axios.delete(url, { data: { options } }));
    if (err) {
      const e = (err as AxiosError<APIErrorJSON>).response?.data || err;
      setEditLoading(false);
      setDeleteError(e.message);
    } else {
      setEditChecked(true);
      setTimeout(() => {
        const idx = meetings.findIndex((m) => m.id === editing.id);
        if (idx < 0) return;
        const updated = [...meetings.slice(0, idx), ...meetings.slice(idx + 1)];
        const json = updated.map((m) => m.toJSON());
        void mutate(query.endpoint, { meetings: json }, true);
      }, 1000);
    }
  }, [
    setEditLoading,
    setEditChecked,
    query.endpoint,
    meetings,
    editing.parentId,
    editing.id,
    action,
  ]);
  const onDeleteStop = useCallback(
    (evt?: FormEvent) => {
      if (evt) evt.preventDefault();
      if (editing.parentId) return setRecurDelete(true);
      return onDeleteSubmit();
    },
    [editing.parentId, onDeleteSubmit]
  );

  return (
    <CalendarStateContext.Provider value={calendarState}>
      <ClickContext.Provider value={clickContextValue}>
        {!dialog && editLoading && !editChecked && !editError && (
          <Snackbar message='Updating meeting...' timeout={-1} leading open />
        )}
        {!dialog && editChecked && (
          <Snackbar message='Updated meeting.' leading open />
        )}
        {!dialog && editError && (
          <Snackbar
            message='Could not update meeting. Try again later.'
            leading
            open
          />
        )}
        {deleteError && (
          <Snackbar
            message='Could not delete meeting. Try again later'
            leading
            open
          />
        )}
        {(recurEdit || recurDelete) && (
          <RecurDialog
            title={`${recurEdit ? 'Edit' : 'Delete'} recurring meeting`}
            action={action}
            setAction={setAction}
            onClose={(evt) => {
              if (evt.detail.action === 'ok') {
                if (recurEdit) void onEditSubmit();
                if (recurDelete) void onDeleteSubmit();
              } else {
                setEditing(original.current);
              }
            }}
            onClosed={() => {
              setRecurEdit(false);
              setRecurDelete(false);
            }}
          />
        )}
        {dialog && (
          <DialogSurface width={width} offset={offset}>
            <DialogContent page={dialogPage} forceMinHeight={!editing.id}>
              <DisplayPage
                people={editing.people}
                loading={editLoading}
                checked={editChecked}
                onDeleteStop={onDeleteStop}
              />
              <EditPage
                people={editing.people}
                loading={editLoading}
                checked={editChecked}
                error={editError}
              />
              <CreatePage
                people={editing.people}
                loading={editLoading}
                checked={editChecked}
                error={editError}
              />
            </DialogContent>
          </DialogSurface>
        )}
        <Header query={query} setQuery={setQuery} />
        <div className={styles.wrapper}>
          <SearchBar
            query={query}
            setQuery={setQuery}
            setFiltersOpen={setFiltersOpen}
            byOrg={byOrg}
          />
          <div className={styles.content}>
            <WeeklyDisplay
              searching={!data}
              meetings={meetings}
              filtersOpen={filtersOpen}
              width={width}
              setWidth={setWidth}
              offset={offset}
              setOffset={setOffset}
            />
            <FiltersSheet
              query={query}
              setQuery={setQuery}
              filtersOpen={filtersOpen}
            />
          </div>
        </div>
      </ClickContext.Provider>
    </CalendarStateContext.Provider>
  );
}