diff --git a/schema.md b/schema.md index 8dcf353..202153e 100644 --- a/schema.md +++ b/schema.md @@ -646,21 +646,42 @@ Characters and units should both be defined by quantity, however represent any u | `characters` | Selected characters | | `notes` | Optional user notes | | `allowMercenaries` | Whether the catalog offers Regiments of Renown for hire (optional; absent reads as off) | +| `folderId` | Folder the list is filed under (optional; absent or null means the rail's top level) | +| `sortIndex` | Position among the lists in the same folder (optional; lists without one sort last, in saved order) | + +## Folder Schema + +Folders organise the list rail. They hold no lists themselves — a list names its folder through `folderId` — and they belong to a rule set, like the lists they hold, so each rule set keeps its own organisation. Folders are saved in their own localStorage key (`warmuster.folders.v1`), separate from the lists. + + { + "id": "folder-8fq2k1xa", + "ruleSet": "warmaster-revolution", + "name": "Tournament", + "sortIndex": 0 + } + +| Field | Purpose | +| ----------- | -------------------------------------------------------------- | +| `id` | Stable id referenced by a list's `folderId` | +| `ruleSet` | Rule set whose rail shows this folder | +| `name` | User's folder name. `Imports` is created on demand by share-link imports and reused by later ones | +| `sortIndex` | Position among the rule set's folders | + +Deleting a folder deletes the lists filed under it. A list whose folder is missing — a corrupt store, or a backup carrying the list but not its folder — is shown at the top level rather than disappearing. ## Backup File Schema -A backup is the browser's whole collection in one file — every saved list, from every rule set. Importing one *replaces* the collection rather than merging into it, so a backup taken on one computer restores onto another as an exact mirror. List `id`s are therefore preserved (unlike share codes, which regenerate ids because they add a single list to an existing collection). +A backup is the browser's whole collection in one file — every saved list, from every rule set, plus the folders they are filed under. Importing one *replaces* the collection rather than merging into it, so a backup taken on one computer restores onto another as an exact mirror. List and folder `id`s are therefore preserved (unlike share codes, which regenerate ids because they add a single list to an existing collection). -```json -{ - "kind": "warmuster/backup", - "backupVersion": 1, - "exportedAt": "2026-08-06T12:00:00.000Z", - "lists": [] -} -``` + { + "kind": "warmuster/backup", + "backupVersion": 1, + "exportedAt": "2026-08-06T12:00:00.000Z", + "lists": [], + "folders": [] + } -The `lists` array contains `SavedList` objects, exactly as described above. +The `lists` array contains `SavedList` objects and the `folders` array `Folder` objects, exactly as described above. `folders` is absent from backups taken before folders existed; those restore as a flat collection. | Field | Purpose | | --------------- | ------------------------------------------------------------- | diff --git a/src/App.tsx b/src/App.tsx index 8f8950a..a330afa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,17 @@ import PrintView, { import Roster from "./components/Roster"; import { consumeShareHash, decodeShareCode } from "./domain/shareCode"; import { getArmy, ruleSets } from "./data/gameData"; +import { + createFolder, + deleteFolder, + foldersForRuleSet, + normalizeImportFolderTarget, + moveFolder, + moveList, + renameFolder, + resolveImportFolder, + type ImportFolderTarget, +} from "./domain/folders"; import { addCharacter, addUnit, @@ -31,14 +42,21 @@ import { totalPoints, } from "./domain/lists"; import { validateList } from "./domain/validation"; +import { loadFolders, saveFolders } from "./storage/folderRepository"; +import { + importFolderPreference, + loadImportFolderPreferences, + saveImportFolderPreferences, +} from "./storage/importFolderRepository"; import { deleteList, listsForRuleSet, loadLists, replaceAllLists, + saveLists, upsertList, } from "./storage/listRepository"; -import type { SavedList } from "./types"; +import type { Folder, SavedList } from "./types"; type Theme = "light" | "dark"; @@ -63,6 +81,14 @@ function Home() { export default function App() { const [lists, setLists] = useState(() => loadLists()); + const [folders, setFolders] = useState(() => loadFolders()); + // Mirrors `folders` so the share import, which resolves after its effect's + // closure was captured, always reads the current folders. + const foldersRef = useRef(folders); + const [importFolderPreferences, setImportFolderPreferences] = useState(() => + loadImportFolderPreferences(), + ); + const importFolderPreferencesRef = useRef(importFolderPreferences); const [activeListId, setActiveListId] = useState(null); const [theme, setTheme] = useState(() => initialTheme()); const [exportOpen, setExportOpen] = useState(false); @@ -118,6 +144,13 @@ export default function App() { return () => window.removeEventListener("popstate", onPop); }, []); + // Every folder write goes through here: ref, storage, then state. + const updateFolders = (next: Folder[]) => { + foldersRef.current = next; + saveFolders(next); + setFolders(next); + }; + // Import a shared list from the URL hash on first load. useEffect(() => { const code = consumeShareHash(); @@ -125,7 +158,11 @@ export default function App() { const generation = restoreGeneration.current; void decodeShareCode(code).then((imported) => { if (!imported || generation !== restoreGeneration.current) return; - setLists((prev) => upsertList(prev, imported)); + const current = foldersRef.current; + const target = importFolderPreference(importFolderPreferencesRef.current, imported.ruleSet); + const { folders: next, folderId } = resolveImportFolder(current, imported.ruleSet, target); + if (next !== current) updateFolders(next); + setLists((prev) => upsertList(prev, { ...imported, folderId })); // Show the set the imported list belongs to so it's visible in the rail. if (ruleSets.some((rs) => rs.id === imported.ruleSet)) setActiveRuleSet(imported.ruleSet); setActiveListId(imported.id); @@ -143,6 +180,10 @@ export default function App() { () => listsForRuleSet(lists, activeRuleSet), [lists, activeRuleSet], ); + const visibleFolders = useMemo( + () => foldersForRuleSet(folders, activeRuleSet), + [folders, activeRuleSet], + ); const issues = useMemo( () => (activeList && army ? validateList(activeList, army) : []), @@ -175,13 +216,39 @@ export default function App() { if (activeListId === id) setActiveListId(null); }; + const handleCreateFolder = (name: string) => + updateFolders([...folders, createFolder(folders, activeRuleSet, name)]); + + const handleRenameFolder = (id: string, name: string) => + updateFolders(renameFolder(folders, id, name)); + + // A folder holds its lists, so deleting it deletes them too — the rail asks + // for confirmation, naming the count, before this runs. + const handleDeleteFolder = (id: string) => { + const next = deleteFolder(folders, lists, id); + updateFolders(next.folders); + saveLists(next.lists); + setLists(next.lists); + if (!next.lists.some((l) => l.id === activeListId)) setActiveListId(null); + }; + + const handleMoveList = (listId: string, folderId: string | null, index: number) => { + const next = moveList(lists, listId, folderId, index); + setLists(next); + saveLists(next); + }; + + const handleMoveFolder = (folderId: string, index: number) => + updateFolders(moveFolder(folders, folderId, index)); + // Restoring a backup swaps in another browser's whole collection, so the // list open at the time is gone unless the backup happens to carry it. - const handleReplaceAllLists = (imported: SavedList[]) => { - if (!replaceAllLists(imported)) return false; + const handleReplaceAll = (importedLists: SavedList[], importedFolders: Folder[]) => { + if (!replaceAllLists(importedLists)) return false; + updateFolders(importedFolders); restoreGeneration.current += 1; - setLists(imported); - if (!imported.some((l) => l.id === activeListId)) setActiveListId(null); + setLists(importedLists); + if (!importedLists.some((l) => l.id === activeListId)) setActiveListId(null); return true; }; @@ -192,6 +259,13 @@ export default function App() { setActiveListId(null); }; + const handleSelectImportFolder = (target: ImportFolderTarget) => { + const next = { ...importFolderPreferencesRef.current, [activeRuleSet]: target }; + importFolderPreferencesRef.current = next; + saveImportFolderPreferences(next); + setImportFolderPreferences(next); + }; + const toggleTheme = () => setTheme(theme === "dark" ? "light" : "dark"); return ( @@ -230,6 +304,7 @@ export default function App() { ruleSets={ruleSets} activeRuleSet={activeRuleSet} lists={visibleLists} + folders={visibleFolders} activeListId={activeListId} onSelect={(id) => { setActiveListId(id); @@ -237,6 +312,11 @@ export default function App() { }} onCreate={handleCreate} onDelete={handleDelete} + onCreateFolder={handleCreateFolder} + onRenameFolder={handleRenameFolder} + onDeleteFolder={handleDeleteFolder} + onMoveList={handleMoveList} + onMoveFolder={handleMoveFolder} onInfo={setInfoTopic} theme={theme} onToggleTheme={toggleTheme} @@ -303,7 +383,14 @@ export default function App() { simplifiedView={simplifiedView} onToggleSimplifiedView={setSimplifiedView} lists={lists} - onReplaceAllLists={handleReplaceAllLists} + folders={folders} + importFolderTarget={normalizeImportFolderTarget( + folders, + activeRuleSet, + importFolderPreference(importFolderPreferences, activeRuleSet), + )} + onSelectImportFolder={handleSelectImportFolder} + onReplaceAll={handleReplaceAll} onClose={() => setConfigOpen(false)} /> )} diff --git a/src/components/Catalog.test.tsx b/src/components/Catalog.test.tsx new file mode 100644 index 0000000..81f2c66 --- /dev/null +++ b/src/components/Catalog.test.tsx @@ -0,0 +1,50 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { getArmy, getRuleSet } from "../data/gameData"; +import { createList } from "../domain/lists"; +import Catalog from "./Catalog"; + +function renderCatalog(ruleSetId: string) { + const ruleSet = getRuleSet(ruleSetId)!; + const army = getArmy(ruleSetId, "dwarfs")!; + const list = createList(ruleSetId, ruleSet.version, "dwarfs", "Test Dwarfs", 2000); + render( + , + ); +} + +describe("Catalog", () => { + it("separates custom Dwarf characters in the right rail", () => { + renderCatalog("warmaster-custom"); + + const standardList = screen.getByRole("heading", { name: "Characters" }).nextElementSibling!; + const customList = screen.getByRole("heading", { + name: "Custom Characters", + }).nextElementSibling!; + + expect(within(standardList as HTMLElement).getByText("Runesmith")).toBeInTheDocument(); + expect(within(standardList as HTMLElement).queryByText("Dramar Thungnisson")).toBeNull(); + expect(within(customList as HTMLElement).getByText("Dramar Thungnisson")).toBeInTheDocument(); + expect(within(customList as HTMLElement).getByText("Roknar Gromdal")).toBeInTheDocument(); + }); + + it("does not add a custom heading to the standard ruleset", () => { + renderCatalog("warmaster-revolution"); + expect(screen.queryByRole("heading", { name: "Custom Characters" })).toBeNull(); + }); + + it("displays flat and per-1000 maximums at their correct 2000-point values", () => { + renderCatalog("warmaster-custom"); + + const fixedCapRow = screen.getByText("Dramar Thungnisson", { exact: true }).closest("li")!; + const scaledCapRow = screen.getByText("Ram Riders", { exact: true }).closest("li")!; + expect(fixedCapRow).toHaveTextContent("Min/Max -/1"); + expect(scaledCapRow).toHaveTextContent("Min/Max -/4"); + }); +}); diff --git a/src/components/Catalog.tsx b/src/components/Catalog.tsx index b840a4f..a498f47 100644 --- a/src/components/Catalog.tsx +++ b/src/components/Catalog.tsx @@ -28,7 +28,8 @@ function CatalogRow({ onAdd?: () => void; }) { const isGeneral = unit.type === "General"; - const max = unit.max != null ? (isGeneral ? unit.max : unit.max * scale) : null; + const max = + unit.max != null ? (isGeneral || unit.maxPerArmy ? unit.max : unit.max * scale) : null; const atMax = max != null && count >= max; // What hiring this regiment will cost the army's own allowances. Only @@ -120,7 +121,12 @@ export default function Catalog({ // army, so nothing is held back. const own = army.units.filter((u) => !isHired(u, army)); const units = own.filter((u) => u.category === "unit"); - const characters = own.filter((u) => u.category === "character"); + const characters = own.filter( + (u) => u.category === "character" && u.ruleSet !== "warmaster-custom", + ); + const customCharacters = own.filter( + (u) => u.category === "character" && u.ruleSet === "warmaster-custom", + ); const upgrades = own.filter((u) => u.category === "upgrade"); const regiments = list.allowMercenaries ? hireableFor(army) : []; const hired = hiredCount(list, army); @@ -157,6 +163,23 @@ export default function Catalog({ /> ))} + {customCharacters.length > 0 && ( + <> +

Custom Characters

+
    + {customCharacters.map((unit) => ( + onAddCharacter(unit.unitId)} + /> + ))} +
+ + )} {upgrades.length > 0 && ( <>

Mounts & upgrades

@@ -238,4 +261,3 @@ export default function Catalog({ ); } - diff --git a/src/components/ConfigDialog.test.tsx b/src/components/ConfigDialog.test.tsx new file mode 100644 index 0000000..ab49f85 --- /dev/null +++ b/src/components/ConfigDialog.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { IMPORTS_FOLDER_TARGET, folderImportTarget } from "../domain/folders"; +import type { Folder, RuleSetInfo } from "../types"; +import ConfigDialog from "./ConfigDialog"; + +const ruleSets = [ + { id: "warmaster-custom", name: "A Matter of Mustaches", version: "1", armies: [] }, + { id: "warmaster-revolution", name: "Warmaster Revolution", version: "1", armies: [] }, +] satisfies RuleSetInfo[]; + +function renderDialog(folders: Folder[], onSelectImportFolder = vi.fn()) { + render( + , + ); + return onSelectImportFolder; +} + +describe("ConfigDialog default import folder", () => { + it("defaults to Imports even when no Imports folder exists", () => { + renderDialog([]); + const select = screen.getByRole("combobox", { name: "Default import folder" }); + expect(select).toHaveValue(IMPORTS_FOLDER_TARGET); + expect(screen.getByRole("option", { name: "Imports" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "No folder" })).toBeInTheDocument(); + }); + + it("offers existing folders from only the active rule set", () => { + const onSelect = renderDialog([ + { id: "events", ruleSet: "warmaster-custom", name: "Events", sortIndex: 0 }, + { id: "other", ruleSet: "warmaster-revolution", name: "Other", sortIndex: 0 }, + ]); + const select = screen.getByRole("combobox", { name: "Default import folder" }); + expect(screen.getByRole("option", { name: "Events" })).toBeInTheDocument(); + expect(screen.queryByRole("option", { name: "Other" })).toBeNull(); + + fireEvent.change(select, { target: { value: folderImportTarget("events") } }); + expect(onSelect).toHaveBeenCalledWith(folderImportTarget("events")); + }); +}); diff --git a/src/components/ConfigDialog.tsx b/src/components/ConfigDialog.tsx index d7c7113..b303da1 100644 --- a/src/components/ConfigDialog.tsx +++ b/src/components/ConfigDialog.tsx @@ -1,6 +1,14 @@ import { useEffect, useRef, useState } from "react"; -import type { RuleSetInfo, SavedList } from "../types"; -import { backupFileName, parseBackup, serializeBackup } from "../domain/backup"; +import type { Folder, RuleSetInfo, SavedList } from "../types"; +import { backupFileName, parseBackup, serializeBackup, type BackupContents } from "../domain/backup"; +import { + folderImportTarget, + foldersForRuleSet, + IMPORTS_FOLDER_NAME, + IMPORTS_FOLDER_TARGET, + NO_IMPORT_FOLDER_TARGET, + type ImportFolderTarget, +} from "../domain/folders"; interface ConfigDialogProps { ruleSets: RuleSetInfo[]; @@ -10,7 +18,11 @@ interface ConfigDialogProps { onToggleSimplifiedView: (value: boolean) => void; /** Every saved list in this browser, across all rule sets. */ lists: SavedList[]; - onReplaceAllLists: (lists: SavedList[]) => boolean; + /** Every folder in this browser, across all rule sets. */ + folders: Folder[]; + importFolderTarget: ImportFolderTarget; + onSelectImportFolder: (target: ImportFolderTarget) => void; + onReplaceAll: (lists: SavedList[], folders: Folder[]) => boolean; onClose: () => void; } @@ -24,7 +36,7 @@ function downloadFile(name: string, text: string) { } /** A parsed backup waiting for the user to confirm the replace. */ -type PendingImport = { fileName: string; lists: SavedList[] }; +type PendingImport = { fileName: string } & BackupContents; /** App configuration: the active rule set (each set keeps its own saved * lists), view preferences, and whole-collection backup/restore. */ @@ -35,13 +47,19 @@ export default function ConfigDialog({ simplifiedView, onToggleSimplifiedView, lists, - onReplaceAllLists, + folders, + importFolderTarget, + onSelectImportFolder, + onReplaceAll, onClose, }: ConfigDialogProps) { const fileInput = useRef(null); const [pending, setPending] = useState(null); const [error, setError] = useState(null); const [imported, setImported] = useState(false); + const importFolderOptions = foldersForRuleSet(folders, activeRuleSet).filter( + (folder) => folder.name.trim().toLowerCase() !== IMPORTS_FOLDER_NAME.toLowerCase(), + ); useEffect(() => { const onKey = (event: KeyboardEvent) => { @@ -51,7 +69,7 @@ export default function ConfigDialog({ return () => document.removeEventListener("keydown", onKey); }, [onClose]); - const exportBackup = () => downloadFile(backupFileName(), serializeBackup(lists)); + const exportBackup = () => downloadFile(backupFileName(), serializeBackup(lists, folders)); const pickFile = async (file: File | undefined) => { if (!file) return; @@ -63,12 +81,12 @@ export default function ConfigDialog({ return; } // Nothing is written yet — the replace happens on confirm below. - setPending({ fileName: file.name, lists: parsed }); + setPending({ fileName: file.name, ...parsed }); }; const confirmImport = () => { if (!pending) return; - if (!onReplaceAllLists(pending.lists)) { + if (!onReplaceAll(pending.lists, pending.folders)) { setError("Could not save the restored lists. Check browser storage and try again."); return; } @@ -102,6 +120,25 @@ export default function ConfigDialog({

Each rule set keeps its own saved lists. Switching shows the lists for that set.

+ +

+ Shared lists are filed here. The Imports folder is created only when first needed. +