All files / src/features/Events/EventDetail/tickets useTicketsExport.ts

96.96% Statements 32/33
87.5% Branches 7/8
83.33% Functions 5/6
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                          10x   10x   10x 10x                                       10x 63x 63x 63x       63x 102x   63x 102x   63x 63x       63x   63x     4x                         3x 3x 3x 3x 3x 3x 3x 3x             63x     5x 4x 4x     63x    
import { useIsMutating, useMutation, useQueryClient } from "react-query";
import { showMessage } from "@common/Toast";
import { QKEY_EVENT_TICKETS } from "@constants/queryKeys";
import { useAppSelector } from "@redux/hooks";
import { sortingKey } from "@redux/slices/fundraisers";
import { selectQueryString } from "@redux/slices/search";
import { customInstance } from "@services/api";
import { buildEventTicketsExportUrl } from "@services/api/products/tickets";
import { useFormattedFilters } from "@shared/GiveFilter/hooks/useFormattedFilters";
import { useCustomThemeV2 } from "@theme/hooks/useCustomThemeV2";
import { DEFAULT_SORTING } from "./useEventTickets";
 
/** Toast copy is spec — frame 7423-217395. */
export const EXPORT_TOAST_TITLE = "Exporting guest list";
export const EXPORT_TOAST_BODY =
  "Your export is being prepared. This may take a few minutes.";
 
const EXPORT_FILENAME = "tickets.csv";
const EXPORT_MUTATION_KEY = "event-tickets-export";
 
/**
 * Downloads `.../ticket-holders.csv` under the list's own sort, search and filter.
 *
 * A hook rather than logic inside the button, because the desktop toolbar, the
 * mobile overflow sheet (frame 7397-90578) and the compact banner's (⋮) all
 * trigger it. The in-flight state is a react-query mutation keyed on the event,
 * so those instances share one `isExporting` — a separate `useState` per
 * instance let two of them fire the same export at once, and left a setState to
 * land after the compact banner had been unmounted by a scroll back up.
 *
 * A synchronous download — the endpoint streams the CSV, and the
 * `Content-Disposition` filename comes from the server. The frame's toast fires on
 * click, which is honest either way: for a large event the count-then-stream
 * request really can take a while. If product wants a true background job (a
 * queued task that emails a link, the way `transactions.csv?async=true` works),
 * that is a server-side change — this would then show the same toast and poll
 * instead of holding the request open.
 */
export const useTicketsExport = (eventId?: string) => {
  const { isMobileView } = useCustomThemeV2();
  const queryClient = useQueryClient();
  const mutationKey = [EXPORT_MUTATION_KEY, eventId];
 
  // Read the sort, search and filter from the same redux state `useEventTickets`
  // reads, so the file can never drift from the list on screen.
  const searchQuery = useAppSelector((state) =>
    selectQueryString(state, QKEY_EVENT_TICKETS),
  );
  const storedSorting = useAppSelector((state) =>
    sortingKey(state, QKEY_EVENT_TICKETS),
  );
  const sorting = storedSorting || DEFAULT_SORTING;
  const { formattedFilterString } = useFormattedFilters({
    queryKey: QKEY_EVENT_TICKETS,
  });
 
  const isExporting = useIsMutating(mutationKey) > 0;
 
  const { mutate } = useMutation(
    mutationKey,
    async () => {
      const data = await customInstance({
        method: "GET",
        url: buildEventTicketsExportUrl(eventId as string, {
          sorting,
          searchQuery,
          filter: formattedFilterString,
        }),
        // Without this axios defaults to "json" and the CSV only survives
        // because JSON.parse fails — a coincidentally-parseable body would
        // arrive as an object and corrupt the file.
        responseType: "blob",
      });
 
      const href = window.URL.createObjectURL(new Blob([data]));
      const link = document.createElement("a");
      link.href = href;
      link.setAttribute("download", EXPORT_FILENAME);
      document.body.appendChild(link);
      link.click();
      Eif (document.body.contains(link)) document.body.removeChild(link);
      window.URL.revokeObjectURL(href);
    },
    // The axios interceptor already surfaces the failure toast; the mutation
    // only has to not reject unhandled.
    { onError: () => undefined },
  );
 
  const exportTickets = () => {
    // Read the cache rather than the rendered flag: that one only catches up on
    // the cache's next notification, so two clicks in one tick both pass it.
    if (!eventId || queryClient.isMutating({ mutationKey }) > 0) return;
    showMessage("Info", EXPORT_TOAST_BODY, !isMobileView, EXPORT_TOAST_TITLE);
    mutate();
  };
 
  return { exportTickets, isExporting };
};