sparkletown/sparkle

View on GitHub
src/pages/Admin/VenueOwnersModal.tsx

Summary

Maintainability
B
5 hrs
Test Coverage
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FormControl, Modal } from "react-bootstrap";
import { useFirestore } from "react-redux-firebase";
import { debounce } from "lodash";

import { DEFAULT_PARTY_NAME } from "settings";

import { addVenueOwner, removeVenueOwner } from "api/admin";

import { User } from "types/User";
import { AnyVenue, Venue_v2 } from "types/venues";

import { WithId } from "utils/id";
import { determineAvatar } from "utils/image";

import "./VenueOwnerModal.scss";

interface PartitionedOwnersOthers {
  owners: WithId<User>[];
  others: WithId<User>[];
}
const makePartitionOwnersFromOthersReducer = (ownerIds: string[]) => (
  { owners, others }: PartitionedOwnersOthers,
  user: WithId<User>
) => {
  if (ownerIds.includes(user.id)) {
    return { owners: [...owners, user], others };
  } else {
    return { owners, others: [...others, user] };
  }
};

const emptyPartition: PartitionedOwnersOthers = {
  owners: [],
  others: [],
};

const makePartyNameFilter = (searchText: string) => (user: WithId<User>) =>
  user.partyName?.toLowerCase()?.includes(searchText.toLowerCase());

interface VenueOwnersModalProps {
  visible: boolean;
  venue: WithId<AnyVenue> | Venue_v2;
  onHide?: () => void;
}

export const VenueOwnersModal: React.FC<VenueOwnersModalProps> = ({
  visible,
  onHide,
  venue,
}) => {
  // Fetch all users the first time this component loads
  // @debt reading every user is obviously bad.
  const firestore = useFirestore();
  useEffect(() => {
    firestore
      .collection("users")
      .get()
      .then((result) =>
        result.docs.map<WithId<User>>(
          (doc) => ({ ...doc.data(), id: doc.id } as WithId<User>) // TODO: be less hacky with types here?
        )
      )
      .then(setAllUsers);
  }, [firestore]);

  const [searchText, setSearchText] = useState("");

  const debouncedSearch: typeof setSearchText = useMemo(
    () => debounce((v) => setSearchText(v), 100),
    []
  );

  const [allUsers, setAllUsers] = useState<WithId<User>[]>([]);

  const isLoading = allUsers.length === 0;

  // Make partition reducer using venue.owners
  const partitionOwnersFromOthersReducer = useMemo(
    () => makePartitionOwnersFromOthersReducer(venue.owners ?? []),
    [venue.owners]
  );

  // Partition owners from others
  const { owners: venueOwnerUsers, others: otherUsers } = useMemo(
    () => allUsers.reduce(partitionOwnersFromOthersReducer, emptyPartition),
    [allUsers, partitionOwnersFromOthersReducer]
  );

  // Filter others by partyName using searchText
  const filteredUsers = useMemo(
    () =>
      searchText !== ""
        ? otherUsers.filter(makePartyNameFilter(searchText))
        : undefined,
    [otherUsers, searchText]
  );

  const isEnterSearchText = filteredUsers === undefined;
  const hasResults = filteredUsers && filteredUsers.length > 0;

  if (isLoading) return <>Loading...</>;

  return (
    <Modal show={visible} onHide={onHide}>
      <Modal.Body>
        <div className="modal-container venue-owner-modal">
          <h3>Manage Owners</h3>
          <div className="row-container">
            <h4>Current Venue Owners</h4>
            {venueOwnerUsers.map((owner) => (
              <UserRow key={owner.id} user={owner} venueId={venue.id} isOwner />
            ))}
          </div>
          <FormControl
            className="text-input"
            autoFocus
            placeholder="Search users..."
            onChange={(e) => debouncedSearch(e.target.value)}
          />
          <div className="row-container">
            {hasResults &&
              (filteredUsers ?? []).map((user) => (
                <UserRow key={user.id} user={user} venueId={venue.id} />
              ))}
            {isEnterSearchText && (
              <div>Enter the users name in the text input above</div>
            )}
            {!isEnterSearchText && !hasResults && (
              <div>No results for your search</div>
            )}
          </div>
        </div>
      </Modal.Body>
    </Modal>
  );
};

interface UserRowProps {
  user: WithId<User>;
  venueId: string;
  isOwner?: boolean;
}

const UserRow: React.FC<UserRowProps> = (props) => {
  const { user, isOwner, venueId } = props;

  const [isLoading, setLoading] = useState(false);
  const [error, setError] = useState<string>();

  const removeOwner = useCallback(async () => {
    setError(undefined);
    setLoading(true);
    await removeVenueOwner(venueId, user.id);
    setLoading(false);
    setError("Something went wrong. Try again.");
  }, [venueId, user.id]);

  const makeOwner = useCallback(async () => {
    setError(undefined);
    setLoading(true);
    await addVenueOwner(venueId, user.id);
    setLoading(false);
    setError("Something went wrong. Try again.");
  }, [venueId, user.id]);

  const { src: userPicture, onError: onLoadError } = determineAvatar({ user });
  const userName = user.partyName || DEFAULT_PARTY_NAME;

  return (
    <>
      <div className="user-row">
        <div className="info-container">
          <img src={userPicture} alt="profile pic" onError={onLoadError} />
          {userName}
        </div>
        {isLoading && <div>Loading...</div>}
        {!isLoading && (
          <button
            className="btn btn-primary"
            onClick={isOwner ? removeOwner : makeOwner}
          >
            {isOwner ? "Remove Owner" : "Make Owner"}
          </button>
        )}
      </div>
      {error && <div>{error}</div>}
    </>
  );
};