All files / src/features/Events/EventDetail/host useAssignableHosts.ts

94.44% Statements 34/36
72.91% Branches 35/48
100% Functions 10/10
96.77% Lines 30/31

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                            8x   8x 1049x             2106x                                           8x         98x 98x 98x 98x   98x             98x               98x       98x   98x 22x       98x 1049x               34x         1049x     1062x 1053x                         98x   2x 2x 2x 2x         2x 2x                                   2x           98x                                                        
import { useCallback, useMemo, useState } from "react";
import { useQueryClient } from "react-query";
import { showMessage } from "@common/Toast";
import { useGetCurrentMerchantId } from "@hooks/common";
import {
  AccountMemberApiRow,
  invalidateHostRosters,
  useGetAssignableHosts,
  useGetEventHosts,
  useUpdateHostAssignment,
} from "@services/api/products/hosts";
import { AssignableHostRow } from "./host.types";
 
/** Frames 8181-61190 / 8122-60109 — one copy for both directions. */
export const HOST_ASSIGNMENT_TOAST = "Host access updated successfully";
 
const fullName = (first: string, last: string) =>
  [first, last].filter(Boolean).join(" ").trim();
 
/**
 * The label the row leads with, and therefore the one the alphabetical sort has
 * to run on: a host with no name on file shows their email alone, so sorting on
 * the (empty) name would park them all at the top in arrival order.
 */
const sortLabel = (row: AssignableHostRow) => row.name || row.email;
 
/**
 * The Assign Host modal's data (frames 8181-61190, 8136-65947).
 *
 * Two reads, one list:
 *
 * - the merchant's host bench — every `operator` membership that has joined,
 *   searched server-side (the `joined` bound is the design's, not the API's —
 *   see getAssignableHosts);
 * - this event's roster, which is what turns a bench row's action into
 *   "Assigned".
 *
 * The event read is deliberately the unsearched one, so it stays a single
 * cached entry no matter what is typed in the modal — and it is the same query
 * the tab behind the modal already holds, so opening the modal usually costs
 * one request, not two.
 *
 * Order is this event's hosts first, then the rest, each group alphabetical
 * (the annotation on 8122-59289). It is recomputed from the server's answer, so
 * a row that was just assigned moves up on the refetch that follows.
 */
export const useAssignableHosts = (
  eventId?: string,
  searchQuery?: string,
  options?: { enabled?: boolean },
) => {
  const { merchantId } = useGetCurrentMerchantId();
  const queryClient = useQueryClient();
  const [pendingId, setPendingId] = useState<number | null>(null);
  const enabled = options?.enabled ?? true;
 
  const trimmedQuery = searchQuery?.trim() || undefined;
 
  const {
    data: benchResponse,
    isLoading: isLoadingBench,
    isFetching,
    isError,
  } = useGetAssignableHosts(merchantId, trimmedQuery, {
    enabled: Boolean(merchantId) && enabled,
  });
 
  const {
    data: eventResponse,
    isLoading: isLoadingEvent,
    isError: isEventError,
  } = useGetEventHosts(merchantId, eventId, undefined, {
    enabled: Boolean(merchantId && eventId) && enabled,
  });
 
  const { mutateAsync: updateAssignment } = useUpdateHostAssignment(merchantId);
 
  const assignedIds = useMemo(
    () => new Set((eventResponse?.data ?? []).map((row) => row.userAccID)),
    [eventResponse],
  );
 
  const rows = useMemo(() => {
    const parse = (row: AccountMemberApiRow): AssignableHostRow => ({
      id: row.user?.accID ?? 0,
      name: fullName(row.user?.firstName ?? "", row.user?.lastName ?? ""),
      email: row.user?.email ?? "",
      imageURL: row.user?.imageURL ?? "",
      isAssigned: assignedIds.has(row.user?.accID ?? 0),
    });
 
    return (
      (benchResponse?.data ?? [])
        // The row is keyed on the member's user id and the whole flow needs it in
        // the PATCH path, so a row without one is unusable rather than merely
        // odd. `user` is a nested object on the wire and the type admits null.
        .filter((row) => Boolean(row.user?.accID))
        .map(parse)
        .sort((a, b) => {
          if (a.isAssigned !== b.isAssigned) return a.isAssigned ? -1 : 1;
          return sortLabel(a).localeCompare(sortLabel(b), undefined, {
            sensitivity: "base",
          });
        })
    );
  }, [benchResponse, assignedIds]);
 
  /**
   * One handler for both directions — the row's current state decides which,
   * because the mockup gives the assigned row's "Assigned" pill the unassign
   * action rather than a separate control. Stable, so the memoized modal body
   * it is handed to does not re-render on every keystroke in the search.
   */
  const toggleAssignment = useCallback(
    async (row: AssignableHostRow) => {
      Iif (!eventId) return;
      setPendingId(row.id);
      try {
        await updateAssignment({
          memberId: row.id,
          productId: Number(eventId),
          assign: !row.isAssigned,
        });
        showMessage("Success", "", true, HOST_ASSIGNMENT_TOAST);
        await invalidateHostRosters(queryClient);
      } catch {
        // Only a 400 reaches the interceptor's toast, and the failures here are
        // mostly outside it: a 403 when the caller may not update the member's
        // assignments, and a 400 `ErrProductNotAssignable` for an event that
        // has already ended (the API refuses NEW door staff for a finished
        // event).
        showMessage(
          "Error",
          row.isAssigned
            ? `${sortLabel(row)} could not be unassigned. Please try again.`
            : `${sortLabel(
                row,
              )} could not be assigned to this event. The event may have already ended.`,
          true,
          row.isAssigned ? "Unassign failed" : "Assign failed",
        );
      } finally {
        setPendingId(null);
      }
    },
    [eventId, updateAssignment, queryClient],
  );
 
  return {
    rows,
    /** The bench read is the list; the event read only decorates it. */
    isLoading: isLoadingBench || isLoadingEvent,
    /**
     * A refetch over rows already on screen — which, with `keepPreviousData`,
     * is the only signal that a search is in flight: the stale rows stay put
     * and `isLoading` never goes true. A write's own refetch is excluded,
     * because the row it belongs to shows its own pending state.
     */
    isRefetching: isFetching && !isLoadingBench && pendingId === null,
    isError: isError || isEventError,
    /**
     * No hosts on the bench at all — distinct from a search that matched
     * nothing, which keeps the search field and its own empty state
     * (frames 8119-113344 vs 8122-59649).
     *
     * Gated on the response having arrived rather than on `!isLoadingBench`: a
     * disabled query sits at `idle` in react-query v3, so a render where the
     * read has not been asked for yet (no merchantId — a masquerade switch)
     * would otherwise show the terminal "once they accept the invitation"
     * message and drop the search field, having asked for nothing.
     */
    isEmpty: Boolean(benchResponse) && rows.length === 0 && !trimmedQuery,
    toggleAssignment,
    pendingId,
  };
};