diff --git a/src/pages/admin/festivals/LinkWizard/LinkWizard.tsx b/src/pages/admin/festivals/LinkWizard/LinkWizard.tsx index 58cd2602..6d93515b 100644 --- a/src/pages/admin/festivals/LinkWizard/LinkWizard.tsx +++ b/src/pages/admin/festivals/LinkWizard/LinkWizard.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { Loader2, LinkIcon } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -6,13 +5,9 @@ import { useUntypedSetsByEditionQuery, } from "@/api/artists/useArtistsMissingLinksByEdition"; import { usePrefetchNextBatchLinks } from "@/api/artistSearch/usePrefetchNextBatchLinks"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { filterArtistsByStage } from "@/lib/filterArtistsByStage"; import { useLinkWizardSkipped } from "@/hooks/useLinkWizardSkipped"; -import { - buildLinkWizardQueue, - type LinkWizardQueueItem, -} from "./buildLinkWizardQueue"; +import { useLinkWizardQueue } from "./useLinkWizardQueue"; +import type { LinkWizardQueueItem } from "./buildLinkWizardQueue"; import { LinkWizardQueue } from "./LinkWizardQueue"; import { LinkWizardStep } from "./LinkWizardStep"; import { SetTypeBackfillStep } from "./SetTypeBackfillStep"; @@ -22,38 +17,19 @@ interface LinkWizardProps { } export function LinkWizard({ editionId }: LinkWizardProps) { - const isMobile = useIsMobile(); const artistsQuery = useArtistsMissingLinksByEditionQuery(editionId); const untypedSetsQuery = useUntypedSetsByEditionQuery(editionId); - const [currentItemId, setCurrentItemId] = useState( - undefined, - ); - const [selectedStages, setSelectedStages] = useState([]); - const [showFullQueue, setShowFullQueue] = useState(false); const skippedHook = useLinkWizardSkipped(editionId); - const allArtists = artistsQuery.data ?? []; - const unskippedArtists = allArtists.filter( - (artist) => !skippedHook.isSkipped(artist.id), - ); - const filteredArtists = filterArtistsByStage( - unskippedArtists, - selectedStages, - ); - - const queueItems = buildLinkWizardQueue( - filteredArtists, - untypedSetsQuery.data ?? [], - selectedStages, - ); - - const currentIndex = currentItemId - ? Math.max( - 0, - queueItems.findIndex((item) => item.id === currentItemId), - ) - : 0; - const currentItem = queueItems[Math.min(currentIndex, queueItems.length - 1)]; + const untypedSets = untypedSetsQuery.data ?? []; + const queue = useLinkWizardQueue(allArtists, untypedSets, skippedHook); + const { + items, + artists: filteredArtists, + currentItem, + position, + total, + } = queue; usePrefetchNextBatchLinks( filteredArtists, @@ -75,14 +51,12 @@ export function LinkWizard({ editionId }: LinkWizardProps) {
setSelectedStages([])} - isPreviewMode={isMobile && !showFullQueue} - onViewAll={() => setShowFullQueue(true)} + onSelectItem={queue.selectItem} + selectedStages={queue.selectedStages} + onStageToggle={queue.toggleStage} + onClearStages={queue.clearStages} skippedArtists={skippedHook.getSkippedArtists()} allArtists={allArtists} onRestoreSkipped={skippedHook.restore} @@ -97,9 +71,9 @@ export function LinkWizard({ editionId }: LinkWizardProps) { - {queueItems.length === 0 ? ( + {items.length === 0 ? (

- {selectedStages.length > 0 + {queue.selectedStages.length > 0 ? "No artists missing links and no sets missing a type on selected stages." : "All artists in this edition have both links set and all sets have a type."}

@@ -107,29 +81,23 @@ export function LinkWizard({ editionId }: LinkWizardProps) { goTo(currentIndex - 1)} - onNext={() => { - skippedHook.markSkipped(currentItem.id); - goTo(nextIndexAfterRemoval()); - }} - onSaveSuccess={() => { - skippedHook.markSaved(currentItem.id); - goTo(nextIndexAfterRemoval()); - }} + onPrev={queue.prev} + onNext={queue.skip} + onSaveSuccess={queue.save} /> ) : ( currentItem && ( goTo(currentIndex - 1)} - onNext={() => goTo(currentIndex + 1)} - onSaveSuccess={() => goTo(nextIndexAfterRemoval())} + position={position} + total={total} + onPrev={queue.prev} + onNext={queue.skip} + onSaveSuccess={queue.save} /> ) )} @@ -137,28 +105,6 @@ export function LinkWizard({ editionId }: LinkWizardProps) {
); - - function goTo(index: number) { - const clamped = Math.max(0, Math.min(index, queueItems.length - 1)); - setCurrentItemId(queueItems[clamped]?.id); - } - - function nextIndexAfterRemoval() { - const isLast = currentIndex >= queueItems.length - 1; - return isLast ? currentIndex - 1 : currentIndex + 1; - } - - function handleSelectItem(item: LinkWizardQueueItem) { - setCurrentItemId(item.id); - } - - function handleStageToggle(stageId: string) { - setSelectedStages((prev) => - prev.includes(stageId) - ? prev.filter((id) => id !== stageId) - : [...prev, stageId], - ); - } } function itemName(item: LinkWizardQueueItem) { diff --git a/src/pages/admin/festivals/LinkWizard/LinkWizardQueue.tsx b/src/pages/admin/festivals/LinkWizard/LinkWizardQueue.tsx index c5b8aee1..894e91f5 100644 --- a/src/pages/admin/festivals/LinkWizard/LinkWizardQueue.tsx +++ b/src/pages/admin/festivals/LinkWizard/LinkWizardQueue.tsx @@ -1,9 +1,11 @@ +import { useState } from "react"; import { Tag } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "@/components/ui/button"; import type { ArtistWithSets } from "@/api/artists/useArtistsMissingLinksByEdition"; import type { ArtistSkipRecord } from "@/hooks/useLinkWizardSkipped"; +import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import type { LinkWizardQueueItem } from "./buildLinkWizardQueue"; import { LinkWizardStageFilterDropdown } from "./LinkWizardStageFilterDropdown"; @@ -19,8 +21,6 @@ interface LinkWizardQueueProps { selectedStages: string[]; onStageToggle: (stageId: string) => void; onClearStages: () => void; - isPreviewMode?: boolean; - onViewAll?: () => void; skippedArtists: ArtistSkipRecord[]; allArtists: Array<{ id: string; name: string }>; onRestoreSkipped: (artistId: string) => void; @@ -34,13 +34,14 @@ export function LinkWizardQueue({ selectedStages, onStageToggle, onClearStages, - isPreviewMode = false, - onViewAll, skippedArtists, allArtists, onRestoreSkipped, onClearAllSkipped, }: LinkWizardQueueProps) { + const isMobile = useIsMobile(); + const [showFullQueue, setShowFullQueue] = useState(false); + const isPreviewMode = isMobile && !showFullQueue; const displayedItems = isPreviewMode ? items.slice(0, MOBILE_PREVIEW_COUNT) : items; @@ -104,7 +105,7 @@ export function LinkWizardQueue({ variant="ghost" size="sm" className="w-full text-xs text-accent hover:text-accent hover:bg-accent-soft" - onClick={onViewAll} + onClick={() => setShowFullQueue(true)} > View all ({items.length - MOBILE_PREVIEW_COUNT} more) diff --git a/src/pages/admin/festivals/LinkWizard/useLinkWizardQueue.test.ts b/src/pages/admin/festivals/LinkWizard/useLinkWizardQueue.test.ts new file mode 100644 index 00000000..1a7c7fe6 --- /dev/null +++ b/src/pages/admin/festivals/LinkWizard/useLinkWizardQueue.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useLinkWizardQueue } from "./useLinkWizardQueue"; +import { useLinkWizardSkipped } from "@/hooks/useLinkWizardSkipped"; +import type { + ArtistSetWithCoPerformers, + ArtistWithSets, +} from "@/api/artists/useArtistsMissingLinksByEdition"; + +const EDITION_ID = "edition-1"; + +function useTestQueue( + artists: ArtistWithSets[], + untypedSets: ArtistSetWithCoPerformers[] = [], +) { + const skippedHook = useLinkWizardSkipped(EDITION_ID); + const queue = useLinkWizardQueue(artists, untypedSets, skippedHook); + return { queue, skippedHook }; +} + +function mockArtist( + id: string, + name: string, + sets: { stage_id: string | null }[] = [], +): ArtistWithSets { + return { + id, + name, + added_by: "test-user", + archived: false, + created_at: new Date().toISOString(), + description: null, + estimated_date: null, + image_url: null, + slug: `${name.toLowerCase()}-${id}`, + soundcloud_url: null, + spotify_url: null, + stage: null, + time_end: null, + time_start: null, + updated_at: new Date().toISOString(), + artist_music_genres: [], + sets: sets.map((s) => ({ + id: `set-${id}-${s.stage_id}`, + name: `Set at ${s.stage_id}`, + description: null, + time_start: null, + time_end: null, + stage_id: s.stage_id, + stage_name: s.stage_id ? `Stage ${s.stage_id}` : null, + set_type: null, + co_performers: [], + })), + }; +} + +function mockSet( + id: string, + name: string, + stageId: string | null = null, +): ArtistSetWithCoPerformers { + return { + id, + name, + description: null, + time_start: null, + time_end: null, + stage_id: stageId, + stage_name: stageId ? `Stage ${stageId}` : null, + set_type: null, + co_performers: [], + }; +} + +describe("useLinkWizardQueue", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("starts on the first artist in the list", async () => { + const artists = [mockArtist("1", "Artist 1"), mockArtist("2", "Artist 2")]; + + const { result } = renderHook(() => useTestQueue(artists)); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("1"); + }); + expect(result.current.queue.position).toBe(1); + expect(result.current.queue.total).toBe(2); + expect(result.current.queue.items.map((i) => i.id)).toEqual(["1", "2"]); + }); + + it("reports position 0 and no current item for an empty queue", async () => { + const { result } = renderHook(() => useTestQueue([])); + + await waitFor(() => { + expect(result.current.queue.total).toBe(0); + }); + expect(result.current.queue.position).toBe(0); + expect(result.current.queue.currentItem).toBeUndefined(); + }); + + it("narrows artists by selected stage and keeps currentItem consistent", async () => { + const artists = [ + mockArtist("1", "Artist 1", [{ stage_id: "stage-a" }]), + mockArtist("2", "Artist 2", [{ stage_id: "stage-b" }]), + mockArtist("3", "Artist 3", [{ stage_id: "stage-a" }]), + ]; + + const { result } = renderHook(() => useTestQueue(artists)); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("1"); + }); + + act(() => { + result.current.queue.toggleStage("stage-a"); + }); + + expect(result.current.queue.items.map((i) => i.id)).toEqual(["1", "3"]); + expect(result.current.queue.currentItem?.id).toBe("1"); + expect(result.current.queue.selectedStages).toEqual(["stage-a"]); + + act(() => { + result.current.queue.clearStages(); + }); + + expect(result.current.queue.items.map((i) => i.id)).toEqual([ + "1", + "2", + "3", + ]); + expect(result.current.queue.selectedStages).toEqual([]); + }); + + it("skip marks the current artist skipped and advances to the next item", async () => { + const artists = [ + mockArtist("1", "Artist 1"), + mockArtist("2", "Artist 2"), + mockArtist("3", "Artist 3"), + ]; + + const { result } = renderHook(() => useTestQueue(artists)); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("1"); + }); + + act(() => { + result.current.queue.skip(); + }); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("2"); + }); + expect(result.current.queue.items.map((i) => i.id)).toEqual(["2", "3"]); + + await waitFor(() => { + expect(result.current.skippedHook.isSkipped("1")).toBe(true); + }); + }); + + it("save marks the current artist saved and advances to the next item", async () => { + const artists = [mockArtist("1", "Artist 1"), mockArtist("2", "Artist 2")]; + + const { result } = renderHook(() => useTestQueue(artists)); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("1"); + }); + + act(() => { + result.current.queue.save(); + }); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("2"); + }); + expect(result.current.queue.items.map((i) => i.id)).toEqual(["2"]); + + await waitFor(() => { + const saved = result.current.skippedHook + .getSkippedArtists() + .find((record) => record.artistId === "1"); + expect(saved?.status).toBe("saved"); + }); + }); + + it("skip/save on the last artist moves to the previous item instead of going out of bounds", async () => { + const artists = [mockArtist("1", "Artist 1"), mockArtist("2", "Artist 2")]; + + const { result } = renderHook(() => useTestQueue(artists)); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("1"); + }); + + act(() => { + result.current.queue.selectItem(result.current.queue.items[1]); + }); + + expect(result.current.queue.currentItem?.id).toBe("2"); + + act(() => { + result.current.queue.skip(); + }); + + await waitFor(() => { + expect(result.current.queue.items.map((i) => i.id)).toEqual(["1"]); + }); + expect(result.current.queue.currentItem?.id).toBe("1"); + }); + + it("selectItem jumps directly to the given item", async () => { + const artists = [ + mockArtist("1", "Artist 1"), + mockArtist("2", "Artist 2"), + mockArtist("3", "Artist 3"), + ]; + + const { result } = renderHook(() => useTestQueue(artists)); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("1"); + }); + + act(() => { + result.current.queue.selectItem(result.current.queue.items[2]); + }); + + expect(result.current.queue.currentItem?.id).toBe("3"); + expect(result.current.queue.position).toBe(3); + }); + + it("prev moves to the prior item in the filtered list", async () => { + const artists = [ + mockArtist("1", "Artist 1"), + mockArtist("2", "Artist 2"), + mockArtist("3", "Artist 3"), + ]; + + const { result } = renderHook(() => useTestQueue(artists)); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("1"); + }); + + act(() => { + result.current.queue.selectItem(result.current.queue.items[2]); + }); + expect(result.current.queue.currentItem?.id).toBe("3"); + + act(() => { + result.current.queue.prev(); + }); + expect(result.current.queue.currentItem?.id).toBe("2"); + }); + + it("keeps currentItem valid when a stage filter is toggled after skip/save", async () => { + const artists = [ + mockArtist("1", "Artist 1", [{ stage_id: "stage-a" }]), + mockArtist("2", "Artist 2", [{ stage_id: "stage-b" }]), + mockArtist("3", "Artist 3", [{ stage_id: "stage-a" }]), + ]; + + const { result } = renderHook(() => useTestQueue(artists)); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("1"); + }); + + act(() => { + result.current.queue.skip(); + }); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("2"); + }); + + act(() => { + result.current.queue.toggleStage("stage-a"); + }); + + expect(result.current.queue.items.map((i) => i.id)).toEqual(["3"]); + expect(result.current.queue.currentItem?.id).toBe("3"); + }); + + it("orders artist-less untyped set items after artist items", async () => { + const artists = [mockArtist("1", "Artist 1")]; + const sets = [mockSet("set-1", "Morning Yoga")]; + + const { result } = renderHook(() => useTestQueue(artists, sets)); + + await waitFor(() => { + expect(result.current.queue.items.map((i) => i.id)).toEqual([ + "1", + "set-1", + ]); + }); + expect(result.current.queue.currentItem).toMatchObject({ + kind: "artist", + id: "1", + }); + }); + + it("skipping a set item plainly advances without marking it in useLinkWizardSkipped", async () => { + const sets = [mockSet("set-1", "Morning Yoga"), mockSet("set-2", "Talk")]; + + const { result } = renderHook(() => useTestQueue([], sets)); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("set-1"); + }); + + act(() => { + result.current.queue.skip(); + }); + + expect(result.current.queue.currentItem?.id).toBe("set-2"); + expect(result.current.queue.items.map((i) => i.id)).toEqual([ + "set-1", + "set-2", + ]); + + expect(result.current.skippedHook.getSkippedArtists()).toEqual([]); + }); + + it("saving a set item advances as if it were removed, without marking useLinkWizardSkipped", async () => { + const sets = [mockSet("set-1", "Morning Yoga"), mockSet("set-2", "Talk")]; + + const { result } = renderHook(() => useTestQueue([], sets)); + + await waitFor(() => { + expect(result.current.queue.currentItem?.id).toBe("set-1"); + }); + + act(() => { + result.current.queue.save(); + }); + + expect(result.current.queue.currentItem?.id).toBe("set-2"); + expect(result.current.skippedHook.getSkippedArtists()).toEqual([]); + }); + + it("filters set items by stage alongside artist items", async () => { + const artists = [mockArtist("1", "Artist 1", [{ stage_id: "stage-a" }])]; + const sets = [ + mockSet("set-a", "On Stage A", "stage-a"), + mockSet("set-b", "On Stage B", "stage-b"), + ]; + + const { result } = renderHook(() => useTestQueue(artists, sets)); + + await waitFor(() => { + expect(result.current.queue.items).toHaveLength(3); + }); + + act(() => { + result.current.queue.toggleStage("stage-a"); + }); + + expect(result.current.queue.items.map((i) => i.id)).toEqual(["1", "set-a"]); + }); +}); diff --git a/src/pages/admin/festivals/LinkWizard/useLinkWizardQueue.ts b/src/pages/admin/festivals/LinkWizard/useLinkWizardQueue.ts new file mode 100644 index 00000000..81a36a95 --- /dev/null +++ b/src/pages/admin/festivals/LinkWizard/useLinkWizardQueue.ts @@ -0,0 +1,104 @@ +import { useState } from "react"; +import type { + ArtistSetWithCoPerformers, + ArtistWithSets, +} from "@/api/artists/useArtistsMissingLinksByEdition"; +import { filterArtistsByStage } from "@/lib/filterArtistsByStage"; +import type { useLinkWizardSkipped } from "@/hooks/useLinkWizardSkipped"; +import { + buildLinkWizardQueue, + type LinkWizardQueueItem, +} from "./buildLinkWizardQueue"; + +export function useLinkWizardQueue( + allArtists: ArtistWithSets[], + untypedSets: ArtistSetWithCoPerformers[], + skippedHook: ReturnType, +) { + const [currentItemId, setCurrentItemId] = useState( + undefined, + ); + const [selectedStages, setSelectedStages] = useState([]); + + const unskippedArtists = allArtists.filter( + (artist) => !skippedHook.isSkipped(artist.id), + ); + const artists = filterArtistsByStage(unskippedArtists, selectedStages); + const items = buildLinkWizardQueue(artists, untypedSets, selectedStages); + + const currentIndex = currentItemId + ? Math.max( + 0, + items.findIndex((item) => item.id === currentItemId), + ) + : 0; + const currentItem = items[Math.min(currentIndex, items.length - 1)]; + + function goTo(index: number) { + const clamped = Math.max(0, Math.min(index, items.length - 1)); + setCurrentItemId(items[clamped]?.id); + } + + function nextIndexAfterRemoval() { + const isLast = currentIndex >= items.length - 1; + return isLast ? currentIndex - 1 : currentIndex + 1; + } + + function prev() { + goTo(currentIndex - 1); + } + + // Artist items are removed from the queue once skipped/saved, so advancing + // treats the current position as vacated. Set items aren't tracked in + // skippedHook and only leave the queue once a refetch confirms they're + // typed, so skipping one is a plain advance (nothing to mark, nothing + // removed yet) — see buildLinkWizardQueue.ts. + function skip() { + if (!currentItem) return; + if (currentItem.kind === "artist") { + skippedHook.markSkipped(currentItem.id); + goTo(nextIndexAfterRemoval()); + } else { + goTo(currentIndex + 1); + } + } + + function save() { + if (!currentItem) return; + if (currentItem.kind === "artist") { + skippedHook.markSaved(currentItem.id); + } + goTo(nextIndexAfterRemoval()); + } + + function selectItem(item: LinkWizardQueueItem) { + setCurrentItemId(item.id); + } + + function toggleStage(stageId: string) { + setSelectedStages((prev) => + prev.includes(stageId) + ? prev.filter((id) => id !== stageId) + : [...prev, stageId], + ); + } + + function clearStages() { + setSelectedStages([]); + } + + return { + items, + artists, + currentItem, + position: items.length === 0 ? 0 : currentIndex + 1, + total: items.length, + prev, + skip, + save, + selectItem, + selectedStages, + toggleStage, + clearStages, + }; +}