From bcb2bdea5957b5abd3f64ce12897269ffa63b462 Mon Sep 17 00:00:00 2001 From: carlh171112 Date: Wed, 17 Jun 2026 07:54:36 -0700 Subject: [PATCH 1/2] Refactor PreconDeckModal to use MenuSelect and enhance loading state handling - Replaced SelectField with MenuSelect for improved UI/UX in type filtering. - Updated useDecks hook to return loading and error states. - Enhanced PreconDeckModal to display loading and error messages based on the state. - Added type filter label and options using useMemo for performance optimization. - Updated translations for loading and error messages in multiple languages. --- .../src/components/menu/PreconDeckModal.tsx | 60 +++++++++++++------ .../menu/__tests__/MyDecks.test.tsx | 2 +- client/src/hooks/useDecks.ts | 24 ++++++-- client/src/i18n/locales/de/menu.json | 2 + client/src/i18n/locales/en/menu.json | 2 + client/src/i18n/locales/es/menu.json | 2 + client/src/i18n/locales/fr/menu.json | 2 + client/src/i18n/locales/it/menu.json | 2 + client/src/i18n/locales/pl/menu.json | 2 + client/src/i18n/locales/pt/menu.json | 2 + .../pages/__tests__/GameSetupPage.test.tsx | 2 +- 11 files changed, 77 insertions(+), 25 deletions(-) diff --git a/client/src/components/menu/PreconDeckModal.tsx b/client/src/components/menu/PreconDeckModal.tsx index e9d9dccae3..785456ed11 100644 --- a/client/src/components/menu/PreconDeckModal.tsx +++ b/client/src/components/menu/PreconDeckModal.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { isCommanderPreconDeck, useDecks, type DeckEntry } from "../../hooks/useDecks"; import { preconExists, savePreconDeck } from "../../services/preconDecks"; import { menuButtonClass } from "./buttonStyles"; -import { SelectField } from "../ui/SelectField"; +import { MenuSelect } from "../ui/MenuSelect"; interface PreconDeckModalProps { open: boolean; @@ -41,7 +41,7 @@ function coverageTone(pct: number): string { export function PreconDeckModal({ open, onClose, onImported }: PreconDeckModalProps) { const { t } = useTranslation("menu"); - const decks = useDecks(); + const { decks, loading, loadError } = useDecks(); const [query, setQuery] = useState(""); const [typeFilter, setTypeFilter] = useState(ALL_TYPES); // Multi-select state. Keyed by deck id (filename stem) so it survives the @@ -91,6 +91,30 @@ export function PreconDeckModal({ open, onClose, onImported }: PreconDeckModalPr .slice(0, MAX_RESULTS); }, [decks, query, typeFilter]); + const typeOptions = useMemo( + () => + Array.from(typeCounts.entries()) + .filter(([, n]) => n > 0) + .sort((a, b) => a[0].localeCompare(b[0])), + [typeCounts], + ); + + const typeFilterItems = useMemo( + () => [ + { value: ALL_TYPES, label: t("precon.allTypes", { count: totalMatches }) }, + ...typeOptions.map(([type, n]) => ({ value: type, label: `${type} (${n})` })), + ], + [t, totalMatches, typeOptions], + ); + + const typeFilterLabel = useMemo(() => { + if (typeFilter === ALL_TYPES) { + return t("precon.allTypes", { count: totalMatches }); + } + const match = typeOptions.find(([type]) => type === typeFilter); + return match ? `${match[0]} (${match[1]})` : typeFilter; + }, [typeFilter, totalMatches, typeOptions, t]); + if (!open) return null; const handlePick = (deck: DeckEntry) => { @@ -163,10 +187,6 @@ export function PreconDeckModal({ open, onClose, onImported }: PreconDeckModalPr } }; - const typeOptions = Array.from(typeCounts.entries()) - .filter(([, n]) => n > 0) - .sort((a, b) => a[0].localeCompare(b[0])); - return (
- setTypeFilter(e.target.value)} - className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none" - > - - {typeOptions.map(([type, n]) => ( - - ))} - +
- {!decks ? ( + {loading ? (
{t("precon.loadingCatalog")}
+ ) : loadError ? ( +
{t("precon.loadFailed")}
) : filtered.length === 0 ? (
{t("precon.noMatch")}
) : ( diff --git a/client/src/components/menu/__tests__/MyDecks.test.tsx b/client/src/components/menu/__tests__/MyDecks.test.tsx index 11583766d1..9b5d64545b 100644 --- a/client/src/components/menu/__tests__/MyDecks.test.tsx +++ b/client/src/components/menu/__tests__/MyDecks.test.tsx @@ -31,7 +31,7 @@ vi.mock("../../../services/deckCompatibility", () => ({ vi.mock("../../../hooks/useDecks", () => ({ loadPreconDeckMap: vi.fn(), isCommanderPreconDeck: (deck: { type: string }) => deck.type === "Commander Deck", - useDecks: vi.fn(() => null), + useDecks: vi.fn(() => ({ decks: null, loading: true, loadError: false })), })); function saveDeck(name: string, deck: ParsedDeck): void { diff --git a/client/src/hooks/useDecks.ts b/client/src/hooks/useDecks.ts index b68a0e73a2..1f8dda88ee 100644 --- a/client/src/hooks/useDecks.ts +++ b/client/src/hooks/useDecks.ts @@ -43,20 +43,36 @@ export function loadPreconDeckMap(): Promise { return fetchPromise; } +export interface UseDecksResult { + /** Catalog entries keyed by MTGJSON filename stem; `null` until the first fetch settles. */ + decks: DeckMap | null; + loading: boolean; + loadError: boolean; +} + /** * Returns the preconstructed deck catalog keyed by deck id (MTGJSON filename * stem, e.g. `RedDeckB_10E`). Includes every deck above MIN_DECK_CARDS — each * entry carries a `coveragePct`, so consumers (e.g. the precon picker) can * apply their own coverage-floor filter rather than dropping decks at build - * time. `null` while loading or on fetch failure. + * time. */ -export function useDecks(): DeckMap | null { +export function useDecks(): UseDecksResult { const [decks, setDecks] = useState(cached); + const [loading, setLoading] = useState(!cached); + const [loadError, setLoadError] = useState(false); useEffect(() => { if (cached) return; - loadPreconDeckMap().then((d) => { if (d) setDecks(d); }); + loadPreconDeckMap().then((d) => { + setLoading(false); + if (d) { + setDecks(d); + return; + } + setLoadError(true); + }); }, []); - return decks; + return { decks, loading, loadError }; } diff --git a/client/src/i18n/locales/de/menu.json b/client/src/i18n/locales/de/menu.json index d35f43723e..39c77e68f3 100644 --- a/client/src/i18n/locales/de/menu.json +++ b/client/src/i18n/locales/de/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} insgesamt", "searchPlaceholder": "Nach Name, Set-Code oder Typ suchen…", "allTypes": "Alle ({{count}})", + "typeFilter": "Decktyp", "loadingCatalog": "Deckkatalog wird geladen…", + "loadFailed": "Der Katalog vorkonstruierter Decks konnte nicht geladen werden. Verbindung prüfen und erneut versuchen.", "noMatch": "Keine Decks passen.", "selectDeck": "{{name}} auswählen", "cardCount_one": "{{count}} Karte", diff --git a/client/src/i18n/locales/en/menu.json b/client/src/i18n/locales/en/menu.json index 4426222661..5f114ca791 100644 --- a/client/src/i18n/locales/en/menu.json +++ b/client/src/i18n/locales/en/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} total", "searchPlaceholder": "Search by name, set code, or type…", "allTypes": "All ({{count}})", + "typeFilter": "Deck type", "loadingCatalog": "Loading deck catalog…", + "loadFailed": "Could not load the preconstructed deck catalog. Check your connection and try again.", "noMatch": "No decks match.", "selectDeck": "Select {{name}}", "cardCount_one": "{{count}} card", diff --git a/client/src/i18n/locales/es/menu.json b/client/src/i18n/locales/es/menu.json index ddc620cf5d..ded03357dd 100644 --- a/client/src/i18n/locales/es/menu.json +++ b/client/src/i18n/locales/es/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} en total", "searchPlaceholder": "Buscar por nombre, código de colección o tipo…", "allTypes": "Todos ({{count}})", + "typeFilter": "Tipo de mazo", "loadingCatalog": "Cargando catálogo de mazos…", + "loadFailed": "No se pudo cargar el catálogo de mazos preconstruidos. Comprueba tu conexión e inténtalo de nuevo.", "noMatch": "Ningún mazo coincide.", "selectDeck": "Seleccionar {{name}}", "cardCount_one": "{{count}} carta", diff --git a/client/src/i18n/locales/fr/menu.json b/client/src/i18n/locales/fr/menu.json index 41f70eaf02..323f62534d 100644 --- a/client/src/i18n/locales/fr/menu.json +++ b/client/src/i18n/locales/fr/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} au total", "searchPlaceholder": "Rechercher par nom, code d'extension ou type…", "allTypes": "Tous ({{count}})", + "typeFilter": "Type de deck", "loadingCatalog": "Chargement du catalogue de decks…", + "loadFailed": "Impossible de charger le catalogue de decks préconstruits. Vérifiez votre connexion et réessayez.", "noMatch": "Aucun deck ne correspond.", "selectDeck": "Sélectionner {{name}}", "cardCount_one": "{{count}} carte", diff --git a/client/src/i18n/locales/it/menu.json b/client/src/i18n/locales/it/menu.json index 90dbba4709..a9f1f3957b 100644 --- a/client/src/i18n/locales/it/menu.json +++ b/client/src/i18n/locales/it/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} totali", "searchPlaceholder": "Cerca per nome, codice set o tipo…", "allTypes": "Tutti ({{count}})", + "typeFilter": "Tipo di mazzo", "loadingCatalog": "Caricamento catalogo mazzi…", + "loadFailed": "Impossibile caricare il catalogo dei mazzi precostruiti. Controlla la connessione e riprova.", "noMatch": "Nessun mazzo corrisponde.", "selectDeck": "Seleziona {{name}}", "cardCount_one": "{{count}} carta", diff --git a/client/src/i18n/locales/pl/menu.json b/client/src/i18n/locales/pl/menu.json index 0645ff6fc7..beea5aed6c 100644 --- a/client/src/i18n/locales/pl/menu.json +++ b/client/src/i18n/locales/pl/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} łącznie", "searchPlaceholder": "Szukaj według nazwy, kodu dodatku lub typu…", "allTypes": "Wszystkie ({{count}})", + "typeFilter": "Typ talii", "loadingCatalog": "Wczytywanie katalogu talii…", + "loadFailed": "Nie udało się wczytać katalogu gotowych talii. Sprawdź połączenie i spróbuj ponownie.", "noMatch": "Brak pasujących talii.", "selectDeck": "Wybierz {{name}}", "cardCount_one": "{{count}} karta", diff --git a/client/src/i18n/locales/pt/menu.json b/client/src/i18n/locales/pt/menu.json index 8f742296b8..7c66653595 100644 --- a/client/src/i18n/locales/pt/menu.json +++ b/client/src/i18n/locales/pt/menu.json @@ -173,7 +173,9 @@ "totalCount": "· {{count}} no total", "searchPlaceholder": "Buscar por nome, código de coleção ou tipo…", "allTypes": "Todos ({{count}})", + "typeFilter": "Tipo de deck", "loadingCatalog": "Carregando catálogo de decks…", + "loadFailed": "Não foi possível carregar o catálogo de decks pré-construídos. Verifique sua conexão e tente novamente.", "noMatch": "Nenhum deck corresponde.", "selectDeck": "Selecionar {{name}}", "cardCount_one": "{{count}} carta", diff --git a/client/src/pages/__tests__/GameSetupPage.test.tsx b/client/src/pages/__tests__/GameSetupPage.test.tsx index 1f9932428e..b8cd319461 100644 --- a/client/src/pages/__tests__/GameSetupPage.test.tsx +++ b/client/src/pages/__tests__/GameSetupPage.test.tsx @@ -83,7 +83,7 @@ vi.mock("../../hooks/useDecks", async () => { ); return { ...actual, - useDecks: () => null, + useDecks: () => ({ decks: null, loading: false, loadError: false }), }; }); From 2bd91a8a3e0608840fe1b284b1840780cc879d26 Mon Sep 17 00:00:00 2001 From: carlh171112 Date: Wed, 17 Jun 2026 08:07:20 -0700 Subject: [PATCH 2/2] Refactor useDecks hook to return status instead of loading and error flags - Updated useDecks hook to return a status string ("loading", "success", "error") for better state management. - Modified PreconDeckModal and related tests to utilize the new status structure. - Enhanced loading and error handling in PreconDeckModal for improved user experience. --- client/src/components/menu/PreconDeckModal.tsx | 8 ++++---- .../src/components/menu/__tests__/MyDecks.test.tsx | 2 +- client/src/hooks/useDecks.ts | 14 +++++++------- client/src/pages/__tests__/GameSetupPage.test.tsx | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/client/src/components/menu/PreconDeckModal.tsx b/client/src/components/menu/PreconDeckModal.tsx index 785456ed11..988778fc46 100644 --- a/client/src/components/menu/PreconDeckModal.tsx +++ b/client/src/components/menu/PreconDeckModal.tsx @@ -41,7 +41,7 @@ function coverageTone(pct: number): string { export function PreconDeckModal({ open, onClose, onImported }: PreconDeckModalProps) { const { t } = useTranslation("menu"); - const { decks, loading, loadError } = useDecks(); + const { decks, status } = useDecks(); const [query, setQuery] = useState(""); const [typeFilter, setTypeFilter] = useState(ALL_TYPES); // Multi-select state. Keyed by deck id (filename stem) so it survives the @@ -240,16 +240,16 @@ export function PreconDeckModal({ open, onClose, onImported }: PreconDeckModalPr menuLayout="dropdown" wrapperClassName="sm:w-52 shrink-0" fitContainer - disabled={loading || loadError} + disabled={status === "loading" || status === "error"} className="rounded-lg border-white/10 bg-black/30 py-2 focus-visible:ring-white/30" />
- {loading ? ( + {status === "loading" ? (
{t("precon.loadingCatalog")}
- ) : loadError ? ( + ) : status === "error" ? (
{t("precon.loadFailed")}
) : filtered.length === 0 ? (
{t("precon.noMatch")}
diff --git a/client/src/components/menu/__tests__/MyDecks.test.tsx b/client/src/components/menu/__tests__/MyDecks.test.tsx index 9b5d64545b..c67869ee24 100644 --- a/client/src/components/menu/__tests__/MyDecks.test.tsx +++ b/client/src/components/menu/__tests__/MyDecks.test.tsx @@ -31,7 +31,7 @@ vi.mock("../../../services/deckCompatibility", () => ({ vi.mock("../../../hooks/useDecks", () => ({ loadPreconDeckMap: vi.fn(), isCommanderPreconDeck: (deck: { type: string }) => deck.type === "Commander Deck", - useDecks: vi.fn(() => ({ decks: null, loading: true, loadError: false })), + useDecks: vi.fn(() => ({ decks: null, status: "loading" as const })), })); function saveDeck(name: string, deck: ParsedDeck): void { diff --git a/client/src/hooks/useDecks.ts b/client/src/hooks/useDecks.ts index 1f8dda88ee..ba1e36c5dc 100644 --- a/client/src/hooks/useDecks.ts +++ b/client/src/hooks/useDecks.ts @@ -43,11 +43,12 @@ export function loadPreconDeckMap(): Promise { return fetchPromise; } +export type UseDecksStatus = "loading" | "success" | "error"; + export interface UseDecksResult { /** Catalog entries keyed by MTGJSON filename stem; `null` until the first fetch settles. */ decks: DeckMap | null; - loading: boolean; - loadError: boolean; + status: UseDecksStatus; } /** @@ -59,20 +60,19 @@ export interface UseDecksResult { */ export function useDecks(): UseDecksResult { const [decks, setDecks] = useState(cached); - const [loading, setLoading] = useState(!cached); - const [loadError, setLoadError] = useState(false); + const [status, setStatus] = useState(cached ? "success" : "loading"); useEffect(() => { if (cached) return; loadPreconDeckMap().then((d) => { - setLoading(false); if (d) { setDecks(d); + setStatus("success"); return; } - setLoadError(true); + setStatus("error"); }); }, []); - return { decks, loading, loadError }; + return { decks, status }; } diff --git a/client/src/pages/__tests__/GameSetupPage.test.tsx b/client/src/pages/__tests__/GameSetupPage.test.tsx index b8cd319461..010e198969 100644 --- a/client/src/pages/__tests__/GameSetupPage.test.tsx +++ b/client/src/pages/__tests__/GameSetupPage.test.tsx @@ -83,7 +83,7 @@ vi.mock("../../hooks/useDecks", async () => { ); return { ...actual, - useDecks: () => ({ decks: null, loading: false, loadError: false }), + useDecks: () => ({ decks: null, status: "success" as const }), }; });