diff --git a/src/app.tsx b/src/app.tsx index 3a4f837f..c2ad7402 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,7 +1,7 @@ import { Suspense, lazy, useEffect } from "react"; import { Route, Router, Switch, useLocation } from "wouter"; +import { useBrowserLocation } from "wouter/use-browser-location"; -import { CardModalProvider } from "./components/card-modal/card-modal-context"; import { ErrorBoundary } from "./components/error-boundary"; import { Loader } from "./components/ui/loader"; import { ToastProvider } from "./components/ui/toast"; @@ -13,8 +13,6 @@ import { queryDataVersion, queryMetadata, } from "./store/services/queries"; -import { useBrowserLocationWithConfirmation } from "./utils/use-location-with-confirm"; -import { useSyncActiveDeckId } from "./utils/use-sync-active-deck-id"; const Browse = lazy(() => import("./pages/browse/browse")); const DeckEdit = lazy(() => import("./pages/deck-edit/deck-edit")); @@ -41,32 +39,30 @@ function App() { return ( - - - - }> - {storeInitialized && ( - - - - - - - - - - - - - - - )} - - - + + + }> + {storeInitialized && ( + + + + + + + + + + + + + + + )} + + ); } @@ -74,8 +70,6 @@ function App() { function RouteReset() { const [pathname] = useLocation(); - useSyncActiveDeckId(); - useEffect(() => { if (window.location.hash) { // HACK: this enables hash-based deep links to work when a route is loaded async. diff --git a/src/components/card-list/card-list.tsx b/src/components/card-list/card-list.tsx index d1159745..e900b1ca 100644 --- a/src/components/card-list/card-list.tsx +++ b/src/components/card-list/card-list.tsx @@ -6,10 +6,11 @@ import { CenterLayout } from "@/layouts/center-layout"; import { useStore } from "@/store"; import type { ListState } from "@/store/selectors/card-list"; import { selectListCards } from "@/store/selectors/card-list"; -import { selectCardQuantities } from "@/store/selectors/decks"; import { selectActiveListSearch } from "@/store/selectors/lists"; import type { Card } from "@/store/services/queries.types"; +import type { Id, Slots } from "@/store/slices/data.types"; import { range } from "@/utils/range"; +import { useDeckId } from "@/utils/use-deck-id"; import css from "./card-list.module.css"; @@ -22,28 +23,32 @@ import { CardListNav } from "./card-list-nav"; import { CardSearch } from "./card-search"; type Props = { - canEdit?: boolean; - onOpenModal?: (code: string) => void; + deckId?: Id; + onChangeCardQuantity?: (code: string, quantity: number) => void; + quantities?: Slots; renderListCardAction?: (card: Card) => React.ReactNode; renderListCardExtra?: (card: Card) => React.ReactNode; slotLeft?: React.ReactNode; slotRight?: React.ReactNode; + targetDeck?: "slots" | "extraSlots" | "both"; }; export function CardList({ - canEdit, - onOpenModal, + onChangeCardQuantity, + quantities, renderListCardAction, renderListCardExtra, slotLeft, slotRight, + targetDeck, }: Props) { const modalContext = useCardModalContext(); + const deckIdCtx = useDeckId(); - const data = useStore(selectListCards); + const data = useStore((state) => + selectListCards(state, deckIdCtx.deckId, deckIdCtx.canEdit, targetDeck), + ); - const updateCardQuantity = useStore((state) => state.updateCardQuantity); - const quantities = useStore(selectCardQuantities); const search = useStore(selectActiveListSearch); const metadata = useStore((state) => state.metadata); @@ -196,10 +201,7 @@ export function CardList({ disableKeyboard isActive={index === currentTop} key={data.cards[index].code} - onChangeCardQuantity={ - canEdit ? updateCardQuantity : undefined - } - onOpenModal={onOpenModal} + onChangeCardQuantity={onChangeCardQuantity} quantities={quantities} renderAction={renderListCardAction} renderExtra={renderListCardExtra} diff --git a/src/components/card-modal/card-modal-context.tsx b/src/components/card-modal/card-modal-context.tsx index c032d213..d4822cd3 100644 --- a/src/components/card-modal/card-modal-context.tsx +++ b/src/components/card-modal/card-modal-context.tsx @@ -5,8 +5,6 @@ import { CardModal } from "./card-modal"; type CardModalContextConfig = { code: string; - deckId?: string | number; - canEdit?: boolean; }; type CardModalContextState = diff --git a/src/components/card-modal/card-modal-quantities.tsx b/src/components/card-modal/card-modal-quantities.tsx index ad4fd5bb..7920701a 100644 --- a/src/components/card-modal/card-modal-quantities.tsx +++ b/src/components/card-modal/card-modal-quantities.tsx @@ -2,9 +2,9 @@ import { useCallback, useEffect, useRef } from "react"; import { useStore } from "@/store"; import type { DisplayDeck } from "@/store/lib/deck-grouping"; -import { selectShowIgnoreDeckLimitSlots } from "@/store/selectors/decks"; +import { selectShowIgnoreDeckLimitSlotsById } from "@/store/selectors/deck-view"; import type { Card } from "@/store/services/queries.types"; -import type { Slot } from "@/store/slices/deck-view.types"; +import type { Slot } from "@/store/slices/deck-edits.types"; import css from "./card-modal.module.css"; @@ -42,17 +42,23 @@ export function CardModalQuantities({ if (!canEdit) return; function onKeyDown(evt: KeyboardEvent) { - if (evt.metaKey) return; + if (evt.metaKey || !deck?.id) return; if (evt.key === "ArrowRight") { evt.preventDefault(); - updateCardQuantity(card.code, 1, "slots"); + updateCardQuantity(deck.id, card.code, 1, "slots"); } else if (evt.key === "ArrowLeft") { evt.preventDefault(); - updateCardQuantity(card.code, -1, "slots"); + updateCardQuantity(deck.id, card.code, -1, "slots"); } else if (Number.parseInt(evt.key) >= 0) { evt.preventDefault(); - updateCardQuantity(card.code, Number.parseInt(evt.key), "slots", "set"); + updateCardQuantity( + deck.id, + card.code, + Number.parseInt(evt.key), + "slots", + "set", + ); onClickBackground?.(); } } @@ -61,7 +67,7 @@ export function CardModalQuantities({ return () => { window.removeEventListener("keydown", onKeyDown); }; - }, [canEdit, card.code, updateCardQuantity, onClickBackground]); + }, [canEdit, card.code, updateCardQuantity, onClickBackground, deck?.id]); const quantities = deck?.slots; const sideSlotQuantities = deck?.sideSlots; @@ -70,11 +76,14 @@ export function CardModalQuantities({ const ignoreDeckLimitQuantities = deck?.ignoreDeckLimitSlots; const onChangeQuantity = (quantity: number, slot: Slot) => { - updateCardQuantity(card.code, quantity, slot); + if (!deck?.id) return; + updateCardQuantity(deck.id, card.code, quantity, slot); }; const showIgnoreDeckLimitSlots = useStore((state) => - selectShowIgnoreDeckLimitSlots(state, card), + deck + ? selectShowIgnoreDeckLimitSlotsById(state, deck.id, false, card) + : false, ); const code = card.code; diff --git a/src/components/card-modal/card-modal.tsx b/src/components/card-modal/card-modal.tsx index 5f9c2d39..535b8d45 100644 --- a/src/components/card-modal/card-modal.tsx +++ b/src/components/card-modal/card-modal.tsx @@ -9,8 +9,8 @@ import { } from "@/store/lib/resolve-card"; import { selectCardWithRelations } from "@/store/selectors/card-view"; import { selectActiveDeckById } from "@/store/selectors/deck-view"; -import { selectActiveDeck } from "@/store/selectors/decks"; import { formatRelationTitle } from "@/utils/formatting"; +import { useDeckId } from "@/utils/use-deck-id"; import { useMedia } from "@/utils/use-media"; import css from "./card-modal.module.css"; @@ -22,41 +22,30 @@ import { CustomizationsEditor } from "../customizations/customizations-editor"; import { Button } from "../ui/button"; import { useDialogContext } from "../ui/dialog.hooks"; import { Modal } from "../ui/modal"; -import { useCardModalContext } from "./card-modal-context"; import { CardModalQuantities } from "./card-modal-quantities"; type Props = { code: string; - deckId?: string; - canEdit?: boolean; }; -export function CardModal({ canEdit, code, deckId }: Props) { +export function CardModal({ code }: Props) { + const deckIdCtx = useDeckId(); + const deckId = deckIdCtx.deckId; + const canEdit = deckIdCtx.canEdit; + const modalContext = useDialogContext(); - const cardModalContext = useCardModalContext(); const onCloseModal = useCallback(() => { modalContext?.setOpen(false); }, [modalContext]); - const onOpenModal = useCallback( - (code: string) => { - cardModalContext?.setOpen({ canEdit, code, deckId }); - }, - [canEdit, deckId, cardModalContext], - ); - - // FIXME: Remove this hack when we have refactored the deck edit state. + // we need the active deck here to get contents of bondedSlots. const activeDeck = useStore((state) => - deckId - ? canEdit - ? selectActiveDeck(state) - : selectActiveDeckById(state, deckId) - : undefined, + deckId ? selectActiveDeckById(state, deckId, canEdit) : undefined, ); const cardWithRelations = useStore((state) => - selectCardWithRelations(state, code, true), + selectCardWithRelations(state, code, true, deckId, canEdit), ); const showQuantities = @@ -78,9 +67,9 @@ export function CardModal({ canEdit, code, deckId }: Props) { {cardWithRelations.card.customization_options ? ( activeDeck ? ( ) : ( @@ -93,7 +82,6 @@ export function CardModal({ canEdit, code, deckId }: Props) { const cards = Array.isArray(value) ? value : [value]; return ( - selectCardWithRelations(state, code, false), + selectCardWithRelations( + state, + code, + false, + deckIdCtx?.deckId, + deckIdCtx?.canEdit, + ), ); if (!resolvedCard) return null; diff --git a/src/components/cardset.tsx b/src/components/cardset.tsx index 832d6d2c..e8352ca7 100644 --- a/src/components/cardset.tsx +++ b/src/components/cardset.tsx @@ -14,17 +14,11 @@ import { Checkbox } from "./ui/checkbox"; type Props = { onChangeCardQuantity?: (code: string, quantity: number) => void; - onOpenModal?: (code: string) => void; onSelect?: (id: string) => void; set: CardSetType; }; -export function CardSet({ - onChangeCardQuantity, - onOpenModal, - onSelect, - set, -}: Props) { +export function CardSet({ onChangeCardQuantity, onSelect, set }: Props) { const canCheckOwnership = useStore(selectCanCheckOwnership); const cardOwnedCount = useStore(selectCardOwnedCount); @@ -55,7 +49,6 @@ export function CardSet({ onChangeCardQuantity={ set.canSetQuantity ? onChangeCardQuantity : undefined } - onOpenModal={onOpenModal} owned={cardOwnedCount(card)} quantities={ set.quantities diff --git a/src/components/customizations/customization-option.tsx b/src/components/customizations/customization-option.tsx index 88b36723..87b7f3ed 100644 --- a/src/components/customizations/customization-option.tsx +++ b/src/components/customizations/customization-option.tsx @@ -5,7 +5,7 @@ import type { Card, CustomizationOption as CustomizationOptionType, } from "@/store/services/queries.types"; -import type { CustomizationEdit } from "@/store/slices/deck-view.types"; +import type { CustomizationEdit } from "@/store/slices/deck-edits.types"; import { parseCustomizationTextHtml } from "@/utils/card-utils"; import { range } from "@/utils/range"; diff --git a/src/components/customizations/customizations-editor.tsx b/src/components/customizations/customizations-editor.tsx index 8b108157..fe5e0e0c 100644 --- a/src/components/customizations/customizations-editor.tsx +++ b/src/components/customizations/customizations-editor.tsx @@ -4,33 +4,36 @@ import { useCallback } from "react"; import { useStore } from "@/store"; import type { ResolvedCard, ResolvedDeck } from "@/store/lib/types"; import type { Card } from "@/store/services/queries.types"; -import type { CustomizationEdit } from "@/store/slices/deck-view.types"; +import type { CustomizationEdit } from "@/store/slices/deck-edits.types"; import { getCardColor } from "@/utils/card-utils"; +import { useDeckId } from "@/utils/use-deck-id"; import css from "./customizations.module.css"; import { CustomizationOption } from "./customization-option"; type Props = { - activeDeck?: ResolvedDeck; + deck?: ResolvedDeck; card: Card; canEdit?: boolean; }; -export function CustomizationsEditor({ activeDeck, card, canEdit }: Props) { +export function CustomizationsEditor({ deck, card, canEdit }: Props) { + const deckIdCtx = useDeckId(); const updateCustomization = useStore((state) => state.updateCustomization); const backgroundCls = getCardColor(card, "background"); - const choices = activeDeck?.customizations?.[card.code]; + const choices = deck?.customizations?.[card.code]; const options = card.customization_options; const text = card.real_customization_text?.split("\n"); const onChangeCustomization = useCallback( (index: number, edit: CustomizationEdit) => { - updateCustomization(card.code, index, edit); + if (!deckIdCtx.deckId) return; + updateCustomization(deckIdCtx.deckId, card.code, index, edit); }, - [card.code, updateCustomization], + [card.code, updateCustomization, deckIdCtx.deckId], ); if (!options || !text) return null; diff --git a/src/components/decklist/decklist-groups.tsx b/src/components/decklist/decklist-groups.tsx index 92fbf01a..33b36ed4 100644 --- a/src/components/decklist/decklist-groups.tsx +++ b/src/components/decklist/decklist-groups.tsx @@ -1,12 +1,15 @@ import clsx from "clsx"; +import { useMemo } from "react"; import { useStore } from "@/store"; import type { Grouping } from "@/store/lib/deck-grouping"; import { sortByName, sortBySlots, sortTypesByOrder } from "@/store/lib/sorting"; -import { selectForbiddenCards } from "@/store/selectors/decks"; +import { selectForbiddenCardsById } from "@/store/selectors/deck-view"; import { selectCanCheckOwnership } from "@/store/selectors/shared"; import type { Card } from "@/store/services/queries.types"; +import type { Slot } from "@/store/slices/deck-edits.types"; import { capitalize } from "@/utils/formatting"; +import { useDeckIdChecked } from "@/utils/use-deck-id"; import css from "./decklist-groups.module.css"; @@ -14,10 +17,8 @@ import SlotIcon from "../icons/slot-icon"; import { ListCard } from "../list-card/list-card"; type Props = { - canEdit?: boolean; group: Grouping; ignoredCounts?: Record; - onOpenModal?: (code: string) => void; quantities?: Record; layout: "one_column" | "two_column"; mapping: string; @@ -25,12 +26,10 @@ type Props = { }; export function DecklistGroups({ - canEdit, group, ignoredCounts, layout, mapping, - onOpenModal, ownershipCounts, quantities, }: Props) { @@ -48,11 +47,9 @@ export function DecklistGroups({ {capitalize(key)} @@ -98,29 +95,38 @@ export function DecklistGroups({ } type DecklistGroupProps = { - canEdit?: boolean; cards: Card[]; ignoredCounts?: Record; mapping: string; - onOpenModal?: (code: string) => void; ownershipCounts: Record; quantities?: Record; }; export function DecklistGroup({ - canEdit: _canEdit, cards, ignoredCounts, mapping, - onOpenModal, ownershipCounts, quantities, }: DecklistGroupProps) { - const forbiddenCards = useStore(selectForbiddenCards); - const canEdit = _canEdit && mapping !== "bonded"; + const ctx = useDeckIdChecked(); + + const forbiddenCards = useStore((state) => + selectForbiddenCardsById(state, ctx.deckId, ctx.canEdit), + ); + + const canEdit = ctx.canEdit && mapping !== "bonded"; const canCheckOwnership = useStore(selectCanCheckOwnership); const updateCardQuantity = useStore((state) => state.updateCardQuantity); + const onChangeCardQuantity = useMemo(() => { + if (!canEdit) return undefined; + + return (code: string, quantity: number) => { + updateCardQuantity(ctx.deckId, code, quantity, mapping as Slot); + }; + }, [updateCardQuantity, canEdit, ctx.deckId, mapping]); + return (
    {cards.toSorted(sortByName).map((card) => ( @@ -137,8 +143,7 @@ export function DecklistGroup({ isIgnored={ignoredCounts?.[card.code]} key={card.code} omitBorders - onChangeCardQuantity={canEdit ? updateCardQuantity : undefined} - onOpenModal={onOpenModal} + onChangeCardQuantity={onChangeCardQuantity} owned={ownershipCounts[card.code]} quantities={quantities} size="sm" diff --git a/src/components/decklist/decklist.tsx b/src/components/decklist/decklist.tsx index 20b111d1..9c58daf8 100644 --- a/src/components/decklist/decklist.tsx +++ b/src/components/decklist/decklist.tsx @@ -14,9 +14,7 @@ const LABELS: Record = { }; type Props = { - canEdit?: boolean; deck: DisplayDeck; - onOpenModal?: (code: string) => void; }; function getSlotsForGrouping(deck: DisplayDeck, grouping: NamedGrouping) { @@ -26,7 +24,7 @@ function getSlotsForGrouping(deck: DisplayDeck, grouping: NamedGrouping) { return undefined; } -export function Decklist({ canEdit, deck, onOpenModal }: Props) { +export function Decklist({ deck }: Props) { const firstCol = deck.groups.extra || deck.groups.side ? deck.groups.bonded : null; @@ -38,12 +36,10 @@ export function Decklist({ canEdit, deck, onOpenModal }: Props) {
    @@ -53,12 +49,10 @@ export function Decklist({ canEdit, deck, onOpenModal }: Props) {
    @@ -66,11 +60,9 @@ export function Decklist({ canEdit, deck, onOpenModal }: Props) { {firstCol && ( @@ -81,11 +73,9 @@ export function Decklist({ canEdit, deck, onOpenModal }: Props) {
    @@ -97,11 +87,9 @@ export function Decklist({ canEdit, deck, onOpenModal }: Props) { {thirdCol && ( diff --git a/src/components/list-card/list-card-inner.tsx b/src/components/list-card/list-card-inner.tsx index f0638ec1..6c36a0f7 100644 --- a/src/components/list-card/list-card-inner.tsx +++ b/src/components/list-card/list-card-inner.tsx @@ -10,6 +10,7 @@ import css from "./list-card.module.css"; import { CardHealth } from "../card-health"; import { CardIcon } from "../card-icon"; +import { useCardModalContext } from "../card-modal/card-modal-context"; import { CardThumbnail } from "../card/card-thumbnail"; import { ExperienceDots } from "../experience-dots"; import { MulticlassIcons } from "../icons/multiclass-icons"; @@ -27,11 +28,11 @@ export type Props = { card: Card; className?: string; disableKeyboard?: boolean; + disableModalOpen?: boolean; figureRef?: (node: ReferenceType | null) => void; isForbidden?: boolean; isIgnored?: number; omitBorders?: boolean; - onOpenModal?: (code: string) => void; onChangeCardQuantity?: (code: string, quantity: number) => void; owned?: number; quantities?: { @@ -48,15 +49,15 @@ export function ListCardInner({ isActive, as = "div", card, - disableKeyboard, canIndicateRemoval, canCheckOwnership, className, + disableKeyboard, + disableModalOpen, figureRef, isForbidden, isIgnored, onChangeCardQuantity, - onOpenModal, omitBorders, owned, referenceProps, @@ -66,6 +67,7 @@ export function ListCardInner({ showInvestigatorIcons, size, }: Props) { + const modalContext = useCardModalContext(); const quantity = quantities ? quantities[card.code] ?? 0 : 0; const ownedCount = owned ?? 0; @@ -82,10 +84,8 @@ export function ListCardInner({ ); const openModal = useCallback(() => { - if (onOpenModal) { - onOpenModal(card.code); - } - }, [onOpenModal, card.code]); + modalContext.setOpen({ code: card.code }); + }, [modalContext, card.code]); return ( {card.imageurl && ( - - - - +
    ); } diff --git a/src/pages/deck-edit/editor/editor.tsx b/src/pages/deck-edit/editor/editor.tsx index ec712cda..fcfe8fd7 100644 --- a/src/pages/deck-edit/editor/editor.tsx +++ b/src/pages/deck-edit/editor/editor.tsx @@ -3,10 +3,9 @@ import { DecklistGroups } from "@/components/decklist/decklist-groups"; import { DecklistSection } from "@/components/decklist/decklist-section"; import { Scroller } from "@/components/ui/scroller"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useStore } from "@/store"; import type { DisplayDeck } from "@/store/lib/deck-grouping"; import type { DeckValidationResult } from "@/store/lib/deck-validation"; -import { selectCurrentTab } from "@/store/selectors/decks"; +import type { Tab } from "@/store/slices/deck-edits.types"; import css from "./editor.module.css"; @@ -16,15 +15,13 @@ import { MetaEditor } from "./meta-editor"; type Props = { className?: string; + currentTab: Tab; + onTabChange: (tab: Tab) => void; deck: DisplayDeck; - onOpenModal: (code: string) => void; validation?: DeckValidationResult; }; -export function Editor({ deck, onOpenModal, validation }: Props) { - const currentTab = useStore(selectCurrentTab); - const updateActiveTab = useStore((state) => state.updateActiveTab); - +export function Editor({ currentTab, deck, onTabChange, validation }: Props) { return (
    @@ -35,7 +32,7 @@ export function Editor({ deck, onOpenModal, validation }: Props) { className={css["editor-tabs"]} length={deck.hasExtraDeck ? 4 : 3} onValueChange={(value: string) => { - updateActiveTab(value); + onTabChange(value as Tab); }} value={currentTab} > @@ -52,24 +49,20 @@ export function Editor({ deck, onOpenModal, validation }: Props) { @@ -80,7 +73,6 @@ export function Editor({ deck, onOpenModal, validation }: Props) { group={deck.groups.bonded.data} layout="two_column" mapping="bonded" - onOpenModal={onOpenModal} ownershipCounts={deck.ownershipCounts} quantities={deck.bondedSlots} /> @@ -92,11 +84,9 @@ export function Editor({ deck, onOpenModal, validation }: Props) { {deck.groups.side?.data ? ( @@ -111,11 +101,9 @@ export function Editor({ deck, onOpenModal, validation }: Props) { {deck.groups.extra?.data ? ( diff --git a/src/pages/deck-edit/editor/investigator-listcard.tsx b/src/pages/deck-edit/editor/investigator-listcard.tsx index fd6f47de..7a687738 100644 --- a/src/pages/deck-edit/editor/investigator-listcard.tsx +++ b/src/pages/deck-edit/editor/investigator-listcard.tsx @@ -34,6 +34,7 @@ function InvestigatorListcardInner({ deck }: Props) { ) => { if (evt.target instanceof HTMLSelectElement) { const value = Number.parseInt(evt.target.value, 10); - updateTabooId(Number.isNaN(value) ? null : value); + updateTabooId(deck.id, Number.isNaN(value) ? null : value); } }, - [updateTabooId], + [updateTabooId, deck.id], ); const onNameChange = useCallback( @@ -79,19 +79,19 @@ export function MetaEditor({ deck }: Props) { const onDescriptionChange = useCallback( (evt: React.ChangeEvent) => { if (evt.target instanceof HTMLTextAreaElement) { - updateDescription(evt.target.value); + updateDescription(deck.id, evt.target.value); } }, - [updateDescription], + [updateDescription, deck.id], ); const onTagsChange = useCallback( (evt: React.ChangeEvent) => { if (evt.target instanceof HTMLInputElement) { - updateTags(evt.target.value); + updateTags(deck.id, evt.target.value); } }, - [updateTags], + [updateTags, deck.id], ); const onFieldChange = useCallback( @@ -101,6 +101,7 @@ export function MetaEditor({ deck }: Props) { if (evt.target.dataset.field && evt.target.dataset.type) { updateMetaProperty( + deck.id, evt.target.dataset.field, value || null, evt.target.dataset.type as DeckOptionSelectType, @@ -108,7 +109,7 @@ export function MetaEditor({ deck }: Props) { } } }, - [updateMetaProperty], + [updateMetaProperty, deck.id], ); const onInvestigatorSideChange = useCallback( @@ -116,11 +117,11 @@ export function MetaEditor({ deck }: Props) { if (evt.target instanceof HTMLSelectElement) { const value = evt.target.value; if (evt.target.dataset.side) { - updateInvestigatorSide(evt.target.dataset.side, value); + updateInvestigatorSide(deck.id, evt.target.dataset.side, value); } } }, - [updateInvestigatorSide], + [updateInvestigatorSide, deck.id], ); return ( diff --git a/src/pages/deck-edit/show-unusable-cards-toggle.tsx b/src/pages/deck-edit/show-unusable-cards-toggle.tsx index fcad64f0..3965b706 100644 --- a/src/pages/deck-edit/show-unusable-cards-toggle.tsx +++ b/src/pages/deck-edit/show-unusable-cards-toggle.tsx @@ -1,29 +1,21 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Field } from "@/components/ui/field"; -import { useStore } from "@/store"; import css from "./deck-edit.module.css"; -export function ShowUnusableCardsToggle() { - const showUnusableCards = useStore( - (state) => state.deckView?.showUnusableCards ?? false, - ); - - const updateShowUnusableCards = useStore( - (state) => state.updateShowUnusableCards, - ); - - const handleValueChange = (val: boolean) => { - updateShowUnusableCards(val); - }; +type Props = { + checked: boolean; + onValueChange: (checked: boolean) => void; +}; +export function ShowUnusableCardsToggle({ checked, onValueChange }: Props) { return ( ); diff --git a/src/pages/deck-view/deck-view.tsx b/src/pages/deck-view/deck-view.tsx index c1988d74..2d074a8d 100644 --- a/src/pages/deck-view/deck-view.tsx +++ b/src/pages/deck-view/deck-view.tsx @@ -1,17 +1,18 @@ -import { useCallback } from "react"; import { Redirect, useParams } from "wouter"; -import { useCardModalContext } from "@/components/card-modal/card-modal-context"; +import { CardModalProvider } from "@/components/card-modal/card-modal-context"; import { DeckTags } from "@/components/deck-tags"; import { Decklist } from "@/components/decklist/decklist"; import { DecklistValidation } from "@/components/decklist/decklist-validation"; import { Dialog } from "@/components/ui/dialog"; import { AppLayout } from "@/layouts/app-layout"; import { useStore } from "@/store"; +import type { DisplayDeck } from "@/store/lib/deck-grouping"; import { selectActiveDeckById, selectDeckValidById, } from "@/store/selectors/deck-view"; +import { DeckIdProvider } from "@/utils/use-deck-id"; import css from "./deck-view.module.css"; @@ -19,20 +20,22 @@ import { DeckNotes } from "./deck-notes"; import { Sidebar } from "./sidebar/sidebar"; function DeckView() { - const cardModalContext = useCardModalContext(); const { id } = useParams<{ id: string }>(); - const deck = useStore((state) => selectActiveDeckById(state, id)); - const validation = useStore((state) => selectDeckValidById(state, id)); - const onOpenModal = useCallback( - (code: string) => { - cardModalContext.setOpen({ code, deckId: id }); - }, - [cardModalContext, id], + if (!deck) return ; + + return ( + + + + + ); +} - if (!deck) return ; +function DeckViewInner({ deck }: { deck: DisplayDeck }) { + const validation = useStore((state) => selectDeckValidById(state, deck.id)); return (
    - +
    {deck.description_md && ( diff --git a/src/store/index.ts b/src/store/index.ts index f958cbcb..c33afd90 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -4,7 +4,7 @@ import { devtools, persist } from "zustand/middleware"; import type { StoreState } from "./slices"; import { createDataSlice } from "./slices/data"; import { createDeckCreateSlice } from "./slices/deck-create"; -import { createDeckViewSlice } from "./slices/deck-view"; +import { createDeckEditsSlice } from "./slices/deck-edits"; import { createListsSlice } from "./slices/lists"; import { createLookupTablesSlice } from "./slices/lookup-tables"; import { createMetadataSlice } from "./slices/metadata"; @@ -22,7 +22,7 @@ export const stateCreator = (...args: [any, any, any]) => ({ ...createSettingsSlice(...args), ...createUISlice(...args), ...createSharedSlice(...args), - ...createDeckViewSlice(...args), + ...createDeckEditsSlice(...args), ...createDeckCreateSlice(...args), }); diff --git a/src/store/lib/deck-edits.ts b/src/store/lib/deck-edits.ts index 95bd712f..5ddbbb19 100644 --- a/src/store/lib/deck-edits.ts +++ b/src/store/lib/deck-edits.ts @@ -1,7 +1,7 @@ import { isEmpty } from "@/utils/is-empty"; import type { Deck, Slots } from "../slices/data.types"; -import type { DeckViewState, EditState, Slot } from "../slices/deck-view.types"; +import type { EditState, Slot } from "../slices/deck-edits.types"; import type { Metadata } from "../slices/metadata.types"; import { decodeCustomizations, @@ -21,43 +21,42 @@ import type { DeckMeta } from "./types"; */ export function applyDeckEdits( originalDeck: Deck, - deckView: DeckViewState, + edits: EditState | undefined, metadata: Metadata, alwaysDeleteEmpty = false, ) { + if (!edits) return originalDeck; + const deck = structuredClone(originalDeck); - if (deckView.edits.name != undefined) { - deck.name = deckView.edits.name; + if (edits.name != undefined) { + deck.name = edits.name; } - if (deckView.edits.description_md != undefined) { - deck.description_md = deckView.edits.description_md; + if (edits.description_md != undefined) { + deck.description_md = edits.description_md; } - if (deckView.edits.tags != undefined) { - deck.tags = deckView.edits.tags; + if (edits.tags != undefined) { + deck.tags = edits.tags; } // adjust taboo id based on deck edits. - if (deckView.edits.tabooId !== undefined) { - deck.taboo_id = deckView.edits.tabooId; + if (edits.tabooId !== undefined) { + deck.taboo_id = edits.tabooId; } // adjust meta based on deck edits. const deckMeta = decodeDeckMeta(deck); // adjust customizations based on deck edits. - Object.assign( - deckMeta, - mergeCustomizationEdits(deckView, deckMeta, metadata), - ); + Object.assign(deckMeta, mergeCustomizationEdits(edits, deckMeta, metadata)); const extraSlots = decodeExtraSlots(deckMeta); // adjust quantities based on deck edits. - for (const [key, edits] of Object.entries(deckView.edits.quantities)) { - for (const [code, value] of Object.entries(edits)) { + for (const [key, quantityEdits] of Object.entries(edits.quantities)) { + for (const [code, value] of Object.entries(quantityEdits)) { const slotKey = key as Slot; if (slotKey === "extraSlots") { @@ -100,18 +99,18 @@ export function applyDeckEdits( deck.meta = JSON.stringify({ ...deckMeta, - ...deckView.edits.meta, + ...edits.meta, extra_deck: encodeExtraSlots(extraSlots), alternate_back: applyInvestigatorSide( deck, deckMeta, - deckView, + edits, "investigatorBack", ), alternate_front: applyInvestigatorSide( deck, deckMeta, - deckView, + edits, "investigatorFront", ), }); @@ -122,10 +121,10 @@ export function applyDeckEdits( function applyInvestigatorSide( deck: Deck, deckMeta: DeckMeta, - deckView: EditState, + edits: EditState, key: "investigatorFront" | "investigatorBack", ) { - const current = deckView.edits[key]; + const current = edits[key]; if (!current) { const deckMetaKey = @@ -140,17 +139,17 @@ function applyInvestigatorSide( * Merges stored customizations in a deck with edits, returning a deck.meta JSON block. */ export function mergeCustomizationEdits( - state: EditState, + edits: EditState, deckMeta: DeckMeta, metadata: Metadata, ) { - if (isEmpty(state.edits.customizations)) { + if (isEmpty(edits.customizations)) { return {}; } const customizations = decodeCustomizations(deckMeta, metadata) ?? {}; - for (const [code, changes] of Object.entries(state.edits.customizations)) { + for (const [code, changes] of Object.entries(edits.customizations)) { customizations[code] ??= {}; for (const [id, change] of Object.entries(changes)) { diff --git a/src/store/lib/filtering.ts b/src/store/lib/filtering.ts index 957a1bdf..dac7e821 100644 --- a/src/store/lib/filtering.ts +++ b/src/store/lib/filtering.ts @@ -744,6 +744,7 @@ export function filterInvestigatorAccess( if (mode === "extraSlots") return extraDeckFilter; const filters = []; + if (deckFilter) filters.push(deckFilter); if (extraDeckFilter) filters.push(extraDeckFilter); return or(filters); @@ -818,15 +819,19 @@ function makePlayerCardsFilter( export function filterInvestigatorWeaknessAccess( card: Card, lookupTables: LookupTables, + config?: Pick, ) { // normalize parallel investigators to root for lookups. const code = card.alternate_of_code ?? card.code; - const ors: Filter[] = [ - filterRequired(code, lookupTables.relations), - filterSubtypes(["basicweakness"]), - (card: Card) => card.xp == null && !card.restrictions, - ]; + const ors: Filter[] = + config?.targetDeck !== "extraSlots" + ? [ + filterRequired(code, lookupTables.relations), + filterSubtypes(["basicweakness"]), + (card: Card) => card.xp == null && !card.restrictions, + ] + : [(c: Card) => !!card.side_deck_requirements?.card?.[c.code]]; return and([ filterSubtypes(["basicweakness", "weakness"]), diff --git a/src/store/selectors/card-list.ts b/src/store/selectors/card-list.ts index 55ba4dbf..7f30a4ab 100644 --- a/src/store/selectors/card-list.ts +++ b/src/store/selectors/card-list.ts @@ -31,6 +31,7 @@ import { applySearch } from "../lib/searching"; import { makeSortFunction } from "../lib/sorting"; import type { Card } from "../services/queries.types"; import type { StoreState } from "../slices"; +import type { Id } from "../slices/data.types"; import type { AssetFilter, CostFilter, @@ -44,7 +45,7 @@ import type { import type { LookupTables } from "../slices/lookup-tables.types"; import type { Metadata } from "../slices/metadata.types"; import type { SettingsState } from "../slices/settings.types"; -import { selectResolvedDeck } from "./decks"; +import { selectResolvedDeckById } from "./deck-view"; import { selectActiveList, selectCanonicalTabooSetId } from "./lists"; export type CardGroup = { @@ -65,6 +66,7 @@ function makeUserFilter( list: List, settings: SettingsState, deckInvestigatorFilter?: Filter, + targetDeck?: "slots" | "extraSlots" | "both", ) { const filters: Filter[] = []; @@ -118,10 +120,12 @@ function makeUserFilter( const accessFilter = filterInvestigatorAccess( metadata.cards[value], lookupTables, + { targetDeck }, ); const weaknessFilter = filterInvestigatorWeaknessAccess( metadata.cards[value], lookupTables, + { targetDeck }, ); if (accessFilter) filter.push(accessFilter); @@ -213,10 +217,14 @@ function makeUserFilter( // only when the investigator back changes or certain slots are changed. export const selectDeckInvestigatorFilter = createSelector( (state: StoreState) => state.lookupTables, - selectResolvedDeck, - (state: StoreState) => - state.deckView?.activeTab === "extraSlots" ? "extraSlots" : "slots", - (state: StoreState) => !!state.deckView?.showUnusableCards, + selectResolvedDeckById, + ( + state: StoreState, + id?: Id, + applyEdits?: boolean, + targetDeck?: "slots" | "extraSlots" | "both", + ) => targetDeck, + () => false, // FIXME (lookupTables, resolvedDeck, targetDeck, showUnusableCards) => { if (!resolvedDeck) return undefined; @@ -250,6 +258,9 @@ export const selectDeckInvestigatorFilter = createSelector( const weaknessFilter = filterInvestigatorWeaknessAccess( investigator, lookupTables, + { + targetDeck, + }, ); if (investigatorFilter) ors.push(investigatorFilter); @@ -264,9 +275,15 @@ export const selectListCards = createSelector( (state: StoreState) => state.lookupTables, (state: StoreState) => state.settings, selectActiveList, - selectResolvedDeck, + selectResolvedDeckById, selectCanonicalTabooSetId, selectDeckInvestigatorFilter, + ( + state: StoreState, + id?: Id, + applyEdits?: boolean, + targetDeck?: "slots" | "extraSlots" | "both", + ) => targetDeck, ( metadata, lookupTables, diff --git a/src/store/selectors/card-view.ts b/src/store/selectors/card-view.ts index 2e05933a..3ec19c8c 100644 --- a/src/store/selectors/card-view.ts +++ b/src/store/selectors/card-view.ts @@ -1,20 +1,23 @@ import { resolveCardWithRelations } from "../lib/resolve-card"; import type { CardWithRelations, ResolvedCard } from "../lib/types"; import type { StoreState } from "../slices"; -import { selectActiveDeck } from "./decks"; +import type { Id } from "../slices/data.types"; +import { selectResolvedDeckById } from "./deck-view"; import { selectCanonicalTabooSetId } from "./lists"; export function selectCardWithRelations( state: StoreState, code: string | undefined, withRelations: T, + deckId: Id | undefined, + applyEdits?: boolean, ): T extends true ? undefined | CardWithRelations : undefined | ResolvedCard { return resolveCardWithRelations( state.metadata, state.lookupTables, code, - selectCanonicalTabooSetId(state), - selectActiveDeck(state)?.customizations, + selectCanonicalTabooSetId(state, deckId, applyEdits), + selectResolvedDeckById(state, deckId, applyEdits)?.customizations, withRelations, ); } diff --git a/src/store/selectors/deck-view.ts b/src/store/selectors/deck-view.ts index b9d0fb22..11572887 100644 --- a/src/store/selectors/deck-view.ts +++ b/src/store/selectors/deck-view.ts @@ -1,22 +1,35 @@ import { createSelector } from "reselect"; +import { SPECIAL_CARD_CODES } from "@/utils/constants"; + +import { applyDeckEdits } from "../lib/deck-edits"; import type { DisplayDeck } from "../lib/deck-grouping"; import { groupDeckCardsByType } from "../lib/deck-grouping"; +import type { ForbiddenCardError } from "../lib/deck-validation"; import { validateDeck } from "../lib/deck-validation"; import { resolveDeck } from "../lib/resolve-deck"; +import type { Card } from "../services/queries.types"; import type { StoreState } from "../slices"; +import type { Id } from "../slices/data.types"; export const selectResolvedDeckById = createSelector( (state: StoreState) => state.metadata, (state: StoreState) => state.lookupTables, - (state: StoreState) => state.data.decks, - (_: StoreState, id: string) => id, - (metadata, lookupTables, decks, id) => { - const deck = decks[id]; + (state: StoreState, deckId?: Id) => + deckId ? state.data.decks[deckId] : undefined, + (state: StoreState, deckId?: Id, applyEdits?: boolean) => + deckId && applyEdits ? state.deckEdits?.[deckId] : undefined, + (metadata, lookupTables, deck, edits) => { if (!deck) return undefined; console.time("[perf] select_resolved_deck"); - const resolvedDeck = resolveDeck(metadata, lookupTables, deck, true); + + const resolvedDeck = resolveDeck( + metadata, + lookupTables, + edits ? applyDeckEdits(deck, edits, metadata) : deck, + true, + ); console.timeEnd("[perf] select_resolved_deck"); return resolvedDeck; @@ -24,7 +37,8 @@ export const selectResolvedDeckById = createSelector( ); export const selectActiveDeckById = createSelector( - (state: StoreState, id: string) => selectResolvedDeckById(state, id), + (state: StoreState, id: Id, applyEdits?: boolean) => + selectResolvedDeckById(state, id, applyEdits), (state: StoreState) => state.metadata, (state: StoreState) => state.lookupTables, (state: StoreState) => state.settings.collection, @@ -57,7 +71,8 @@ export const selectActiveDeckById = createSelector( ); export const selectDeckValidById = createSelector( - (state: StoreState, id: string) => selectResolvedDeckById(state, id), + (state: StoreState, id: Id, applyDeckEdits?: boolean) => + selectResolvedDeckById(state, id, applyDeckEdits), (state: StoreState) => state.lookupTables, (state: StoreState) => state.metadata, (deck, lookupTables, metadata) => { @@ -66,3 +81,36 @@ export const selectDeckValidById = createSelector( : { valid: false, errors: [] }; }, ); + +export const selectForbiddenCardsById = createSelector( + selectDeckValidById, + (deckValidation) => { + const forbidden = deckValidation.errors.find((x) => x.type === "FORBIDDEN"); + if (!forbidden) return []; + return (forbidden as ForbiddenCardError).details; + }, +); + +export const selectShowIgnoreDeckLimitSlotsById = createSelector( + selectActiveDeckById, + (_: StoreState, __: Id, ___: boolean | undefined, card: Card) => card, + (deck, card) => { + if (!deck) return false; + + const traits = card.real_traits ?? ""; + const investigator = deck.investigatorBack.card.code; + + return ( + // cards that are already ignored. + !!deck.ignoreDeckLimitSlots?.[card.code] || + // parallel agnes & spells + (investigator === SPECIAL_CARD_CODES.PARALLEL_AGNES && + traits.includes("Spell")) || + // parallel skids & gambit / fortune + (investigator === SPECIAL_CARD_CODES.PARALLEL_SKIDS && + (traits.includes("Gambit") || traits.includes("Fortunes"))) || + // ace of rods + card.code === SPECIAL_CARD_CODES.ACE_OF_RODS + ); + }, +); diff --git a/src/store/selectors/decks.ts b/src/store/selectors/decks.ts index fb2077e0..af9deb75 100644 --- a/src/store/selectors/decks.ts +++ b/src/store/selectors/decks.ts @@ -1,21 +1,12 @@ import { createSelector } from "reselect"; -import type { DisplayDeck } from "@/store/lib/deck-grouping"; -import { groupDeckCardsByType } from "@/store/lib/deck-grouping"; import { resolveDeck } from "@/store/lib/resolve-deck"; -import { SPECIAL_CARD_CODES } from "@/utils/constants"; -import { applyDeckEdits } from "../lib/deck-edits"; -import type { - DeckValidationResult, - ForbiddenCardError, -} from "../lib/deck-validation"; +import type { DeckValidationResult } from "../lib/deck-validation"; import { validateDeck } from "../lib/deck-validation"; import { sortAlphabetical } from "../lib/sorting"; import type { ResolvedCard, ResolvedDeck } from "../lib/types"; -import type { Card } from "../services/queries.types"; import type { StoreState } from "../slices"; -import { type Slot, mapTabToSlot } from "../slices/deck-view.types"; type LocalDeck = { deck: ResolvedDeck; @@ -64,121 +55,3 @@ export const selectLocalDecks = createSelector( return resolvedDecks; }, ); - -export const selectResolvedDeck = createSelector( - (state: StoreState) => state.metadata, - (state: StoreState) => state.lookupTables, - (state: StoreState) => state.data.decks, - (state: StoreState) => state.deckView, - (metadata, lookupTables, decks, deckView) => { - if (!deckView || !decks[deckView.id]) return undefined; - - console.time("[perf] select_resolved_deck"); - - const deck = applyDeckEdits(decks[deckView.id], deckView, metadata); - const resolvedDeck = resolveDeck(metadata, lookupTables, deck, true); - - console.timeEnd("[perf] select_resolved_deck"); - return resolvedDeck; - }, -); - -export const selectActiveDeck = createSelector( - selectResolvedDeck, - (state: StoreState) => state.metadata, - (state: StoreState) => state.lookupTables, - (state: StoreState) => state.settings.collection, - (state: StoreState) => state.settings.showAllCards, - (resolvedDeck, metadata, lookupTables, collectionSetting, showAll) => { - if (!resolvedDeck) return undefined; - - const displayDeck = resolvedDeck as DisplayDeck; - const { groupings, bonded, ownershipCounts } = groupDeckCardsByType( - resolvedDeck, - metadata, - lookupTables, - collectionSetting, - showAll, - ); - - displayDeck.ownershipCounts = ownershipCounts; - displayDeck.groups = groupings; - - displayDeck.bondedSlots = bonded.reduce>( - (acc, curr) => { - acc[curr.code] = curr.quantity; - return acc; - }, - {}, - ); - - return displayDeck; - }, -); - -export const selectDeckValid = createSelector( - selectResolvedDeck, - (state: StoreState) => state.lookupTables, - (state: StoreState) => state.metadata, - (deck, lookupTables, metadata) => - deck - ? validateDeck(deck, { lookupTables, metadata } as StoreState) - : { valid: false, errors: [] }, -); - -export const selectForbiddenCards = createSelector( - selectDeckValid, - (deckValidation) => { - const forbidden = deckValidation.errors.find((x) => x.type === "FORBIDDEN"); - if (!forbidden) return []; - return (forbidden as ForbiddenCardError).details; - }, -); - -export function selectCardQuantitiesForSlot( - state: StoreState, - slot: Slot | "bondedSlots", -) { - if (!state.deckView) return undefined; - - const activeDeck = selectActiveDeck(state); - if (!activeDeck) return undefined; - - return activeDeck[slot]; -} - -export function selectCardQuantities(state: StoreState) { - if (!state.deckView) return undefined; - - const slot = mapTabToSlot(state.deckView.activeTab); - return selectCardQuantitiesForSlot(state, slot) ?? undefined; -} - -export const selectCurrentTab = (state: StoreState) => { - if (!state.deckView) return "slots"; - return state.deckView.activeTab; -}; - -export const selectShowIgnoreDeckLimitSlots = ( - state: StoreState, - card: Card, -) => { - const activeDeck = selectActiveDeck(state); - if (!activeDeck) return false; - - const traits = card.real_traits ?? ""; - const investigator = activeDeck.investigatorBack.card.code; - - return ( - // cards that are already ignored. - !!activeDeck.ignoreDeckLimitSlots?.[card.code] || - // parallel agnes & spells - (investigator === SPECIAL_CARD_CODES.PARALLEL_AGNES && - traits.includes("Spell")) || - // parallel skids & gambit / fortune - (investigator === SPECIAL_CARD_CODES.PARALLEL_SKIDS && - (traits.includes("Gambit") || traits.includes("Fortunes"))) || - // ace of rods - card.code === SPECIAL_CARD_CODES.ACE_OF_RODS - ); -}; diff --git a/src/store/selectors/lists.ts b/src/store/selectors/lists.ts index 56ae990d..363a1aeb 100644 --- a/src/store/selectors/lists.ts +++ b/src/store/selectors/lists.ts @@ -10,6 +10,7 @@ import { } from "../lib/sorting"; import type { Card, Cycle, Pack } from "../services/queries.types"; import type { StoreState } from "../slices"; +import type { Id } from "../slices/data.types"; import type { AssetFilter, CostFilter, @@ -19,7 +20,7 @@ import type { SelectFilter, SkillIconsFilter, } from "../slices/lists.types"; -import { selectActiveDeck } from "./decks"; +import { selectResolvedDeckById } from "./deck-view"; export const selectActiveList = (state: StoreState) => { const active = state.activeList; @@ -402,7 +403,11 @@ export const selectTabooSetChanges = createSelector( }, ); -export const selectCanonicalTabooSetId = (state: StoreState) => { +export const selectCanonicalTabooSetId = ( + state: StoreState, + deckId?: Id, + applyEdits?: boolean, +) => { const filters = selectActiveListFilters(state); const filterId = filters.findIndex((f) => f === "tabooSet"); @@ -411,8 +416,10 @@ export const selectCanonicalTabooSetId = (state: StoreState) => { : undefined; if (typeof filterValue === "number") return filterValue; - const activeDeck = selectActiveDeck(state); - if (activeDeck) return activeDeck.taboo_id; + if (deckId) { + const resolvedDeck = selectResolvedDeckById(state, deckId, applyEdits); + if (resolvedDeck) return resolvedDeck.tabooSet?.id; + } return state.settings.tabooSetId; }; diff --git a/src/store/selectors/shared.ts b/src/store/selectors/shared.ts index 7490aad7..aaf7006e 100644 --- a/src/store/selectors/shared.ts +++ b/src/store/selectors/shared.ts @@ -25,11 +25,3 @@ export const selectCardOwnedCount = createSelector( }; }, ); - -export const selectNeedsConfirmation = (state: StoreState) => { - if (state.deckView?.dirty) { - return "This operation will revert the changes made to the deck. Do you want to continue?"; - } - - return undefined; -}; diff --git a/src/store/slices/data.types.ts b/src/store/slices/data.types.ts index 8e92d7a4..5e0fa5aa 100644 --- a/src/store/slices/data.types.ts +++ b/src/store/slices/data.types.ts @@ -2,7 +2,7 @@ export type Slots = { [code: string]: number; }; -type Id = number | string; +export type Id = number | string; export type Deck = { id: Id; diff --git a/src/store/slices/deck-edits.spec.ts b/src/store/slices/deck-edits.spec.ts new file mode 100644 index 00000000..07360c39 --- /dev/null +++ b/src/store/slices/deck-edits.spec.ts @@ -0,0 +1,88 @@ +import { afterEach } from "node:test"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { StoreApi } from "zustand"; + +import deckExtraSlots from "@/test/fixtures/decks/extra_slots.json"; +import { getMockStore } from "@/test/get-mock-store"; + +import type { StoreState } from "."; +import { selectResolvedDeckById } from "../selectors/deck-view"; + +describe("deck-view slice", () => { + let store: StoreApi; + + beforeAll(async () => { + store = await getMockStore(); + }); + + describe("updateCardQuantity", () => { + beforeEach(() => { + store.setState({ + data: { + decks: { + "deck-id": deckExtraSlots, + }, + history: { + "deck-id": [], + }, + }, + }); + }); + + afterEach(async () => { + store = await getMockStore(); + }); + + it("increments the quantity of a card", () => { + const state = store.getState(); + state.updateCardQuantity("deck-id", "01000", 1, "slots", "increment"); + expect( + selectResolvedDeckById(store.getState(), "deck-id")?.slots["01000"], + ).toEqual(2); + }); + + it("decrements the quantity of a card", () => { + const state = store.getState(); + state.updateCardQuantity("deck-id", "01000", -1, "slots", "increment"); + expect( + selectResolvedDeckById(store.getState(), "deck-id")?.slots["01000"], + ).toEqual(0); + }); + + it("sets the quantity of a card", () => { + const state = store.getState(); + state.updateCardQuantity("deck-id", "01000", 5, "slots", "set"); + expect( + selectResolvedDeckById(store.getState(), "deck-id")?.slots["01000"], + ).toEqual(5); + }); + + it("does not set the quantity of a card to a negative value", () => { + const state = store.getState(); + state.updateCardQuantity("deck-id", "01000", -5, "slots", "set"); + state.updateCardQuantity("deck-id", "01000", -5, "slots", "increment"); + expect( + selectResolvedDeckById(store.getState(), "deck-id")?.slots["01000"], + ).toEqual(0); + }); + + it("does not set the quantity of a card exceeding the limit", () => { + const state = store.getState(); + state.updateCardQuantity("deck-id", "06021", 5, "slots", "set"); + state.updateCardQuantity("deck-id", "06021", 5, "slots", "increment"); + expect( + selectResolvedDeckById(store.getState(), "deck-id")?.slots["06021"], + ).toEqual(3); + }); + + it("adjusts cards in side slots", () => { + const state = store.getState(); + state.updateCardQuantity("deck-id", "06021", 1, "sideSlots", "increment"); + expect( + selectResolvedDeckById(store.getState(), "deck-id")?.sideSlots?.[ + "06021" + ], + ).toEqual(1); + }); + }); +}); diff --git a/src/store/slices/deck-edits.ts b/src/store/slices/deck-edits.ts new file mode 100644 index 00000000..d793531b --- /dev/null +++ b/src/store/slices/deck-edits.ts @@ -0,0 +1,192 @@ +import type { StateCreator } from "zustand"; + +import { capitalize } from "@/utils/formatting"; + +import type { StoreState } from "."; +import { resolveDeck } from "../lib/resolve-deck"; +import type { Id } from "./data.types"; +import { type DeckEditsSlice, mapTabToSlot } from "./deck-edits.types"; + +function currentEdits(state: StoreState, deckId: Id) { + return ( + state.deckEdits[deckId] ?? { + quantities: {}, + meta: {}, + customizations: {}, + } + ); +} + +export const createDeckEditsSlice: StateCreator< + StoreState, + [], + [], + DeckEditsSlice +> = (set, get) => ({ + deckEdits: {}, + + discardEdits(deckId) { + const state = get(); + const deckEdits = { ...state.deckEdits }; + delete deckEdits[deckId]; + set({ deckEdits }); + }, + + updateCardQuantity(deckId, code, quantity, tab, mode = "increment") { + const state = get(); + + const edits = currentEdits(state, deckId); + + const targetTab = tab || "slots"; + const slot = mapTabToSlot(targetTab); + + const card = state.metadata.cards[code]; + const limit = card.deck_limit ?? card.quantity; + + const slotEdits = edits.quantities[slot]; + + const deck = resolveDeck( + state.metadata, + state.lookupTables, + state.data.decks[deckId], + false, + ); + const slots = deck[slot] ?? {}; + + const value = slotEdits?.[code] ?? slots?.[code] ?? 0; + + const newValue = + mode === "increment" + ? Math.max(value + quantity, 0) + : Math.max(Math.min(quantity, limit), 0); + + if (mode === "increment" && value + quantity > limit) return; + + set({ + deckEdits: { + ...state.deckEdits, + [deckId]: { + ...edits, + quantities: { + ...edits.quantities, + [slot]: { + ...edits.quantities[slot], + [code]: newValue, + }, + }, + }, + }, + }); + }, + + updateTabooId(deckId, value) { + const state = get(); + + set({ + deckEdits: { + ...state.deckEdits, + [deckId]: { + ...currentEdits(state, deckId), + tabooId: value, + }, + }, + }); + }, + updateDescription(deckId, value) { + const state = get(); + + set({ + deckEdits: { + ...state.deckEdits, + [deckId]: { + ...currentEdits(state, deckId), + description_md: value, + }, + }, + }); + }, + updateName(deckId, value) { + const state = get(); + + set({ + deckEdits: { + ...state.deckEdits, + [deckId]: { + ...currentEdits(state, deckId), + name: value, + }, + }, + }); + }, + updateMetaProperty(deckId, key, value) { + const state = get(); + const edits = currentEdits(state, deckId); + + set({ + deckEdits: { + ...state.deckEdits, + [deckId]: { + ...edits, + meta: { + ...edits.meta, + [key]: value, + }, + }, + }, + }); + }, + + updateInvestigatorSide(deckId, side, code) { + const state = get(); + const edits = currentEdits(state, deckId); + + set({ + deckEdits: { + ...state.deckEdits, + [deckId]: { + ...edits, + [`investigator${capitalize(side)}`]: code, + }, + }, + }); + }, + + updateCustomization(deckId, code, index, patch) { + const state = get(); + const edits = currentEdits(state, deckId); + + set({ + deckEdits: { + ...state.deckEdits, + [deckId]: { + ...edits, + customizations: { + ...edits.customizations, + [code]: { + ...edits.customizations?.[code], + [index]: { + ...edits.customizations?.[code]?.[index], + ...patch, + }, + }, + }, + }, + }, + }); + }, + + updateTags(deckId, value) { + const state = get(); + const edits = currentEdits(state, deckId); + + set({ + deckEdits: { + ...state.deckEdits, + [deckId]: { + ...edits, + tags: value, + }, + }, + }); + }, +}); diff --git a/src/store/slices/deck-view.types.ts b/src/store/slices/deck-edits.types.ts similarity index 53% rename from src/store/slices/deck-view.types.ts rename to src/store/slices/deck-edits.types.ts index 25df5e29..5e22f6d1 100644 --- a/src/store/slices/deck-view.types.ts +++ b/src/store/slices/deck-edits.types.ts @@ -1,5 +1,7 @@ import type { DeckOptionSelectType } from "@/store/services/queries.types"; +import type { Id } from "./data.types"; + export type Slot = | "slots" | "sideSlots" @@ -40,72 +42,66 @@ export type CustomizationEdit = { }; export type EditState = { - edits: { - customizations: { - [code: string]: { - [id: number]: CustomizationEdit; - }; - }; - meta: { - [key: string]: string | null; + customizations: { + [code: string]: { + [id: number]: CustomizationEdit; }; - quantities: { - extraSlots?: Record; - ignoreDeckLimitSlots?: Record; - sideSlots?: Record; - slots?: Record; - }; - name?: string | null; - description_md?: string | null; - tags?: string | null; - tabooId?: number | null; - investigatorFront?: string | null; - investigatorBack?: string | null; }; - dirty: boolean; - activeTab: Tab; - showUnusableCards: boolean; + meta: { + [key: string]: string | null; + }; + quantities: { + extraSlots?: Record; + ignoreDeckLimitSlots?: Record; + sideSlots?: Record; + slots?: Record; + }; + name?: string | null; + description_md?: string | null; + tags?: string | null; + tabooId?: number | null; + investigatorFront?: string | null; + investigatorBack?: string | null; }; -export type DeckViewState = { - id: string; -} & EditState; - -export type DeckViewSlice = { - deckView: DeckViewState | null; +export type EditsState = { + [id: Id]: EditState; +}; - setActiveDeck(id?: string): void; +export type DeckEditsSlice = { + deckEdits: EditsState; - updateActiveTab(value: string): void; + discardEdits(deckId: Id): void; updateCardQuantity( + deckId: Id, code: string, quantity: number, slot?: Slot, mode?: "increment" | "set", ): void; - updateTabooId(value: number | null): void; + updateTabooId(deckId: Id, value: number | null): void; - updateInvestigatorSide(side: string, code: string): void; + updateInvestigatorSide(deckId: Id, side: string, code: string): void; updateCustomization( + deckId: Id, code: string, index: number, edit: CustomizationEdit, ): void; updateMetaProperty( + deckId: Id, key: string, value: string | null, type: DeckOptionSelectType, ): void; - updateName(value: string): void; - - updateDescription(value: string): void; + updateName(deckId: Id, value: string): void; - updateTags(value: string): void; + updateDescription(deckId: Id, value: string): void; - updateShowUnusableCards(value: boolean): void; + updateTags(deckId: Id, value: string): void; }; diff --git a/src/store/slices/deck-view.spec.ts b/src/store/slices/deck-view.spec.ts deleted file mode 100644 index 7809bf9b..00000000 --- a/src/store/slices/deck-view.spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { afterEach } from "node:test"; -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; -import type { StoreApi } from "zustand"; - -import deckExtraSlots from "@/test/fixtures/decks/extra_slots.json"; -import { getMockStore } from "@/test/get-mock-store"; - -import type { StoreState } from "."; -import { selectActiveDeck } from "../selectors/decks"; - -describe("deck-view slice", () => { - let store: StoreApi; - - beforeAll(async () => { - store = await getMockStore(); - }); - - describe("updateCardQuantity", () => { - beforeEach(() => { - store.setState({ - data: { - decks: { - "deck-id": deckExtraSlots, - }, - history: { - "deck-id": [], - }, - }, - }); - }); - - afterEach(async () => { - store = await getMockStore(); - }); - - it("throws an error if there is no active deck", () => { - store.setState({ - deckView: null, - }); - - expect(() => { - store.getState().updateCardQuantity("01000", 1, "slots"); - }).toThrowErrorMatchingInlineSnapshot( - `[Error: assertion failed: trying to edit deck, but state does not have an active deck.]`, - ); - }); - - it("throws an error if the active deck does not exist", () => { - store.getState().setActiveDeck("non-existent-deck"); - - expect(() => { - store.getState().updateCardQuantity("01000", 1, "slots"); - }).toThrowErrorMatchingInlineSnapshot( - `[Error: assertion failed: trying to edit deck, but deck does not exist.]`, - ); - }); - - it("increments the quantity of a card", () => { - const state = store.getState(); - state.setActiveDeck("deck-id"); - state.updateCardQuantity("01000", 1, "slots", "increment"); - expect(selectActiveDeck(store.getState())?.slots["01000"]).toEqual(2); - }); - - it("decrements the quantity of a card", () => { - const state = store.getState(); - state.setActiveDeck("deck-id"); - state.updateCardQuantity("01000", -1, "slots", "increment"); - expect(selectActiveDeck(store.getState())?.slots["01000"]).toEqual(0); - }); - - it("sets the quantity of a card", () => { - const state = store.getState(); - state.setActiveDeck("deck-id"); - state.updateCardQuantity("01000", 5, "slots", "set"); - expect(selectActiveDeck(store.getState())?.slots["01000"]).toEqual(5); - }); - - it("does not set the quantity of a card to a negative value", () => { - const state = store.getState(); - state.setActiveDeck("deck-id"); - state.updateCardQuantity("01000", -5, "slots", "set"); - state.updateCardQuantity("01000", -5, "slots", "increment"); - expect(selectActiveDeck(store.getState())?.slots["01000"]).toEqual(0); - }); - - it("does not set the quantity of a card exceeding the limit", () => { - const state = store.getState(); - state.setActiveDeck("deck-id"); - state.updateCardQuantity("06021", 5, "slots", "set"); - state.updateCardQuantity("06021", 5, "slots", "increment"); - expect(selectActiveDeck(store.getState())?.slots["06021"]).toEqual(3); - }); - - it("adjusts cards in side slots", () => { - const state = store.getState(); - state.setActiveDeck("deck-id"); - state.updateCardQuantity("06021", 1, "sideSlots", "increment"); - expect(selectActiveDeck(store.getState())?.sideSlots?.["06021"]).toEqual( - 1, - ); - }); - }); -}); diff --git a/src/store/slices/deck-view.ts b/src/store/slices/deck-view.ts deleted file mode 100644 index 2c3f9f04..00000000 --- a/src/store/slices/deck-view.ts +++ /dev/null @@ -1,248 +0,0 @@ -import type { StateCreator } from "zustand"; - -import { assert } from "@/utils/assert"; - -import type { StoreState } from "."; -import { resolveDeck } from "../lib/resolve-deck"; -import type { EditState } from "./deck-view.types"; -import { type DeckViewSlice, isTab, mapTabToSlot } from "./deck-view.types"; - -export const createDeckViewSlice: StateCreator< - StoreState, - [], - [], - DeckViewSlice -> = (set, get) => ({ - deckView: null, - - setActiveDeck(activeDeckId) { - if (!activeDeckId) { - set({ deckView: null }); - return; - } - set({ - deckView: { - activeTab: "slots", - showUnusableCards: false, - id: activeDeckId, - dirty: false, - edits: { - meta: {}, - quantities: {}, - customizations: {}, - }, - }, - }); - }, - - updateActiveTab(value) { - const state = get(); - - assertInEditMode(state); - assert(isTab(value), `invalid tab value: ${value}`); - - set({ - deckView: { - ...state.deckView, - activeTab: value, - }, - }); - }, - - updateCardQuantity(code, quantity, tab, mode = "increment") { - const state = get(); - assertInEditMode(state); - - const targetTab = tab || state.deckView.activeTab || "slots"; - - const slot = mapTabToSlot(targetTab); - - const card = state.metadata.cards[code]; - const limit = card.deck_limit ?? card.quantity; - - const slotEdits = state.deckView.edits.quantities[slot]; - - const deck = resolveDeck( - state.metadata, - state.lookupTables, - state.data.decks[state.deckView.id], - false, - ); - const slots = deck[slot] ?? {}; - - const value = slotEdits?.[code] ?? slots?.[code] ?? 0; - - const newValue = - mode === "increment" - ? Math.max(value + quantity, 0) - : Math.max(Math.min(quantity, limit), 0); - - if (mode === "increment" && value + quantity > limit) return; - - set({ - deckView: { - ...state.deckView, - dirty: true, - edits: { - ...state.deckView.edits, - quantities: { - ...state.deckView.edits.quantities, - [slot]: { - ...state.deckView.edits.quantities[slot], - [code]: newValue, - }, - }, - }, - }, - }); - }, - - updateTabooId(value) { - const state = get(); - assertInEditMode(state); - - set({ - deckView: { - ...state.deckView, - dirty: true, - edits: { - ...state.deckView.edits, - tabooId: value, - }, - }, - }); - }, - updateDescription(value) { - const state = get(); - assertInEditMode(state); - - set({ - deckView: { - ...state.deckView, - dirty: true, - edits: { - ...state.deckView.edits, - description_md: value, - }, - }, - }); - }, - updateName(value) { - const state = get(); - assertInEditMode(state); - - set({ - deckView: { - ...state.deckView, - dirty: true, - edits: { - ...state.deckView.edits, - name: value, - }, - }, - }); - }, - updateMetaProperty(key, value) { - const state = get(); - assertInEditMode(state); - - set({ - deckView: { - ...state.deckView, - dirty: true, - edits: { - ...state.deckView.edits, - meta: { - ...state.deckView.edits.meta, - [key]: value, - }, - }, - }, - }); - }, - - updateInvestigatorSide(side, code) { - const state = get(); - assertInEditMode(state); - - set({ - deckView: { - ...state.deckView, - dirty: true, - edits: { - ...state.deckView.edits, - investigatorBack: - side === "back" ? code : state.deckView.edits.investigatorBack, - investigatorFront: - side === "front" ? code : state.deckView.edits.investigatorFront, - }, - }, - }); - }, - - updateCustomization(code, index, edit) { - const state = get(); - assertInEditMode(state); - - set({ - deckView: { - ...state.deckView, - dirty: true, - edits: { - ...state.deckView.edits, - customizations: { - ...state.deckView.edits.customizations, - [code]: { - ...state.deckView.edits.customizations[code], - [index]: { - ...state.deckView.edits.customizations[code]?.[index], - ...edit, - }, - }, - }, - }, - }, - }); - }, - - updateTags(value) { - const state = get(); - assertInEditMode(state); - set({ - deckView: { - ...state.deckView, - dirty: true, - edits: { - ...state.deckView.edits, - tags: value, - }, - }, - }); - }, - - updateShowUnusableCards(showUnusableCards) { - const state = get(); - assertInEditMode(state); - - set({ - deckView: { - ...state.deckView, - showUnusableCards, - }, - }); - }, -}); - -function assertInEditMode(state: StoreState): asserts state is StoreState & { - deckView: EditState; -} { - assert( - state.deckView, - "trying to edit deck, but state does not have an active deck.", - ); - - assert( - state.data.decks[state.deckView.id], - `trying to edit deck, but deck does not exist.`, - ); -} diff --git a/src/store/slices/index.ts b/src/store/slices/index.ts index a53fce83..f0c37fa5 100644 --- a/src/store/slices/index.ts +++ b/src/store/slices/index.ts @@ -1,6 +1,6 @@ import type { DataSlice } from "./data.types"; import type { DeckCreateSlice } from "./deck-create.types"; -import type { DeckViewSlice } from "./deck-view.types"; +import type { DeckEditsSlice } from "./deck-edits.types"; import type { ListsSlice } from "./lists.types"; import type { LookupTablesSlice } from "./lookup-tables.types"; import type { MetadataSlice } from "./metadata.types"; @@ -15,5 +15,5 @@ export type StoreState = SharedSlice & UISlice & SettingsSlice & DataSlice & - DeckViewSlice & + DeckEditsSlice & DeckCreateSlice; diff --git a/src/store/slices/shared.ts b/src/store/slices/shared.ts index dd371485..5d957537 100644 --- a/src/store/slices/shared.ts +++ b/src/store/slices/shared.ts @@ -145,31 +145,27 @@ export const createSharedSlice: StateCreator< return true; }, - saveDeck() { + saveDeck(deckId) { const state = get(); - if (!state.deckView) { + const edits = state.deckEdits[deckId]; + + if (!edits) { console.warn("Tried to save deck but not in edit mode."); return; } - const deck = state.data.decks[state.deckView.id]; + const deck = state.data.decks[deckId]; if (!deck) return; - const nextDeck = applyDeckEdits(deck, state.deckView, state.metadata, true); + const nextDeck = applyDeckEdits(deck, edits, state.metadata, true); nextDeck.date_update = new Date().toISOString(); + const deckEdits = { ...state.deckEdits }; + delete deckEdits[deckId]; + set({ - deckView: { - ...state.deckView, - activeTab: state.deckView.activeTab, - dirty: false, - edits: { - meta: {}, - quantities: {}, - customizations: {}, - }, - }, + deckEdits, data: { ...state.data, decks: { diff --git a/src/store/slices/shared.types.ts b/src/store/slices/shared.types.ts index 043f3d12..11b15793 100644 --- a/src/store/slices/shared.types.ts +++ b/src/store/slices/shared.types.ts @@ -4,6 +4,8 @@ import type { MetadataResponse, } from "@/store/services/queries"; +import type { Id } from "./data.types"; + export type SharedSlice = { init( queryMetadata: () => Promise, @@ -14,5 +16,5 @@ export type SharedSlice = { createDeck(): string | number; - saveDeck(): void; + saveDeck(deckId: Id): void; }; diff --git a/src/utils/custom-equal-selector.ts b/src/utils/custom-equal-selector.ts index 2d9c7d93..a3cdf3dc 100644 --- a/src/utils/custom-equal-selector.ts +++ b/src/utils/custom-equal-selector.ts @@ -7,7 +7,8 @@ export const createCustomEqualSelector = (equalFn: EqualityFn) => export const createDebugSelector = createSelectorCreator(lruMemoize, { equalityCheck: (previousVal, currentVal) => { const rv = currentVal === previousVal; - if (!rv) console.log("Selector param value changed", currentVal); + if (!rv) + console.log("Selector param value changed: ", previousVal, currentVal); return rv; }, }); diff --git a/src/utils/use-deck-id.tsx b/src/utils/use-deck-id.tsx new file mode 100644 index 00000000..ab65b5d2 --- /dev/null +++ b/src/utils/use-deck-id.tsx @@ -0,0 +1,56 @@ +import type React from "react"; +import { createContext, useContext, useMemo } from "react"; + +import type { Id } from "@/store/slices/data.types"; + +import { assert } from "./assert"; + +interface DeckContextType { + deckId?: Id; + canEdit?: boolean; +} + +type DeckContextTypeChecked = { + deckId: Id; + canEdit?: boolean; +}; + +function isDeckContextTypeChecked( + context: DeckContextType, +): context is DeckContextTypeChecked { + return context.deckId !== undefined; +} + +const DeckIdContext = createContext(undefined); + +type Props = DeckContextType & { + children: React.ReactNode; +}; + +export function DeckIdProvider({ deckId, canEdit, children }: Props) { + const value = useMemo( + () => ({ + deckId, + canEdit, + }), + [deckId, canEdit], + ); + + return ( + {children} + ); +} + +export function useDeckId() { + const context = useContext(DeckIdContext); + return context ?? { deckId: undefined, canEdit: false }; +} + +export function useDeckIdChecked(): DeckContextTypeChecked { + const context = useDeckId(); + assert( + isDeckContextTypeChecked(context), + "expected to be defined in a parent DeckIdProvider", + ); + return context; +} diff --git a/src/utils/use-location-with-confirm.ts b/src/utils/use-location-with-confirm.ts deleted file mode 100644 index ed8bc07f..00000000 --- a/src/utils/use-location-with-confirm.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useCallback } from "react"; -import type { BaseLocationHook } from "wouter"; -import { useBrowserLocation } from "wouter/use-browser-location"; - -import { useStore } from "@/store"; -import { selectNeedsConfirmation } from "@/store/selectors/shared"; - -export const useBrowserLocationWithConfirmation: BaseLocationHook = () => { - const [location, setLocation] = useBrowserLocation(); - const needsConfirm = useStore(selectNeedsConfirmation); - - const onLocationChange = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (newLocation: string, options: any) => { - const perfomNavigation = - needsConfirm && !options?.state?.confirm - ? window.confirm(needsConfirm) - : true; - if (perfomNavigation) setLocation(newLocation); - }, - [needsConfirm, setLocation], - ); - - return [location, onLocationChange]; -}; diff --git a/src/utils/use-sync-active-deck-id.ts b/src/utils/use-sync-active-deck-id.ts deleted file mode 100644 index 4f9459af..00000000 --- a/src/utils/use-sync-active-deck-id.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect } from "react"; -import { usePathname } from "wouter/use-browser-location"; - -import { useCardModalContext } from "@/components/card-modal/card-modal-context"; -import { useStore } from "@/store"; - -export function useSyncActiveDeckId() { - const pathname = usePathname(); - const modalContext = useCardModalContext(); - - const setActiveDeck = useStore((state) => state.setActiveDeck); - - useEffect(() => { - modalContext.setClosed(); - - if (pathname.startsWith("/deck/edit/")) { - const id = pathname.split("/").at(-1); - setActiveDeck(id); - } else { - setActiveDeck(undefined); - } - }, [setActiveDeck, pathname, modalContext]); -}