From 94fa84f443b8755d05abb2725a6ad624faceb4a6 Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 21 Jun 2026 15:02:54 +0100 Subject: [PATCH 1/9] Upgraded category modal --- .../hooks/CategoryListHook.ts | 83 +++++++ .../hooks/CategoryTreeHook.ts | 204 ++++++++++++++++++ .../extensions/category_management/index.ts | 4 +- .../views/CategoryDialog.tsx | 64 ++---- .../views/CategoryList.tsx | 101 +++++++-- .../views/CategoryListItem.tsx | 190 ++++++++++++++++ 6 files changed, 586 insertions(+), 60 deletions(-) create mode 100644 src/renderer/src/extensions/category_management/hooks/CategoryListHook.ts create mode 100644 src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts create mode 100644 src/renderer/src/extensions/category_management/views/CategoryListItem.tsx diff --git a/src/renderer/src/extensions/category_management/hooks/CategoryListHook.ts b/src/renderer/src/extensions/category_management/hooks/CategoryListHook.ts new file mode 100644 index 0000000000..4e4f96e236 --- /dev/null +++ b/src/renderer/src/extensions/category_management/hooks/CategoryListHook.ts @@ -0,0 +1,83 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useSelector, useStore } from "react-redux"; +import type { AnyAction } from "redux"; + +import { + removeCategory, + renameCategory, + setCategory, + setCategoryOrder, + showDialog, +} from "@/actions"; +import type { DialogActions, DialogType, IDialogContent, IErrorOptions, IState } from "@/types/api"; +import type { IToolbarAction } from "@/ui/components/toolbar/ToolbarGroup"; +import { showError } from "@/util/message"; +import { activeGameId } from "@/util/selectors"; + +import type { ICategory } from "../types/ICategoryDictionary"; + +export default function useCategoryList() { + const { gameMode, language, categories, mods } = useSelector((state: IState) => { + const gameMode = activeGameId(state); + return { + gameMode, + language: state.settings.interface.language, + categories: state.persistent.categories?.[gameMode] || {}, + mods: state.persistent.mods[gameMode], + }; + }); + + const { t } = useTranslation("common"); + + const store = useStore(); + + const onRenameCategory = useCallback( + (gameId: string, categoryId: string, newCategory: string) => + store.dispatch(renameCategory(gameId, categoryId, newCategory)), + [store], + ); + + const onSetCategory = useCallback( + (gameId: string, categoryId: string, category: ICategory) => + store.dispatch(setCategory(gameId, categoryId, category)), + [store], + ); + + const onRemoveCategory = useCallback( + (gameId: string, categoryId: string) => store.dispatch(removeCategory(gameId, categoryId)), + [store], + ); + + const onSetCategoryOrder = useCallback( + (gameId: string, categoryIds: string[]) => + store.dispatch(setCategoryOrder(gameId, categoryIds)), + [store], + ); + + const onShowError = useCallback( + (message: string, details: string | Error, options: IErrorOptions) => + showError(store.dispatch, message, details, options), + [store], + ); + + const onShowDialog = useCallback( + (type: DialogType, title: string, content: IDialogContent, actions: DialogActions) => + store.dispatch(showDialog(type, title, content, actions) as unknown as AnyAction), + [store], + ); + + return { + t, + gameMode, + language, + categories, + mods, + onRenameCategory, + onSetCategory, + onRemoveCategory, + onSetCategoryOrder, + onShowError, + onShowDialog, + }; +} diff --git a/src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts b/src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts new file mode 100644 index 0000000000..dae8186d0d --- /dev/null +++ b/src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts @@ -0,0 +1,204 @@ +import { + mdiCollapseAll, + mdiExpandAll, + mdiEye, + mdiEyeOff, + mdiFolderPlus, + mdiSortAlphabeticalAscending, + mdiSync, +} from "@mdi/js"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; + +import { log } from "@/logging"; +import type { IState } from "@/types/api"; +import type { IToolbarAction } from "@/ui/components/toolbar/ToolbarGroup"; +import { activeGameId } from "@/util/selectors"; + +import type { ICategoriesTree } from "../types/ITrees"; +import createTreeDataObject from "../util/createTreeDataObject"; +import useCategoryList from "./CategoryListHook"; + +export default function useCategoryTree() { + const [expanded, setExpanded] = useState>(() => new Set()); + const [showEmpty, setShowEmpty] = useState(true); + // const [searchFocusIndex, setSearchFocusIndex] = useState(0); + // const [searchFoundCount, setSearchFoundCount] = useState(0); + const [searchString, setSearchString] = useState(""); + + const { t } = useTranslation("common"); + const { categories, mods, gameMode } = useSelector((state: IState) => { + const gameMode = activeGameId(state); + return { + gameMode, + mods: state.persistent.mods[gameMode], + categories: state.persistent.categories?.[gameMode] || {}, + }; + }); + + const { onSetCategoryOrder, onRemoveCategory } = useCategoryList(); + + const treeData = useMemo(() => { + if (!categories || !Object.keys(categories).length) return []; + return createTreeDataObject(t, categories, mods); + }, [categories, mods, t]); + + const nonEmptyCategories = useMemo(() => { + const result = new Set(); + + const markNonEmpty = (node: ICategoriesTree): boolean => { + let hasMods = node.modCount > 0; + + for (const child of node.children) { + if (markNonEmpty(child)) { + hasMods = true; + } + } + + if (hasMods) { + result.add(node.categoryId); + } + + return hasMods; + }; + + treeData.forEach(markNonEmpty); + + return result; + }, [treeData]); + + const toggleExpand = useCallback((categoryId: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(categoryId)) next.delete(categoryId); + else next.add(categoryId); + return next; + }); + }, []); + + const expandedTreeData = useMemo(() => { + const applyExpand = (categoryTree: ICategoriesTree[]): ICategoriesTree[] => { + const filtered = showEmpty + ? new Set(categoryTree.map((obj) => obj.categoryId)) + : nonEmptyCategories; + + return categoryTree + .map((obj) => { + if (!filtered.has(obj.categoryId)) return undefined; + const copy = { ...obj }; + copy.expanded = expanded.has(copy.categoryId); + copy.children = applyExpand(copy.children); + return copy; + }) + .filter(Boolean); + }; + + return applyExpand(treeData); + }, [treeData, showEmpty, expanded, nonEmptyCategories]); + + const expandAll = useCallback(() => { + setExpanded(new Set(treeData.map((c) => c.categoryId))); + }, [treeData]); + + const collapseAll = useCallback(() => { + setExpanded(new Set()); + }, []); + + const toggleEmpty = useCallback(() => { + setShowEmpty((prev) => !prev); + }, []); + + const filteredTreeData = useMemo(() => { + if (!searchString?.trim()) return expandedTreeData; + + const query = searchString.toLowerCase(); + + const filterTree = (nodes: ICategoriesTree[]): ICategoriesTree[] => { + return nodes + .map((node) => { + const matches = + node.title.toLowerCase().includes(query) || node.subtitle.toLowerCase().includes(query); + + const children = filterTree(node.children); + + if (matches || children.length > 0) { + return { ...node, children }; + } + + return undefined; + }) + .filter((node): node is ICategoriesTree => !!node); + }; + + return filterTree(expandedTreeData); + }, [expandedTreeData, searchString]); + + const sortAlphabetically = useCallback(() => { + const aToZSortFunc = (a, b) => categories[a].name.localeCompare(categories[b].name); + try { + const newTree: ICategoriesTree[] = createTreeDataObject(t, categories, mods, aToZSortFunc); + const newOrder = (base: ICategoriesTree[]): string[] => + ([] as string[]).concat( + ...base.map((node) => [node.categoryId, ...newOrder(node.children)]), + ); + + onSetCategoryOrder(gameMode, newOrder(newTree)); + } catch (e: unknown) { + log("error", "Failed to sort categories", e); + } + }, [gameMode, categories, mods, onSetCategoryOrder, t]); + + const toolbarActions: IToolbarAction[] = useMemo( + () => [ + { + label: "Expand All", + iconPath: mdiExpandAll, + showLabel: true, + onClick: expandAll, + }, + { + label: "Collapse All", + onClick: collapseAll, + iconPath: mdiCollapseAll, + showLabel: true, + }, + { + label: "Add Top Level", + iconPath: mdiFolderPlus, + }, + { + label: "Show/Hide Empty", + title: "Show/Hide Empty", + iconPath: showEmpty ? mdiEye : mdiEyeOff, + onClick: toggleEmpty, + }, + { + label: "Sort A-Z", + iconPath: mdiSortAlphabeticalAscending, + onClick: sortAlphabetically, + }, + { + label: "Fetch from Nexus Mods", + iconPath: mdiSync, + }, + ], + [showEmpty, toggleEmpty, collapseAll, expandAll, sortAlphabetically], + ); + + const removeCategory = useCallback( + (id: string) => { + onRemoveCategory(gameMode, id); + }, + [gameMode, onRemoveCategory], + ); + + return { + searchString, + setSearchString, + toolbarActions, + filteredTreeData, + toggleExpand, + removeCategory, + }; +} diff --git a/src/renderer/src/extensions/category_management/index.ts b/src/renderer/src/extensions/category_management/index.ts index 87b3f4691a..5a819d38a3 100644 --- a/src/renderer/src/extensions/category_management/index.ts +++ b/src/renderer/src/extensions/category_management/index.ts @@ -1,6 +1,8 @@ import i18next from "i18next"; import type * as Redux from "redux"; +import { setDialogVisible } from "@/actions"; + import type { IExtensionContext } from "../../types/IExtensionContext"; import type { IState } from "../../types/IState"; import type { TFunction } from "../../util/i18n"; @@ -95,7 +97,7 @@ function init(context: IExtensionContext): boolean { context.registerDialog("categories", CategoryDialog); context.registerAction("mod-icons", 80, "categories", {}, "Categories", () => { - context.api.store.dispatch(showCategoriesDialog(true)); + context.api.store.dispatch(setDialogVisible("categories")); }); context.registerTableAttribute("mods", { diff --git a/src/renderer/src/extensions/category_management/views/CategoryDialog.tsx b/src/renderer/src/extensions/category_management/views/CategoryDialog.tsx index ef317f7487..2b64b278b6 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryDialog.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryDialog.tsx @@ -1,54 +1,32 @@ import * as React from "react"; -import type * as Redux from "redux"; +import { useTranslation } from "react-i18next"; -import { ComponentEx, connect, translate } from "../../../controls/ComponentEx"; import Modal from "../../../controls/Modal"; -import { showCategoriesDialog } from "../actions/session"; -import CategoryList from "./CategoryList"; +import CategoryList, { CategoryListFC } from "./CategoryList"; -interface IConnectedProps { - showDialog: boolean; +interface IProps { + visible: boolean; + onHide: () => void; } -interface IActionProps { - onShowSelf: (show: boolean) => void; -} +function CategoryDialog({ visible, onHide }: IProps) { + const { t } = useTranslation("common"); -type IProps = IConnectedProps & IActionProps; - -class CategoryDialog extends ComponentEx { - public render(): JSX.Element { - const { t, showDialog } = this.props; - - return ( - - - {t("Categories")} - - - - - - ); - } - - private hide = () => { - this.props.onShowSelf(false); - }; -} + return ( + onHide()}> + {t("Categories")} -function mapStateToProps(state: any): IConnectedProps { - return { - showDialog: state.session.categories.showDialog, - }; -} + + + + + -function mapDispatchToProps(dispatch: Redux.Dispatch): IActionProps { - return { - onShowSelf: (show: boolean) => dispatch(showCategoriesDialog(show)), - }; + +
onHide()}>Close me
+
+
+ ); } -export default translate(["common"])( - connect(mapStateToProps, mapDispatchToProps)(CategoryDialog), -) as React.ComponentClass<{}>; +export default CategoryDialog; diff --git a/src/renderer/src/extensions/category_management/views/CategoryList.tsx b/src/renderer/src/extensions/category_management/views/CategoryList.tsx index 00c15e63c9..517a2bc973 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryList.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryList.tsx @@ -1,3 +1,4 @@ +import { mdiMagnify } from "@mdi/js"; import { unknownToError } from "@vortex/shared"; import type PromiseBB from "bluebird"; import * as React from "react"; @@ -13,6 +14,12 @@ import SortableTree from "react-sortable-tree"; import type * as Redux from "redux"; import type { ThunkDispatch } from "redux-thunk"; +import { Input } from "@/ui/components/form/input/Input"; +import { Icon as MdiIcon } from "@/ui/components/icon/Icon"; +import { Listing } from "@/ui/components/listing/Listing"; +import { Toolbar } from "@/ui/components/toolbar/Toolbar"; +import { ToolbarGroup } from "@/ui/components/toolbar/ToolbarGroup"; + import { showDialog } from "../../../actions/notifications"; import ActionDropdown from "../../../controls/ActionDropdown"; import { ComponentEx, connect, translate } from "../../../controls/ComponentEx"; @@ -36,9 +43,11 @@ import { showError } from "../../../util/message"; import { activeGameId } from "../../../util/selectors"; import type { IMod } from "../../mod_management/types/IMod"; import { removeCategory, renameCategory, setCategory, setCategoryOrder } from "../actions/category"; +import useCategoryTree from "../hooks/CategoryTreeHook"; import type { ICategory, ICategoryDictionary } from "../types/ICategoryDictionary"; import type { ICategoriesTree } from "../types/ITrees"; import createTreeDataObject from "../util/createTreeDataObject"; +import CategoryListItem, { CategoryListSkeletonTile } from "./CategoryListItem"; const nop = () => undefined; @@ -155,21 +164,24 @@ class CategoryList extends ComponentEx { return (
+
+ + {t("{{ pos }} of {{ total }}", { replace: { @@ -179,38 +191,41 @@ class CategoryList extends ComponentEx { })}
+ +
+
); @@ -562,9 +577,9 @@ class CategoryList extends ComponentEx { , ], }; @@ -611,6 +626,60 @@ class CategoryList extends ComponentEx { }; } +export function CategoryListFC() { + const { + searchString, + setSearchString, + filteredTreeData, + toolbarActions, + toggleExpand, + removeCategory, + } = useCategoryTree(); + + return ( +
+ + + + +
+ + + setSearchString(e.target.value)} + /> +
+ +
+ + {filteredTreeData?.map((c) => ( + + ))} + +
+
+ ); +} + const emptyObj = {}; function mapStateToProps(state: IState): IConnectedProps { diff --git a/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx new file mode 100644 index 0000000000..4c21ab8b54 --- /dev/null +++ b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx @@ -0,0 +1,190 @@ +import { + mdiCancel, + mdiChevronDown, + mdiChevronRight, + mdiDelete, + mdiFolderPlus, + mdiPlus, + mdiSubdirectoryArrowRight, + mdiTagEdit, +} from "@mdi/js"; +import React, { useState } from "react"; + +import { Button } from "@/ui/components/button/Button"; +import { Input } from "@/ui/components/form/input/Input"; +import { Icon } from "@/ui/components/icon/Icon"; +import { Toolbar } from "@/ui/components/toolbar/Toolbar"; +import type { IToolbarAction } from "@/ui/components/toolbar/ToolbarGroup"; +import { ToolbarGroup } from "@/ui/components/toolbar/ToolbarGroup"; +import { Typography } from "@/ui/components/typography/Typography"; + +import type { ICategoriesTree } from "../types/ITrees"; + +interface ICategoryListItemProps { + category: ICategoriesTree; + expand: (id: string) => void; + remove: (id: string) => void; +} + +export default function CategoryListItem({ category, expand, remove }: ICategoryListItemProps) { + const [addNew, setAddNew] = useState(false); + const [editName, setEditName] = useState(false); + const [newName, setNewName] = useState(category.title); + const [newSubcategoryName, setNewSubcategoryName] = useState(""); + + const { title, subtitle, expanded, children, parentId, categoryId } = category; + const actions: IToolbarAction[] = [ + { + label: "Edit", + iconPath: mdiTagEdit, + onClick: () => setEditMode("name"), + }, + { + label: "New Sub-Category", + iconPath: mdiFolderPlus, + onClick: () => setEditMode("subcategory"), + }, + { + label: "Delete", + iconPath: mdiDelete, + onClick: () => remove(categoryId), + }, + ]; + + const setEditMode = (type?: "name" | "subcategory") => { + if (!type) { + setEditName(false); + setAddNew(false); + } else if (type === "name") { + setEditName(true); + setAddNew(false); + setNewSubcategoryName(""); + } else if (type === "subcategory") { + setAddNew(true); + setEditName(false); + setNewName(title); + } + }; + + return ( +
+
+
+
+ {!!parentId && } + + {children.length > 0 && ( +
+ +
+ {!editName && {title}} + + {editName && ( +
+ setNewName(e.target.value)} + /> + +
+ )} + + + {subtitle} + +
+
+ +
+ + + +
+
+ + {addNew && ( +
+ setNewSubcategoryName(e.target.value)} + /> + +
+ )} + + {children && expanded && ( +
+ {children.map((c) => ( + expand(c.categoryId)} + key={c.categoryId} + remove={remove} + /> + ))} +
+ )} +
+ ); +} + +export const CategoryListSkeletonTile = () => ( +
+
+ +
+
+); From 107aa0aa10c100c94b8041591c1f237680ac715a Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 21 Jun 2026 20:40:10 +0100 Subject: [PATCH 2/9] Category management Switch to new style modal. Added creation of new subcategories --- .../hooks/CategoryTreeHook.ts | 13 +++++++- .../views/CategoryDialog.tsx | 17 ++++------- .../views/CategoryList.tsx | 6 ++-- .../views/CategoryListItem.tsx | 30 +++++++++++++++++-- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts b/src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts index dae8186d0d..eddf164d92 100644 --- a/src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts +++ b/src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "crypto"; + import { mdiCollapseAll, mdiExpandAll, @@ -37,7 +39,7 @@ export default function useCategoryTree() { }; }); - const { onSetCategoryOrder, onRemoveCategory } = useCategoryList(); + const { onSetCategoryOrder, onSetCategory, onRemoveCategory } = useCategoryList(); const treeData = useMemo(() => { if (!categories || !Object.keys(categories).length) return []; @@ -186,6 +188,14 @@ export default function useCategoryTree() { [showEmpty, toggleEmpty, collapseAll, expandAll, sortAlphabetically], ); + const createCategory = useCallback( + (name: string, order: number = 0, parentCategory?: string) => { + const uid = randomUUID(); + onSetCategory(gameMode, uid, { name, parentCategory, order }); + }, + [gameMode, onSetCategory], + ); + const removeCategory = useCallback( (id: string) => { onRemoveCategory(gameMode, id); @@ -199,6 +209,7 @@ export default function useCategoryTree() { toolbarActions, filteredTreeData, toggleExpand, + createCategory, removeCategory, }; } diff --git a/src/renderer/src/extensions/category_management/views/CategoryDialog.tsx b/src/renderer/src/extensions/category_management/views/CategoryDialog.tsx index 2b64b278b6..679f6f26fc 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryDialog.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryDialog.tsx @@ -1,7 +1,8 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; -import Modal from "../../../controls/Modal"; +import { Modal } from "@/ui/components/modal/Modal"; + import CategoryList, { CategoryListFC } from "./CategoryList"; interface IProps { @@ -13,18 +14,12 @@ function CategoryDialog({ visible, onHide }: IProps) { const { t } = useTranslation("common"); return ( - onHide()}> - {t("Categories")} - - - + onHide()}> + - - + - -
onHide()}>Close me
-
+
onHide()}>Close me
); } diff --git a/src/renderer/src/extensions/category_management/views/CategoryList.tsx b/src/renderer/src/extensions/category_management/views/CategoryList.tsx index 517a2bc973..ef6ca7a6d9 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryList.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryList.tsx @@ -633,6 +633,7 @@ export function CategoryListFC() { filteredTreeData, toolbarActions, toggleExpand, + createCategory, removeCategory, } = useCategoryTree(); @@ -642,7 +643,7 @@ export function CategoryListFC() { -
+
( void; remove: (id: string) => void; + createSubcategory: (name: string, order: number, parent: string) => void; } -export default function CategoryListItem({ category, expand, remove }: ICategoryListItemProps) { +export default function CategoryListItem({ + category, + expand, + remove, + createSubcategory, +}: ICategoryListItemProps) { const [addNew, setAddNew] = useState(false); const [editName, setEditName] = useState(false); const [newName, setNewName] = useState(category.title); @@ -55,6 +63,8 @@ export default function CategoryListItem({ category, expand, remove }: ICategory if (!type) { setEditName(false); setAddNew(false); + setNewSubcategoryName(""); + setNewName(title); } else if (type === "name") { setEditName(true); setAddNew(false); @@ -66,6 +76,13 @@ export default function CategoryListItem({ category, expand, remove }: ICategory } }; + const createCategory = (name: string) => { + setEditMode(); + setNewSubcategoryName(""); + createSubcategory(name, 0, categoryId); + expand(categoryId); + }; + return (
@@ -86,7 +103,11 @@ export default function CategoryListItem({ category, expand, remove }: ICategory
- {!editName && {title}} + {!editName && ( + + {title} + + )} {editName && (
@@ -128,7 +149,7 @@ export default function CategoryListItem({ category, expand, remove }: ICategory
- +
@@ -150,8 +171,10 @@ export default function CategoryListItem({ category, expand, remove }: ICategory appearance="moderate" aria-label="Save" brand="primary" + disabled={newSubcategoryName.length <= 2} leftIconPath={mdiPlus} size="sm" + onClick={() => createCategory(newSubcategoryName)} /> +
+ ); +}; + +export default CustomCategoryFetchError; diff --git a/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx index 60a982831f..851dfe9eb8 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx @@ -3,12 +3,14 @@ import { mdiChevronDown, mdiChevronRight, mdiDelete, + mdiDrag, mdiFolderPlus, mdiPlus, + mdiRename, mdiSubdirectoryArrowRight, - mdiTagEdit, } from "@mdi/js"; -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; +import { useDrag, useDrop } from "react-dnd"; import { Button } from "@/ui/components/button/Button"; import { Input } from "@/ui/components/form/input/Input"; @@ -20,11 +22,18 @@ import { Typography } from "@/ui/components/typography/Typography"; import type { ICategoriesTree } from "../types/ITrees"; +const CATEGORY_ITEM = "CATEGORY_ITEM"; + interface ICategoryListItemProps { category: ICategoriesTree; expand: (id: string) => void; remove: (id: string) => void; createSubcategory: (name: string, order: number, parent: string) => void; + moveCategory: ( + sourceId: string, + targetId: string, + position: "before" | "after" | "inside", + ) => void; } export default function CategoryListItem({ @@ -32,17 +41,68 @@ export default function CategoryListItem({ expand, remove, createSubcategory, + moveCategory, }: ICategoryListItemProps) { + const { title, subtitle, expanded, children, parentId, categoryId, order } = category; + const ref = useRef(null); + const [hoverPosition, setHoverPosition] = useState<"before" | "after" | "inside" | null>(null); + const [{ isDragging: _ }, dragRef] = useDrag({ + type: CATEGORY_ITEM, + item: { id: categoryId, parentId }, + collect: (monitor) => ({ isDragging: monitor.isDragging() }), + }); + const [{ isOver, canDrop: __ }, dropRef] = useDrop({ + accept: CATEGORY_ITEM, + collect: (monitor) => ({ + isOver: monitor.isOver({ shallow: true }), + canDrop: monitor.canDrop(), + }), + hover: (dragged: { id: string; parentId: string }, monitor) => { + if (dragged.id === categoryId) { + setHoverPosition(null); + return; + } + const hoverRect = ref.current?.getBoundingClientRect(); + const clientOffset = monitor.getClientOffset(); + if (!hoverRect || !clientOffset) return; + + const relY = clientOffset.y - hoverRect.top; + const h = hoverRect.height; + + if (relY < h / 3) setHoverPosition((p) => (p === "before" ? p : "before")); + else if (relY > (2 * h) / 3) setHoverPosition((p) => (p === "after" ? p : "after")); + else setHoverPosition((p) => (p === "inside" ? p : "inside")); + }, + drop: (dragged: { id: string; parentId: string }, monitor) => { + if (!monitor.didDrop()) { + const position = hoverPosition ?? "after"; + moveCategory(dragged.id, categoryId, position); + } + setHoverPosition(null); + }, + }); + + dragRef(dropRef(ref)); + + const showHoverPosition = isOver ? hoverPosition : null; + + const indicatorClass = + isOver && showHoverPosition === "before" + ? "border-t-[3px] border-blue-500" + : isOver && showHoverPosition === "after" + ? "border-b-[3px] border-blue-500" + : isOver && showHoverPosition === "inside" + ? "shadow-[inset_0_0_0_2px_rgba(59,130,246,0.12)]" + : ""; + const [addNew, setAddNew] = useState(false); const [editName, setEditName] = useState(false); const [newName, setNewName] = useState(category.title); const [newSubcategoryName, setNewSubcategoryName] = useState(""); - - const { title, subtitle, expanded, children, parentId, categoryId } = category; const actions: IToolbarAction[] = [ { label: "Edit", - iconPath: mdiTagEdit, + iconPath: mdiRename, onClick: () => setEditMode("name"), }, { @@ -83,9 +143,11 @@ export default function CategoryListItem({ return (
-
+
+ + {!!parentId && } {children.length > 0 && ( @@ -102,7 +164,10 @@ export default function CategoryListItem({
{!editName && ( - + {title} )} @@ -194,6 +259,7 @@ export default function CategoryListItem({ createSubcategory={createSubcategory} expand={() => expand(c.categoryId)} key={c.categoryId} + moveCategory={moveCategory} remove={remove} /> ))} diff --git a/src/renderer/src/extensions/category_management/views/CategoryListNoResults.tsx b/src/renderer/src/extensions/category_management/views/CategoryListNoResults.tsx new file mode 100644 index 0000000000..58673f7b3b --- /dev/null +++ b/src/renderer/src/extensions/category_management/views/CategoryListNoResults.tsx @@ -0,0 +1,45 @@ +import { mdiFolderPlus, mdiUpdate } from "@mdi/js"; +import React from "react"; + +import { Button } from "@/ui/components/button/Button"; +import { Typography } from "@/ui/components/typography/Typography"; + +interface ICustomNoCategoryResultsProps { + fetch: () => void; + create: () => void; + searchTerm?: string; +} + +const CustomNoCategoryResults = ({ fetch, create, searchTerm }: ICustomNoCategoryResultsProps) => { + return ( +
+ + {!searchTerm && "No categories"} + + {!!searchTerm && `No categories matching "${searchTerm}"`} + + + + + +
+ ); +}; + +export default CustomNoCategoryResults; From 08ab894218bce12e71a30103b7077a92cae27c40 Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 28 Jun 2026 21:33:45 +0100 Subject: [PATCH 5/9] Updated category modal to use all new components --- .../hooks/CategoriesFromNexusMods.ts | 116 ------ .../hooks/CategoryListHook.ts | 83 ----- .../hooks/CategoryToolbarActionsHook.ts | 90 +++++ .../hooks/CategoryTreeActionsHook.ts | 223 +++++++++++ .../hooks/CategoryTreeDataHook.ts | 109 ++++++ .../hooks/CategoryTreeHook.ts | 352 +++--------------- .../hooks/CategroyTreeStateHook.ts | 68 ++++ .../hooks/CateogryTreeSelectionHook.ts | 129 +++++++ .../types/ICategoriesTreeEntry.ts | 11 + .../category_management/types/ITrees.ts | 12 - .../util/buildCategoryTree.ts | 37 ++ .../util/createTreeDataObject.ts | 129 ------- .../util/generateSubtitle.ts | 31 -- .../util/getCategoriesFromNexusMods.ts | 31 ++ .../views/CategoryList.tsx | 23 +- .../views/CategoryListFetchError.tsx | 2 +- .../views/CategoryListItem.tsx | 24 +- .../views/CategorySubtitle.tsx | 31 ++ 18 files changed, 804 insertions(+), 697 deletions(-) delete mode 100644 src/renderer/src/extensions/category_management/hooks/CategoriesFromNexusMods.ts delete mode 100644 src/renderer/src/extensions/category_management/hooks/CategoryListHook.ts create mode 100644 src/renderer/src/extensions/category_management/hooks/CategoryToolbarActionsHook.ts create mode 100644 src/renderer/src/extensions/category_management/hooks/CategoryTreeActionsHook.ts create mode 100644 src/renderer/src/extensions/category_management/hooks/CategoryTreeDataHook.ts create mode 100644 src/renderer/src/extensions/category_management/hooks/CategroyTreeStateHook.ts create mode 100644 src/renderer/src/extensions/category_management/hooks/CateogryTreeSelectionHook.ts create mode 100644 src/renderer/src/extensions/category_management/types/ICategoriesTreeEntry.ts delete mode 100644 src/renderer/src/extensions/category_management/types/ITrees.ts create mode 100644 src/renderer/src/extensions/category_management/util/buildCategoryTree.ts delete mode 100644 src/renderer/src/extensions/category_management/util/createTreeDataObject.ts delete mode 100644 src/renderer/src/extensions/category_management/util/generateSubtitle.ts create mode 100644 src/renderer/src/extensions/category_management/util/getCategoriesFromNexusMods.ts create mode 100644 src/renderer/src/extensions/category_management/views/CategorySubtitle.tsx diff --git a/src/renderer/src/extensions/category_management/hooks/CategoriesFromNexusMods.ts b/src/renderer/src/extensions/category_management/hooks/CategoriesFromNexusMods.ts deleted file mode 100644 index f84126dca6..0000000000 --- a/src/renderer/src/extensions/category_management/hooks/CategoriesFromNexusMods.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { IGameInfo, IModCategory } from "@nexusmods/nexus-api"; -import { useCallback, useState } from "react"; -import { useSelector, useStore } from "react-redux"; - -import { updateCategories } from "@/actions"; -import { log } from "@/logging"; -import type { IState } from "@/types/api"; -import { getApplication, getGame, nexusGameId } from "@/util/api"; -import { activeGameId } from "@/util/selectors"; - -import type { ICategoryDictionary } from "../types/ICategoryDictionary"; - -const GameV1URL = (domainName: string) => `https://api.nexusmods.com/v1/games/${domainName}.json`; - -async function getCategories( - domainName: string, - token: string, -): Promise { - // This could be outsourced to a library, but it's unclear how - try { - const url = GameV1URL(domainName); - const headers = { - Accept: "application/json", - "Application-Name": "Vortex", - "Application-Version": getApplication().version, - Authorization: `Bearer ${token}`, - }; - const res = await fetch(url, { headers }); - if (!res.ok) throw new Error(`Server responded with HTTP ${res.status}`); - const game: IGameInfo = (await res.json()) as IGameInfo; - return game.categories; - } catch (e: unknown) { - log("warn", "Failed to get categories for game", e); - if ((e as Error).message === "Failed to fetch") - throw new Error("An unexpcted network error occurred.", { cause: e }); - throw e; - } -} - -type IStateWithCreds = IState & { - confidential: { - account: { nexus: { OAuthCredentials: { token: string; refreshToken: string } } }; - }; -}; - -export default function useCategoriesFromNexusMods() { - const store = useStore(); - const [error, setError] = useState<{ title: string; detail: string } | undefined>(undefined); - const [isLoading, setIsLoading] = useState(false); - const [isError, setIsError] = useState(false); - - const { OAuthCredentials, domainName, gameId, categories } = useSelector( - (state: IStateWithCreds) => { - const gameId = activeGameId(state); - const game = getGame(gameId); - const domainName = nexusGameId(game); - return { - OAuthCredentials: state.confidential.account.nexus.OAuthCredentials, - domainName, - gameId, - categories: state.persistent.categories?.[gameId] || {}, - }; - }, - ); - - const fetchCategoriesForGame = useCallback( - async (replaceAll: boolean = false) => { - setIsLoading(true); - setIsError(false); - if (!OAuthCredentials.token) return; - try { - const newCategories = await getCategories(domainName, OAuthCredentials.token); - const categoryMap: ICategoryDictionary = createDictionaryFromList(newCategories); - if (!replaceAll) { - // Try and preserve existing custom categories. - const keysToSave = Object.keys(categories).filter( - (k) => isNaN(Number(k)) || Number(k) > 1000, - ); - keysToSave.forEach((k) => (categoryMap[k] = categories[k])); - } - return store.dispatch(updateCategories(gameId, categoryMap)); - } catch (e: unknown) { - const err = e as Error; - setIsError(true); - setError({ title: "Failed to fetch categories from Nexus Mods", detail: err.message }); - } finally { - setIsLoading(false); - } - }, - [OAuthCredentials, domainName, gameId, store, categories], - ); - - const clearError = () => { - setIsError(false); - setError(undefined); - }; - - return { fetchCategoriesForGame, error, isLoading, isError, clearError }; -} - -function createDictionaryFromList(list: IModCategory[]): ICategoryDictionary { - const dict = {}; - let counter = 1; - list.forEach((category) => { - const parent = - category.parent_category === false ? undefined : category.parent_category.toString(); - dict[String(category.category_id)] = { - name: category.name, - parentCategory: parent, - order: counter, - }; - counter++; - }); - - return dict; -} diff --git a/src/renderer/src/extensions/category_management/hooks/CategoryListHook.ts b/src/renderer/src/extensions/category_management/hooks/CategoryListHook.ts deleted file mode 100644 index 4e4f96e236..0000000000 --- a/src/renderer/src/extensions/category_management/hooks/CategoryListHook.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { useSelector, useStore } from "react-redux"; -import type { AnyAction } from "redux"; - -import { - removeCategory, - renameCategory, - setCategory, - setCategoryOrder, - showDialog, -} from "@/actions"; -import type { DialogActions, DialogType, IDialogContent, IErrorOptions, IState } from "@/types/api"; -import type { IToolbarAction } from "@/ui/components/toolbar/ToolbarGroup"; -import { showError } from "@/util/message"; -import { activeGameId } from "@/util/selectors"; - -import type { ICategory } from "../types/ICategoryDictionary"; - -export default function useCategoryList() { - const { gameMode, language, categories, mods } = useSelector((state: IState) => { - const gameMode = activeGameId(state); - return { - gameMode, - language: state.settings.interface.language, - categories: state.persistent.categories?.[gameMode] || {}, - mods: state.persistent.mods[gameMode], - }; - }); - - const { t } = useTranslation("common"); - - const store = useStore(); - - const onRenameCategory = useCallback( - (gameId: string, categoryId: string, newCategory: string) => - store.dispatch(renameCategory(gameId, categoryId, newCategory)), - [store], - ); - - const onSetCategory = useCallback( - (gameId: string, categoryId: string, category: ICategory) => - store.dispatch(setCategory(gameId, categoryId, category)), - [store], - ); - - const onRemoveCategory = useCallback( - (gameId: string, categoryId: string) => store.dispatch(removeCategory(gameId, categoryId)), - [store], - ); - - const onSetCategoryOrder = useCallback( - (gameId: string, categoryIds: string[]) => - store.dispatch(setCategoryOrder(gameId, categoryIds)), - [store], - ); - - const onShowError = useCallback( - (message: string, details: string | Error, options: IErrorOptions) => - showError(store.dispatch, message, details, options), - [store], - ); - - const onShowDialog = useCallback( - (type: DialogType, title: string, content: IDialogContent, actions: DialogActions) => - store.dispatch(showDialog(type, title, content, actions) as unknown as AnyAction), - [store], - ); - - return { - t, - gameMode, - language, - categories, - mods, - onRenameCategory, - onSetCategory, - onRemoveCategory, - onSetCategoryOrder, - onShowError, - onShowDialog, - }; -} diff --git a/src/renderer/src/extensions/category_management/hooks/CategoryToolbarActionsHook.ts b/src/renderer/src/extensions/category_management/hooks/CategoryToolbarActionsHook.ts new file mode 100644 index 0000000000..5a6e14e91b --- /dev/null +++ b/src/renderer/src/extensions/category_management/hooks/CategoryToolbarActionsHook.ts @@ -0,0 +1,90 @@ +import { + mdiCollapseAll, + mdiExpandAll, + mdiEye, + mdiEyeOff, + mdiFolderPlus, + mdiSortAlphabeticalAscending, + mdiSync, +} from "@mdi/js"; +import { useMemo } from "react"; + +import type { IToolbarAction } from "@/ui/components/toolbar/ToolbarGroup"; + +import type { ICategoriesTreeEntry } from "../types/ICategoriesTreeEntry"; + +interface ICategoryToolbarActionsProps { + expanded: Set; + showEmpty: boolean; + treeData: ICategoriesTreeEntry[]; + expandAll: (ids: string[]) => void; + collapseAll: () => void; + addParentVisible: boolean; + setAddParentVisible: (show?: boolean) => void; + toggleEmpty: () => void; + sortAlphabetically: () => void; + importCategoriesFromNexusMods: (replace?: boolean) => void; +} + +export default function useCategoryToolbarActions(props: ICategoryToolbarActionsProps) { + const { + addParentVisible, + setAddParentVisible, + expanded, + showEmpty, + treeData, + expandAll, + collapseAll, + toggleEmpty, + sortAlphabetically, + importCategoriesFromNexusMods, + } = props; + + const toolbarActions: IToolbarAction[] = useMemo( + () => [ + { + label: expanded.size === 0 ? "Expand All" : "Collapse All", + iconPath: expanded.size === 0 ? mdiExpandAll : mdiCollapseAll, + showLabel: true, + onClick: () => + expanded.size === 0 ? expandAll(treeData.flatMap((t) => t.categoryId)) : collapseAll(), + }, + { + label: showEmpty ? "Hide Empty" : "Show Empty", + iconPath: showEmpty ? mdiEyeOff : mdiEye, + showLabel: true, + onClick: toggleEmpty, + }, + { + label: "Add Top Level", + iconPath: mdiFolderPlus, + onClick: () => setAddParentVisible(!addParentVisible), + }, + { + label: "Sort A-Z", + iconPath: mdiSortAlphabeticalAscending, + onClick: sortAlphabetically, + }, + { + label: "Fetch from Nexus Mods", + iconPath: mdiSync, + onClick: () => { + void importCategoriesFromNexusMods(false); + }, + }, + ], + [ + showEmpty, + toggleEmpty, + collapseAll, + expandAll, + sortAlphabetically, + expanded, + importCategoriesFromNexusMods, + addParentVisible, + setAddParentVisible, + treeData, + ], + ); + return toolbarActions; +} diff --git a/src/renderer/src/extensions/category_management/hooks/CategoryTreeActionsHook.ts b/src/renderer/src/extensions/category_management/hooks/CategoryTreeActionsHook.ts new file mode 100644 index 0000000000..7229c1c94e --- /dev/null +++ b/src/renderer/src/extensions/category_management/hooks/CategoryTreeActionsHook.ts @@ -0,0 +1,223 @@ +import { randomUUID } from "crypto"; + +import type { IModCategory } from "@nexusmods/nexus-api"; +import { useCallback } from "react"; +import { useStore } from "react-redux"; + +import { updateCategories } from "@/actions"; +import { log } from "@/logging"; +import type { IMod } from "@/types/api"; + +import type { ICategoriesTreeEntry } from "../types/ICategoriesTreeEntry"; +import type { ICategory } from "../types/ICategoryDictionary"; +import type { ICategoryDictionary } from "../types/ICategoryDictionary"; +import buildCategoryTree from "../util/buildCategoryTree"; +import getGameCategories from "../util/getCategoriesFromNexusMods"; + +interface ICategoryTreeActionsProps { + categories: ICategoryDictionary; + modsByCategory: { [categoryId: string]: IMod[] }; + gameId: string; + onSetCategory: (gameId: string, categoryId: string, category: ICategory) => void; + onRemoveCategory: (categoryId: string) => void; + onSetCategoryOrder: (gameId: string, categoryIds: string[]) => void; + OAuthCredentials: { token: string }; + isFetching: boolean; + isFetchError: boolean; + setIsFetchError: (isError: boolean) => void; + setFetchError: (err: { title: string; detail: string }) => void; + setIsFetching: (fetching: boolean) => void; + domainName: string; +} +export default function useCategoryTreeActions(props: ICategoryTreeActionsProps) { + const { + OAuthCredentials, + categories, + modsByCategory, + onSetCategory, + onRemoveCategory, + onSetCategoryOrder, + setIsFetching, + setFetchError, + isFetching, + setIsFetchError, + gameId, + domainName, + } = props; + + const store = useStore(); + + const importCategoriesFromNexusMods = useCallback( + async (replaceAll?: boolean) => { + if (isFetching) return; + setIsFetching(true); + setIsFetchError(false); + if (!OAuthCredentials.token) throw new Error("You must be logged in to use this feature."); + try { + const apiCategories = await getGameCategories(domainName, OAuthCredentials.token); + const categoryMap = createDictionaryFromList(apiCategories); + if (!replaceAll) { + // Try and preserve existing custom categories. + const keysToSave = Object.keys(categories).filter( + (k) => isNaN(Number(k)) || Number(k) > 1000, + ); + keysToSave.forEach((k) => (categoryMap[k] = categories[k])); + } + return store.dispatch(updateCategories(gameId, categoryMap)); + } catch (e: unknown) { + const err = e as Error; + setIsFetchError(true); + setFetchError({ + title: "Failed to fetch categories from Nexus Mods", + detail: err.message, + }); + } finally { + setIsFetching(false); + } + }, + [ + OAuthCredentials, + domainName, + gameId, + categories, + setFetchError, + setIsFetchError, + setIsFetching, + store, + isFetching, + ], + ); + + const createCategory = useCallback( + (name: string, order: number = 0, parentCategory?: string) => { + const uid = randomUUID(); + onSetCategory(gameId, uid, { name, parentCategory, order }); + }, + [gameId, onSetCategory], + ); + + const removeCategory = useCallback( + (id: string) => { + onRemoveCategory(id); + }, + [onRemoveCategory], + ); + + const sortAlphabetically = useCallback(() => { + const aToZSortFunc = (a: string, b: string) => + categories[a].name.localeCompare(categories[b].name); + try { + const newTree: ICategoriesTreeEntry[] = buildCategoryTree( + categories, + modsByCategory, + undefined, + aToZSortFunc, + ); + const newOrder = (base: ICategoriesTreeEntry[]): string[] => + ([] as string[]).concat( + ...base.map((node) => [node.categoryId, ...newOrder(node.children)]), + ); + + onSetCategoryOrder(gameId, newOrder(newTree)); + } catch (e: unknown) { + log("error", "Failed to sort categories", e); + } + }, [gameId, categories, modsByCategory, onSetCategoryOrder]); + + const moveCategory = useCallback( + (sourceId: string, targetId: string, position: "before" | "after" | "inside") => { + const tree = buildCategoryTree(categories, modsByCategory); + + // find the node in the tree + const findNode = ( + nodes: ICategoriesTreeEntry[], + id: string, + ): ICategoriesTreeEntry | undefined => { + for (const node of nodes) { + if (node.categoryId === id) return node; + const child = findNode(node.children, id); + if (child) return child; + } + }; + + const sourceNode = findNode(tree, sourceId); + const sourceParent = findNode(tree, sourceNode?.parentId); + const targetNode = findNode(tree, targetId); + if (!sourceNode || !targetNode) return; + + // Remove the source from it's current list + if (sourceParent) { + sourceParent.children = sourceParent.children.filter((c) => c.categoryId !== sourceId); + } else { + tree.splice( + tree.findIndex((c) => c.categoryId === sourceId), + 1, + ); + } + + const destinationParent: Pick = + position === "inside" + ? targetNode + : findNode(tree, targetNode.parentId) || { children: tree, categoryId: undefined }; + const targetIndex = destinationParent.children.findIndex((c) => c.categoryId === targetId); + + const insertIndex = + position === "before" + ? targetIndex + : position === "after" + ? targetIndex + 1 + : destinationParent.children.length; + + destinationParent.children.splice(insertIndex, 0, sourceNode); + + if (position === "inside") { + sourceNode.parentId = targetNode.categoryId; + } else { + sourceNode.parentId = destinationParent?.categoryId; + } + + // Flatten the treee + const flatten = (nodes: ICategoriesTreeEntry[]): string[] => + nodes.flatMap((node) => [node.categoryId, ...flatten(node.children)]); + + const newOrder = flatten(tree); + + if (sourceNode.parentId !== categories[sourceId].parentCategory) { + onSetCategory(gameId, sourceId, { + name: categories[sourceId].name, + parentCategory: sourceNode.parentId, + order: 0, + }); + } + onSetCategoryOrder(gameId, newOrder); + }, + [categories, modsByCategory, gameId, onSetCategory, onSetCategoryOrder], + ); + + return { + createCategory, + removeCategory, + sortAlphabetically, + moveCategory, + importCategoriesFromNexusMods, + }; +} + +function createDictionaryFromList(list: IModCategory[]): ICategoryDictionary { + const dict = {}; + let counter = 1; + list + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((category) => { + const parent = + category.parent_category === false ? undefined : category.parent_category.toString(); + dict[String(category.category_id)] = { + name: category.name, + parentCategory: parent, + order: counter, + }; + counter++; + }); + + return dict; +} diff --git a/src/renderer/src/extensions/category_management/hooks/CategoryTreeDataHook.ts b/src/renderer/src/extensions/category_management/hooks/CategoryTreeDataHook.ts new file mode 100644 index 0000000000..60284db829 --- /dev/null +++ b/src/renderer/src/extensions/category_management/hooks/CategoryTreeDataHook.ts @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useRef } from "react"; + +import type { IMod } from "@/types/api"; + +import type { ICategoriesTreeEntry } from "../types/ICategoriesTreeEntry"; +import type { ICategoryDictionary } from "../types/ICategoryDictionary"; +import buildCategoryTree from "../util/buildCategoryTree"; + +interface ICategoryTreeDataProps { + categories: ICategoryDictionary; + modsbyCategory: { [categoryId: string]: IMod[] }; + expanded: Set; + showEmpty: boolean; + searchString: string; + setExpanded: (categories: Set) => void; +} + +export default function useCategoryTreeData(props: ICategoryTreeDataProps) { + const { categories, modsbyCategory, expanded, showEmpty, searchString, setExpanded } = props; + const didOpenRootCategoriesRef = useRef(false); + + const treeData = useMemo(() => { + if (!categories || !Object.keys(categories).length) return []; + return buildCategoryTree(categories, modsbyCategory); + }, [categories, modsbyCategory]); + + const nonEmptyCategories = useMemo(() => { + const result = new Set(); + + const markNonEmpty = (node: ICategoriesTreeEntry): boolean => { + let hasMods = node.nestedModCount > 0; + + for (const child of node.children) { + if (markNonEmpty(child)) { + hasMods = true; + } + } + + if (hasMods) { + result.add(node.categoryId); + } + + return hasMods; + }; + + treeData.forEach(markNonEmpty); + + return result; + }, [treeData]); + + const expandedTreeData = useMemo(() => { + const applyExpand = (categoryTree: ICategoriesTreeEntry[]): ICategoriesTreeEntry[] => { + const filtered = showEmpty + ? new Set(categoryTree.map((obj) => obj.categoryId)) + : nonEmptyCategories; + + return categoryTree + .map((obj) => { + if (!filtered.has(obj.categoryId)) return undefined; + const copy = { ...obj }; + copy.expanded = expanded.has(copy.categoryId); + copy.children = applyExpand(copy.children); + return copy; + }) + .filter(Boolean); + }; + + return applyExpand(treeData); + }, [treeData, showEmpty, expanded, nonEmptyCategories]); + + const filteredTreeData = useMemo(() => { + if (!searchString?.trim()) return expandedTreeData; + + const query = searchString.toLowerCase(); + + const filterTree = (nodes: ICategoriesTreeEntry[]): ICategoriesTreeEntry[] => { + return nodes + .map((node) => { + const matches = node.title.toLowerCase().includes(query); + + const children = filterTree(node.children); + + if (matches || children.length > 0) { + return { ...node, children }; + } + + return undefined; + }) + .filter((node): node is ICategoriesTreeEntry => !!node); + }; + + return filterTree(expandedTreeData); + }, [expandedTreeData, searchString]); + + useEffect(() => { + if (!didOpenRootCategoriesRef.current && treeData.length > 0) { + const topLevel = treeData.filter((c) => !c.parentId); + setExpanded(new Set(topLevel.map((node) => node.categoryId))); + didOpenRootCategoriesRef.current = true; + } + }, [treeData, setExpanded]); + + return { + treeData, + nonEmptyCategories, + expandedTreeData, + filteredTreeData, + }; +} diff --git a/src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts b/src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts index 818cfb6183..d4947586e1 100644 --- a/src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts +++ b/src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts @@ -1,322 +1,56 @@ -import { randomUUID } from "crypto"; - -import { - mdiCollapseAll, - mdiExpandAll, - mdiEye, - mdiEyeOff, - mdiFolderPlus, - mdiSortAlphabeticalAscending, - mdiSync, -} from "@mdi/js"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; - -import { log } from "@/logging"; -import type { IState } from "@/types/api"; -import type { IToolbarAction } from "@/ui/components/toolbar/ToolbarGroup"; -import { activeGameId } from "@/util/selectors"; -import type { ICategoriesTree } from "../types/ITrees"; -import createTreeDataObject from "../util/createTreeDataObject"; -import useCategoriesFromNexusMods from "./CategoriesFromNexusMods"; -import useCategoryList from "./CategoryListHook"; +import useCategoryToolbarActions from "./CategoryToolbarActionsHook"; +import useCategoryTreeActions from "./CategoryTreeActionsHook"; +import useCategoryTreeData from "./CategoryTreeDataHook"; +import useCategoryTreeState from "./CategroyTreeStateHook"; +import useCategoryTreeSelection from "./CateogryTreeSelectionHook"; export default function useCategoryTree() { - const didOpenRootCategoriesRef = useRef(false); - const [addParentVisible, setAddParentVisible] = useState(false); - const [expanded, setExpanded] = useState>(() => new Set()); - const [showEmpty, setShowEmpty] = useState(true); - const [searchString, setSearchString] = useState(""); - const [newParentCategoryName, setNewParentCategoryName] = useState(""); - const { t } = useTranslation("common"); - const { categories, mods, gameMode } = useSelector((state: IState) => { - const gameMode = activeGameId(state); - return { - gameMode, - mods: state.persistent.mods[gameMode], - categories: state.persistent.categories?.[gameMode] || {}, - }; + const state = useCategoryTreeState(); + const selection = useCategoryTreeSelection(); + + const tree = useCategoryTreeData({ + categories: selection.categories, + modsbyCategory: selection.modsByCategory, + expanded: state.expanded, + setExpanded: state.setExpanded, + showEmpty: state.showEmpty, + searchString: state.searchString, }); - const { - isError, - isLoading, - fetchCategoriesForGame, - error: fetchError, - clearError, - } = useCategoriesFromNexusMods(); - const { onSetCategoryOrder, onSetCategory, onRemoveCategory } = useCategoryList(); - - const startCreateParentCategory = (show: boolean = true) => { - if (!show) setNewParentCategoryName(""); - setAddParentVisible(show); - }; - - const treeData = useMemo(() => { - if (!categories || !Object.keys(categories).length) return []; - return createTreeDataObject(t, categories, mods); - }, [categories, mods, t]); - - const nonEmptyCategories = useMemo(() => { - const result = new Set(); - - const markNonEmpty = (node: ICategoriesTree): boolean => { - let hasMods = node.modCount > 0; - - for (const child of node.children) { - if (markNonEmpty(child)) { - hasMods = true; - } - } - - if (hasMods) { - result.add(node.categoryId); - } - - return hasMods; - }; - - treeData.forEach(markNonEmpty); - - return result; - }, [treeData]); - - const toggleExpand = useCallback((categoryId: string) => { - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(categoryId)) next.delete(categoryId); - else next.add(categoryId); - return next; - }); - }, []); - - const expandedTreeData = useMemo(() => { - const applyExpand = (categoryTree: ICategoriesTree[]): ICategoriesTree[] => { - const filtered = showEmpty - ? new Set(categoryTree.map((obj) => obj.categoryId)) - : nonEmptyCategories; - - return categoryTree - .map((obj) => { - if (!filtered.has(obj.categoryId)) return undefined; - const copy = { ...obj }; - copy.expanded = expanded.has(copy.categoryId); - copy.children = applyExpand(copy.children); - return copy; - }) - .filter(Boolean); - }; - - return applyExpand(treeData); - }, [treeData, showEmpty, expanded, nonEmptyCategories]); - - const expandAll = useCallback(() => { - setExpanded(new Set(treeData.map((c) => c.categoryId))); - }, [treeData]); - - const collapseAll = useCallback(() => { - setExpanded(new Set()); - }, []); - - const toggleEmpty = useCallback(() => { - setShowEmpty((prev) => !prev); - }, []); - - const filteredTreeData = useMemo(() => { - if (!searchString?.trim()) return expandedTreeData; - - const query = searchString.toLowerCase(); - - const filterTree = (nodes: ICategoriesTree[]): ICategoriesTree[] => { - return nodes - .map((node) => { - const matches = - node.title.toLowerCase().includes(query) || node.subtitle.toLowerCase().includes(query); - - const children = filterTree(node.children); - - if (matches || children.length > 0) { - return { ...node, children }; - } - - return undefined; - }) - .filter((node): node is ICategoriesTree => !!node); - }; - - return filterTree(expandedTreeData); - }, [expandedTreeData, searchString]); - - const sortAlphabetically = useCallback(() => { - const aToZSortFunc = (a: string, b: string) => - categories[a].name.localeCompare(categories[b].name); - try { - const newTree: ICategoriesTree[] = createTreeDataObject(t, categories, mods, aToZSortFunc); - const newOrder = (base: ICategoriesTree[]): string[] => - ([] as string[]).concat( - ...base.map((node) => [node.categoryId, ...newOrder(node.children)]), - ); - - onSetCategoryOrder(gameMode, newOrder(newTree)); - } catch (e: unknown) { - log("error", "Failed to sort categories", e); - } - }, [gameMode, categories, mods, onSetCategoryOrder, t]); - - const moveCategory = useCallback( - (sourceId: string, targetId: string, position: "before" | "after" | "inside") => { - console.log("moveCategory called", { sourceId, targetId, position }); - const tree = createTreeDataObject(t, categories, mods); - - // find the node in the tree - const findNode = (nodes: ICategoriesTree[], id: string): ICategoriesTree | undefined => { - for (const node of nodes) { - if (node.categoryId === id) return node; - const child = findNode(node.children, id); - if (child) return child; - } - }; - - const sourceNode = findNode(tree, sourceId); - const sourceParent = findNode(tree, sourceNode?.parentId); - const targetNode = findNode(tree, targetId); - if (!sourceNode || !targetNode) return; - - // Remove the source from it's current list - if (sourceParent) { - sourceParent.children = sourceParent.children.filter((c) => c.categoryId !== sourceId); - } else { - tree.splice( - tree.findIndex((c) => c.categoryId === sourceId), - 1, - ); - } - - const destinationParent: Pick = - position === "inside" - ? targetNode - : findNode(tree, targetNode.parentId) || { children: tree, categoryId: undefined }; - const targetIndex = destinationParent.children.findIndex((c) => c.categoryId === targetId); - - const insertIndex = - position === "before" - ? targetIndex - : position === "after" - ? targetIndex + 1 - : destinationParent.children.length; - - destinationParent.children.splice(insertIndex, 0, sourceNode); - - if (position === "inside") { - sourceNode.parentId = targetNode.categoryId; - } else { - sourceNode.parentId = destinationParent?.categoryId; - } - - // Flatten the treee - const flatten = (nodes: ICategoriesTree[]): string[] => - nodes.flatMap((node) => [node.categoryId, ...flatten(node.children)]); - - const newOrder = flatten(tree); - - if (sourceNode.parentId !== categories[sourceId].parentCategory) { - onSetCategory(gameMode, sourceId, { - name: categories[sourceId].name, - parentCategory: sourceNode.parentId, - order: 0, - }); - } - onSetCategoryOrder(gameMode, newOrder); - }, - [t, categories, mods, gameMode, onSetCategory, onSetCategoryOrder], - ); - const toolbarActions: IToolbarAction[] = useMemo( - () => [ - { - label: expanded.size === 0 ? "Expand All" : "Collapse All", - iconPath: expanded.size === 0 ? mdiExpandAll : mdiCollapseAll, - showLabel: true, - onClick: () => (expanded.size === 0 ? expandAll() : collapseAll()), - }, - { - label: showEmpty ? "Hide Empty" : "Show Empty", - iconPath: showEmpty ? mdiEyeOff : mdiEye, - showLabel: true, - onClick: toggleEmpty, - }, - { - label: "Add Top Level", - iconPath: mdiFolderPlus, - onClick: () => setAddParentVisible(!addParentVisible), - }, - { - label: "Sort A-Z", - iconPath: mdiSortAlphabeticalAscending, - onClick: sortAlphabetically, - }, - { - label: "Fetch from Nexus Mods", - iconPath: mdiSync, - onClick: () => { - void fetchCategoriesForGame(false); - }, - }, - ], - [ - showEmpty, - toggleEmpty, - collapseAll, - expandAll, - sortAlphabetically, - expanded, - fetchCategoriesForGame, - addParentVisible, - ], - ); - - const createCategory = useCallback( - (name: string, order: number = 0, parentCategory?: string) => { - const uid = randomUUID(); - onSetCategory(gameMode, uid, { name, parentCategory, order }); - }, - [gameMode, onSetCategory], - ); - - const removeCategory = useCallback( - (id: string) => { - onRemoveCategory(gameMode, id); - }, - [gameMode, onRemoveCategory], - ); + const actions = useCategoryTreeActions({ + categories: selection.categories, + modsByCategory: selection.modsByCategory, + gameId: selection.gameId, + domainName: selection.domainName, + onRemoveCategory: selection.onRemoveCategory, + onSetCategory: selection.onSetCategory, + onSetCategoryOrder: selection.onSetCategoryOrder, + OAuthCredentials: selection.OAuthCredentials, + isFetching: state.isFetching, + isFetchError: state.isFetchError, + setIsFetchError: state.setIsFetchError, + setIsFetching: state.setIsFetching, + setFetchError: state.setFetchError, + }); - useEffect(() => { - if (!didOpenRootCategoriesRef.current && treeData.length > 0) { - const topLevel = treeData.filter((c) => !c.parentId); - setExpanded(new Set(topLevel.map((node) => node.categoryId))); // eslint-disable-line @eslint-react/set-state-in-effect - didOpenRootCategoriesRef.current = true; - } - }, [treeData]); + const toolbarActions = useCategoryToolbarActions({ + expanded: state.expanded, + showEmpty: state.showEmpty, + treeData: tree.treeData, + setAddParentVisible: state.startCreateParentCategory, + ...state, + ...actions, + }); return { - searchString, - setSearchString, + t, + ...state, + ...tree, + ...actions, toolbarActions, - filteredTreeData, - toggleExpand, - createCategory, - removeCategory, - moveCategory, - fetchError, - isError, - isLoading, - fetchCategoriesForGame, - addParentVisible, - startCreateParentCategory, - newParentCategoryName, - setNewParentCategoryName, - clearError, + onRenameCategory: selection.onRenameCategory, }; } diff --git a/src/renderer/src/extensions/category_management/hooks/CategroyTreeStateHook.ts b/src/renderer/src/extensions/category_management/hooks/CategroyTreeStateHook.ts new file mode 100644 index 0000000000..9873d9ed8a --- /dev/null +++ b/src/renderer/src/extensions/category_management/hooks/CategroyTreeStateHook.ts @@ -0,0 +1,68 @@ +import { useCallback, useState } from "react"; + +export default function useCategoryTreeState() { + const [addParentVisible, setAddParentVisible] = useState(false); + const [expanded, setExpanded] = useState>(() => new Set()); + const [showEmpty, setShowEmpty] = useState(true); + const [searchString, setSearchString] = useState(""); + const [newParentCategoryName, setNewParentCategoryName] = useState(""); + + // Fetch error state values + const [fetchError, setFetchError] = useState<{ title: string; detail: string } | undefined>(); + const [isFetching, setIsFetching] = useState(false); + const [isFetchError, setIsFetchError] = useState(false); + + const toggleExpand = useCallback((categoryId: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(categoryId)) next.delete(categoryId); + else next.add(categoryId); + return next; + }); + }, []); + + const toggleEmpty = useCallback(() => { + setShowEmpty((prev) => !prev); + }, []); + + const startCreateParentCategory = (show: boolean = true) => { + if (!show) setNewParentCategoryName(""); + setAddParentVisible(show); + }; + + const expandAll = useCallback((ids: string[]) => { + setExpanded(new Set(ids)); + }, []); + + const collapseAll = useCallback(() => { + setExpanded(new Set()); + }, []); + + const clearFetchError = () => { + setIsFetchError(false); + setFetchError(undefined); + }; + + return { + addParentVisible, + expanded, + showEmpty, + searchString, + newParentCategoryName, + setSearchString, + setNewParentCategoryName, + toggleExpand, + toggleEmpty, + startCreateParentCategory, + expandAll, + collapseAll, + setExpanded, + fetchError, + isFetchError, + isFetching, + setFetchError, + setIsFetching, + setIsFetchError, + clearFetchError, + }; +} diff --git a/src/renderer/src/extensions/category_management/hooks/CateogryTreeSelectionHook.ts b/src/renderer/src/extensions/category_management/hooks/CateogryTreeSelectionHook.ts new file mode 100644 index 0000000000..0863b9a3f0 --- /dev/null +++ b/src/renderer/src/extensions/category_management/hooks/CateogryTreeSelectionHook.ts @@ -0,0 +1,129 @@ +import { randomUUID } from "crypto"; + +import { useCallback, useMemo } from "react"; +import { useSelector, useStore } from "react-redux"; +import type { AnyAction } from "redux"; + +import { + removeCategory, + renameCategory, + setCategory, + setCategoryOrder, + showDialog, +} from "@/actions"; +import type { + DialogActions, + DialogType, + IDialogContent, + IErrorOptions, + IMod, + IState, +} from "@/types/api"; +import { getGame, nexusGameId } from "@/util/api"; +import { showError } from "@/util/message"; +import { activeGameId } from "@/util/selectors"; + +import type { ICategory } from "../types/ICategoryDictionary"; + +type IStateWithCreds = IState & { + confidential: { + account: { nexus: { OAuthCredentials: { token: string; refreshToken: string } } }; + }; +}; + +export default function useCategoryTreeSelection() { + const { categories, mods, gameId, domainName, OAuthCredentials } = useSelector( + (state: IStateWithCreds) => { + const gameId = activeGameId(state); + const game = getGame(gameId); + const domainName = nexusGameId(game); + return { + OAuthCredentials: state.confidential.account.nexus.OAuthCredentials, + gameId, + domainName, + mods: state.persistent.mods[gameId], + categories: state.persistent.categories?.[gameId] || {}, + }; + }, + ); + + const modsByCategory = useMemo(() => { + return Object.keys(mods || {}).reduce( + (prev: { [categoryId: string]: IMod[] }, current: string) => { + const mod = mods[current]; + const category = mod?.attributes?.category; + if (category === undefined) { + return prev; + } + if (!prev[category]) prev[category] = [mod]; + else prev[category].push(mod); + return prev; + }, + {}, + ); + }, [mods]); + + const store = useStore(); + + const onRenameCategory = useCallback( + (categoryId: string, newCategory: string) => + store.dispatch(renameCategory(gameId, categoryId, newCategory)), + [store, gameId], + ); + + const onSetCategory = useCallback( + (gameId: string, categoryId: string, category: ICategory) => + store.dispatch(setCategory(gameId, categoryId, category)), + [store], + ); + + const onRemoveCategory = useCallback( + (categoryId: string) => { + console.log("Dispatching remove", gameId, categoryId); + store.dispatch(removeCategory(gameId, categoryId)); + }, + [store, gameId], + ); + + const onSetCategoryOrder = useCallback( + (gameId: string, categoryIds: string[]) => + store.dispatch(setCategoryOrder(gameId, categoryIds)), + [store], + ); + + const onShowError = useCallback( + (message: string, details: string | Error, options: IErrorOptions) => + showError(store.dispatch, message, details, options), + [store], + ); + + const onShowDialog = useCallback( + (type: DialogType, title: string, content: IDialogContent, actions: DialogActions) => + store.dispatch(showDialog(type, title, content, actions) as unknown as AnyAction), + [store], + ); + + const onCreateCategory = useCallback( + (name: string, order: number = 0, parentCategory?: string) => { + const uid = randomUUID(); + onSetCategory(gameId, uid, { name, parentCategory, order }); + }, + [gameId, onSetCategory], + ); + + return { + categories, + mods, + modsByCategory, + gameId, + domainName, + OAuthCredentials, + onShowDialog, + onShowError, + onSetCategoryOrder, + onCreateCategory, + onRemoveCategory, + onRenameCategory, + onSetCategory, + }; +} diff --git a/src/renderer/src/extensions/category_management/types/ICategoriesTreeEntry.ts b/src/renderer/src/extensions/category_management/types/ICategoriesTreeEntry.ts new file mode 100644 index 0000000000..eaf20b3b53 --- /dev/null +++ b/src/renderer/src/extensions/category_management/types/ICategoriesTreeEntry.ts @@ -0,0 +1,11 @@ +export interface ICategoriesTreeEntry { + categoryId: string; + expanded: boolean; + parentId: string; + title: string; + order: number; + directModCount: number; + nestedModCount: number; + subCategoryCount: number; + children: ICategoriesTreeEntry[]; +} diff --git a/src/renderer/src/extensions/category_management/types/ITrees.ts b/src/renderer/src/extensions/category_management/types/ITrees.ts deleted file mode 100644 index 4adb24282a..0000000000 --- a/src/renderer/src/extensions/category_management/types/ITrees.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { TreeItem } from "react-sortable-tree"; - -export interface ICategoriesTree extends TreeItem { - categoryId: string; - expanded: boolean; - parentId: string; - subtitle: string; - title: string; - order: number; - modCount: number; - children: ICategoriesTree[]; -} diff --git a/src/renderer/src/extensions/category_management/util/buildCategoryTree.ts b/src/renderer/src/extensions/category_management/util/buildCategoryTree.ts new file mode 100644 index 0000000000..fa7215c63b --- /dev/null +++ b/src/renderer/src/extensions/category_management/util/buildCategoryTree.ts @@ -0,0 +1,37 @@ +import type { IMod } from "../../mod_management/types/IMod"; +import type { ICategoriesTreeEntry } from "../types/ICategoriesTreeEntry"; +import type { ICategory } from "../types/ICategoryDictionary"; + +export default function buildCategoryTree( + categories: { [categoryId: string]: ICategory }, + modsByCategory: { [categoryId: string]: IMod[] }, + parentId?: string, + customSort?: (lhs: string, rhs: string) => number, +): ICategoriesTreeEntry[] { + // Sort with custom or base sorting + const sortFunc = (lhs: string, rhs: string) => + customSort !== undefined ? customSort(lhs, rhs) : categories[lhs].order - categories[rhs].order; + + const childIds = Object.keys(categories) + .filter((id) => categories[id].parentCategory === parentId) + .sort(sortFunc); + + return childIds.map((categoryId) => { + const children = buildCategoryTree(categories, modsByCategory, categoryId, customSort); + const directModCount = modsByCategory[categoryId]?.length ?? 0; + const nestedModCount = + directModCount + children.reduce((sum, child) => sum + child.nestedModCount, 0); + const subCategoryCount = children.length; + return { + categoryId, + title: categories[categoryId].name, + expanded: false, + parentId: categories[categoryId].parentCategory, + order: categories[categoryId].order, + directModCount, + nestedModCount, + subCategoryCount, + children, + }; + }); +} diff --git a/src/renderer/src/extensions/category_management/util/createTreeDataObject.ts b/src/renderer/src/extensions/category_management/util/createTreeDataObject.ts deleted file mode 100644 index 1be68af757..0000000000 --- a/src/renderer/src/extensions/category_management/util/createTreeDataObject.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { TFunction } from "i18next"; - -import { getSafe, pushSafe } from "../../../util/storeHelper"; -import type { IMod } from "../../mod_management/types/IMod"; -import type { ICategory } from "../types/ICategoryDictionary"; -import type { ICategoriesTree } from "../types/ITrees"; -import generateSubtitle from "./generateSubtitle"; - -function searchChildren( - t: TFunction, - categories: { [categoryId: string]: ICategory }, - rootId: string, - mods: { [categoryId: string]: IMod[] }, -): ICategoriesTree[] { - const children = Object.keys(categories) - .filter((id) => rootId === categories[id].parentCategory) - .sort((lhs: string, rhs: string) => categories[lhs].order - categories[rhs].order); - - const childrenList: ICategoriesTree[] = []; - - children.forEach((childId) => { - const nestedChildren: ICategoriesTree[] = searchChildren(t, categories, childId, mods); - // tslint:disable-next-line:no-shadowed-variable - const nestedModCount = nestedChildren.reduce( - (total: number, child: ICategoriesTree) => total + child.modCount, - 0, - ); - const modCount = getSafe(mods, [childId], []).length; - const subt: string = - mods !== undefined ? generateSubtitle(t, childId, mods, nestedModCount) : ""; - const child: ICategoriesTree = { - categoryId: childId, - title: categories[childId].name, - subtitle: subt, - expanded: false, - modCount, - parentId: categories[childId].parentCategory, - order: categories[childId].order, - children: nestedChildren, - }; - childrenList.push(child); - }); - - return childrenList; -} - -/** - * create the treeDataObject from the categories inside the store - * - * @param {Object} categories - * @param {any} mods - * @return {[]} categoryList - * - */ - -function createTreeDataObject( - t: TFunction, - categories: { [categoryId: string]: ICategory }, - mods: { [modId: string]: IMod }, - customSort?: (lhs: string, rhs: string) => number, -): ICategoriesTree[] { - const categoryList: ICategoriesTree[] = []; - - const modsByCategory = Object.keys(mods || {}).reduce( - (prev: { [categoryId: string]: IMod[] }, current: string) => { - const category = mods[current]?.attributes?.category; - if (category === undefined) { - return prev; - } - return pushSafe(prev, [category], current); - }, - {}, - ); - - const sortFunc = (lhs: string, rhs: string) => - customSort !== undefined ? customSort(lhs, rhs) : categories[lhs].order - categories[rhs].order; - - const roots = Object.keys(categories) - .filter((id: string) => categories[id].parentCategory === undefined) - .sort((lhs, rhs) => sortFunc(lhs, rhs)); - - roots.forEach((rootElement) => { - let childCategoryModCount = 0; - const children = Object.keys(categories) - .filter((id: string) => rootElement === categories[id].parentCategory) - .sort((lhs, rhs) => sortFunc(lhs, rhs)); - - const childrenList: ICategoriesTree[] = []; - - children.forEach((element) => { - const nestedChildren = searchChildren(t, categories, element, modsByCategory); - // tslint:disable-next-line:no-shadowed-variable - const nestedModCount = nestedChildren.reduce( - (total: number, child: ICategoriesTree) => total + child.modCount, - 0, - ); - - const subtitle: string = generateSubtitle(t, element, modsByCategory, nestedModCount); - const modCount = getSafe(modsByCategory, [element], []).length; - childCategoryModCount += modCount; - const child: ICategoriesTree = { - categoryId: element, - title: categories[element].name, - subtitle, - expanded: false, - modCount, - parentId: categories[element].parentCategory, - order: categories[element].order, - children: nestedChildren, - }; - childrenList.push(child); - }); - - categoryList.push({ - categoryId: rootElement, - title: categories[rootElement].name, - subtitle: generateSubtitle(t, rootElement, modsByCategory, childCategoryModCount), - expanded: false, - parentId: undefined, - modCount: getSafe(modsByCategory, [rootElement], []).length, - children: childrenList, - order: categories[rootElement].order, - }); - }); - - return categoryList; -} - -export default createTreeDataObject; diff --git a/src/renderer/src/extensions/category_management/util/generateSubtitle.ts b/src/renderer/src/extensions/category_management/util/generateSubtitle.ts deleted file mode 100644 index 40a63a3eb6..0000000000 --- a/src/renderer/src/extensions/category_management/util/generateSubtitle.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { TFunction } from "i18next"; - -import { getSafe } from "../../../util/storeHelper"; -import type { IMod } from "../../mod_management/types/IMod"; - -/** - * generate the category's subtitle - * - * @param {string} rootId - * @param {any} mods - * @return {string} - */ - -function generateSubtitle( - t: TFunction, - categoryId: string, - mods: { [categoryId: string]: IMod[] }, - totalChildModCount?: number, -) { - const modsCount = getSafe(mods, [categoryId], []).length; - let subt: string = - modsCount === 0 ? t("Empty") : t("{{ count }} mods installed", { count: modsCount }); - - if (totalChildModCount !== undefined && totalChildModCount > 0) { - subt = subt + t(" ({{ count }} mods in sub-categories)", { count: totalChildModCount }); - } - - return subt; -} - -export default generateSubtitle; diff --git a/src/renderer/src/extensions/category_management/util/getCategoriesFromNexusMods.ts b/src/renderer/src/extensions/category_management/util/getCategoriesFromNexusMods.ts new file mode 100644 index 0000000000..25a1fd6cb3 --- /dev/null +++ b/src/renderer/src/extensions/category_management/util/getCategoriesFromNexusMods.ts @@ -0,0 +1,31 @@ +import type { IGameInfo, IModCategory } from "@nexusmods/nexus-api"; + +import { log } from "@/logging"; +import { getApplication } from "@/util/api"; + +const GameV1URL = (domainName: string) => `https://api.nexusmods.com/v1/games/${domainName}.json`; + +export default async function getGameCategories( + domainName: string, + token: string, +): Promise { + // This could be outsourced to a library, but it's unclear how + try { + const url = GameV1URL(domainName); + const headers = { + Accept: "application/json", + "Application-Name": "Vortex", + "Application-Version": getApplication().version, + Authorization: `Bearer ${token}`, + }; + const res = await fetch(url, { headers }); + if (!res.ok) throw new Error(`Server responded with HTTP ${res.status}`); + const game: IGameInfo = (await res.json()) as IGameInfo; + return game.categories; + } catch (e: unknown) { + log("warn", "Failed to get categories for game", e); + if ((e as Error).message === "Failed to fetch") + throw new Error("An unexpcted network error occurred.", { cause: e }); + throw e; + } +} diff --git a/src/renderer/src/extensions/category_management/views/CategoryList.tsx b/src/renderer/src/extensions/category_management/views/CategoryList.tsx index f212a0896f..d252b9cc0b 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryList.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryList.tsx @@ -12,13 +12,13 @@ import { CategoryAddParent } from "./CategoryAddParent"; import CustomCategoryFetchError from "./CategoryListFetchError"; import CategoryListItem, { CategoryListSkeletonTile } from "./CategoryListItem"; import CustomNoCategoryResults from "./CategoryListNoResults"; - /** * displays the list of categories related for the current game. * */ export default function CategoryList() { const { + t, searchString, setSearchString, filteredTreeData, @@ -28,19 +28,20 @@ export default function CategoryList() { removeCategory, moveCategory, fetchError, - isError, - isLoading, - fetchCategoriesForGame, + isFetchError, + isFetching, + importCategoriesFromNexusMods, addParentVisible, startCreateParentCategory, newParentCategoryName, setNewParentCategoryName, - clearError, + clearFetchError, + onRenameCategory, } = useCategoryTree(); const fetch = () => { - if (isLoading) return; - fetchCategoriesForGame().catch(() => undefined); + if (isFetching) return; + importCategoriesFromNexusMods().catch(() => undefined); }; return ( @@ -76,7 +77,7 @@ export default function CategoryList() { + } customNoResults={ } entityCount={filteredTreeData?.length ?? 0} - isError={isError} - isLoading={isLoading} + isError={isFetchError} + isLoading={isFetching} skeletonCount={10} SkeletonTile={CategoryListSkeletonTile} > @@ -99,6 +100,8 @@ export default function CategoryList() { key={c.categoryId} moveCategory={moveCategory} remove={removeCategory} + renameCategory={onRenameCategory} + t={t} /> ))} diff --git a/src/renderer/src/extensions/category_management/views/CategoryListFetchError.tsx b/src/renderer/src/extensions/category_management/views/CategoryListFetchError.tsx index 5a5c8ff904..b8b950d8ef 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryListFetchError.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryListFetchError.tsx @@ -1,5 +1,5 @@ import { mdiFolderAlert, mdiUpdate } from "@mdi/js"; -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { Button } from "@/ui/components/button/Button"; import { Icon } from "@/ui/components/icon/Icon"; diff --git a/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx index 851dfe9eb8..a8ae6be8f0 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx @@ -9,6 +9,7 @@ import { mdiRename, mdiSubdirectoryArrowRight, } from "@mdi/js"; +import type { TFunction } from "i18next"; import React, { useRef, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; @@ -20,15 +21,18 @@ import type { IToolbarAction } from "@/ui/components/toolbar/ToolbarGroup"; import { ToolbarGroup } from "@/ui/components/toolbar/ToolbarGroup"; import { Typography } from "@/ui/components/typography/Typography"; -import type { ICategoriesTree } from "../types/ITrees"; +import type { ICategoriesTreeEntry } from "../types/ICategoriesTreeEntry"; +import CategorySubtitle from "./CategorySubtitle"; const CATEGORY_ITEM = "CATEGORY_ITEM"; interface ICategoryListItemProps { - category: ICategoriesTree; + t: TFunction; + category: ICategoriesTreeEntry; expand: (id: string) => void; remove: (id: string) => void; createSubcategory: (name: string, order: number, parent: string) => void; + renameCategory: (categoryId: string, newCategory: string) => void; moveCategory: ( sourceId: string, targetId: string, @@ -37,13 +41,15 @@ interface ICategoryListItemProps { } export default function CategoryListItem({ + t, category, expand, remove, createSubcategory, moveCategory, + renameCategory, }: ICategoryListItemProps) { - const { title, subtitle, expanded, children, parentId, categoryId, order } = category; + const { title, expanded, children, parentId, categoryId, order } = category; const ref = useRef(null); const [hoverPosition, setHoverPosition] = useState<"before" | "after" | "inside" | null>(null); const [{ isDragging: _ }, dragRef] = useDrag({ @@ -141,6 +147,11 @@ export default function CategoryListItem({ expand(categoryId); }; + const rename = () => { + renameCategory(categoryId, newName); + setEditMode(); + }; + return (
@@ -191,6 +202,7 @@ export default function CategoryListItem({ brand="primary" leftIconPath={mdiPlus} size="sm" + onClick={rename} />
@@ -261,6 +271,8 @@ export default function CategoryListItem({ key={c.categoryId} moveCategory={moveCategory} remove={remove} + renameCategory={renameCategory} + t={t} /> ))}
diff --git a/src/renderer/src/extensions/category_management/views/CategorySubtitle.tsx b/src/renderer/src/extensions/category_management/views/CategorySubtitle.tsx new file mode 100644 index 0000000000..2b8c4f644c --- /dev/null +++ b/src/renderer/src/extensions/category_management/views/CategorySubtitle.tsx @@ -0,0 +1,31 @@ +import type { TFunction } from "i18next"; +import React from "react"; + +import { Typography } from "@/ui/components/typography/Typography"; + +import type { ICategoriesTreeEntry } from "../types/ICategoriesTreeEntry"; + +interface ICategorySubtitleProps { + category: ICategoriesTreeEntry; + t: TFunction; +} + +export default function CategorySubtitle({ category, t }: ICategorySubtitleProps) { + const { directModCount, nestedModCount, subCategoryCount } = category; + + let subtitle = + directModCount === 0 ? t("Empty") : t("{{ count }} mod(s)", { count: directModCount }); + if (subCategoryCount > 0) + subtitle = + subtitle + + t(" ({{ sub }} sub-categories with {{ nested }} mods)", { + nested: nestedModCount, + sub: subCategoryCount, + }); + + return ( + + {subtitle} + + ); +} From 247fcce2123f8acc339ff102ed74ff27a57a36e1 Mon Sep 17 00:00:00 2001 From: Mike Date: Mon, 29 Jun 2026 08:49:38 +0100 Subject: [PATCH 6/9] Fixed a crash if the OAuthCredentials are not present --- .../category_management/hooks/CategoryTreeActionsHook.ts | 4 ++-- .../category_management/hooks/CateogryTreeSelectionHook.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/extensions/category_management/hooks/CategoryTreeActionsHook.ts b/src/renderer/src/extensions/category_management/hooks/CategoryTreeActionsHook.ts index 7229c1c94e..db9f0eab55 100644 --- a/src/renderer/src/extensions/category_management/hooks/CategoryTreeActionsHook.ts +++ b/src/renderer/src/extensions/category_management/hooks/CategoryTreeActionsHook.ts @@ -21,7 +21,7 @@ interface ICategoryTreeActionsProps { onSetCategory: (gameId: string, categoryId: string, category: ICategory) => void; onRemoveCategory: (categoryId: string) => void; onSetCategoryOrder: (gameId: string, categoryIds: string[]) => void; - OAuthCredentials: { token: string }; + OAuthCredentials?: { token: string }; isFetching: boolean; isFetchError: boolean; setIsFetchError: (isError: boolean) => void; @@ -52,8 +52,8 @@ export default function useCategoryTreeActions(props: ICategoryTreeActionsProps) if (isFetching) return; setIsFetching(true); setIsFetchError(false); - if (!OAuthCredentials.token) throw new Error("You must be logged in to use this feature."); try { + if (!OAuthCredentials?.token) throw new Error("You must be logged in to use this feature."); const apiCategories = await getGameCategories(domainName, OAuthCredentials.token); const categoryMap = createDictionaryFromList(apiCategories); if (!replaceAll) { diff --git a/src/renderer/src/extensions/category_management/hooks/CateogryTreeSelectionHook.ts b/src/renderer/src/extensions/category_management/hooks/CateogryTreeSelectionHook.ts index 0863b9a3f0..ae2fae1a34 100644 --- a/src/renderer/src/extensions/category_management/hooks/CateogryTreeSelectionHook.ts +++ b/src/renderer/src/extensions/category_management/hooks/CateogryTreeSelectionHook.ts @@ -27,7 +27,7 @@ import type { ICategory } from "../types/ICategoryDictionary"; type IStateWithCreds = IState & { confidential: { - account: { nexus: { OAuthCredentials: { token: string; refreshToken: string } } }; + account: { nexus?: { OAuthCredentials?: { token: string; refreshToken: string } } }; }; }; @@ -38,7 +38,7 @@ export default function useCategoryTreeSelection() { const game = getGame(gameId); const domainName = nexusGameId(game); return { - OAuthCredentials: state.confidential.account.nexus.OAuthCredentials, + OAuthCredentials: state.confidential.account.nexus?.OAuthCredentials, gameId, domainName, mods: state.persistent.mods[gameId], From 53e732cf3017e02db79ba6cf69b283b5b8881276 Mon Sep 17 00:00:00 2001 From: Mike Date: Mon, 29 Jun 2026 09:01:30 +0100 Subject: [PATCH 7/9] Style tweaks suggested by Rich --- .../src/extensions/category_management/views/CategoryList.tsx | 2 +- .../extensions/category_management/views/CategoryListItem.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/extensions/category_management/views/CategoryList.tsx b/src/renderer/src/extensions/category_management/views/CategoryList.tsx index d252b9cc0b..6a3e5291f2 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryList.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryList.tsx @@ -75,7 +75,7 @@ export default function CategoryList() { /> } diff --git a/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx index a8ae6be8f0..68d4e1dd19 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx @@ -153,7 +153,7 @@ export default function CategoryListItem({ }; return ( -
+
@@ -262,7 +262,7 @@ export default function CategoryListItem({ )} {children && expanded && ( -
+
{children.map((c) => ( Date: Fri, 3 Jul 2026 18:42:18 +0100 Subject: [PATCH 8/9] Added tests All key logic should be tested. --- .../util/buildCategoryTree.test.ts | 91 ++++++++++ .../util/getCategoriesFromNexusMods.test.ts | 40 +++++ .../util/getCategoriesFromNexusMods.ts | 2 +- .../views/CategoryDialog.test.tsx | 31 ++++ .../views/CategoryList.test.tsx | 164 ++++++++++++++++++ .../views/CategoryListItem.test.tsx | 115 ++++++++++++ .../views/CategoryListItem.tsx | 4 +- .../views/CategorySubtitle.test.tsx | 52 ++++++ 8 files changed, 497 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/extensions/category_management/util/buildCategoryTree.test.ts create mode 100644 src/renderer/src/extensions/category_management/util/getCategoriesFromNexusMods.test.ts create mode 100644 src/renderer/src/extensions/category_management/views/CategoryDialog.test.tsx create mode 100644 src/renderer/src/extensions/category_management/views/CategoryList.test.tsx create mode 100644 src/renderer/src/extensions/category_management/views/CategoryListItem.test.tsx create mode 100644 src/renderer/src/extensions/category_management/views/CategorySubtitle.test.tsx diff --git a/src/renderer/src/extensions/category_management/util/buildCategoryTree.test.ts b/src/renderer/src/extensions/category_management/util/buildCategoryTree.test.ts new file mode 100644 index 0000000000..13e1d1ead7 --- /dev/null +++ b/src/renderer/src/extensions/category_management/util/buildCategoryTree.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; + +import { makeMod } from "@/test-utils/builders"; + +import type { ICategory } from "../types/ICategoryDictionary"; +import buildCategoryTree from "./buildCategoryTree"; + +describe("buildCategoryTree", () => { + it("sorts top-level categories by `order`", () => { + const categories: Record = { + a: { name: "A", parentCategory: undefined, order: 2 }, + b: { name: "B", parentCategory: undefined, order: 1 }, + }; + + const tree = buildCategoryTree(categories, {}); + expect(tree.map((t) => t.categoryId)).toEqual(["b", "a"]); + }); + + it("nest children under the correct parentId", () => { + const categories: Record = { + p: { name: "P", parentCategory: undefined, order: 0 }, + c: { name: "C", parentCategory: "p", order: 0 }, + }; + + const tree = buildCategoryTree(categories, {}); + expect(tree.length).toBe(1); + expect(tree[0].children.map((ch) => ch.categoryId)).toEqual(["c"]); + }); + + it("rolls up directModCount vs nestedModCount correctly", () => { + const categories: Record = { + parent: { name: "Parent", parentCategory: undefined, order: 0 }, + child: { name: "Child", parentCategory: "parent", order: 0 }, + grand: { name: "Grand", parentCategory: "child", order: 0 }, + }; + + const modsByCategory = { + grand: [makeMod(), makeMod()], + }; + + const tree = buildCategoryTree(categories, modsByCategory); + const parentNode = tree[0]; + const childNode = parentNode.children[0]; + const grandNode = childNode.children[0]; + + expect(grandNode.directModCount).toBe(2); + expect(grandNode.nestedModCount).toBe(2); + expect(childNode.directModCount).toBe(0); + expect(childNode.nestedModCount).toBe(2); + expect(parentNode.directModCount).toBe(0); + expect(parentNode.nestedModCount).toBe(2); + }); + + it("respects a provided customSort", () => { + const categories: Record = { + a: { name: "A", parentCategory: undefined, order: 2 }, + b: { name: "B", parentCategory: undefined, order: 1 }, + }; + + const tree = buildCategoryTree(categories, {}, undefined, (l, r) => r.localeCompare(l)); + expect(tree.map((t) => t.categoryId)).toEqual(["b", "a"]); + }); + + it("handles empty input and categories with no mods", () => { + const empty = buildCategoryTree({}, {}); + expect(empty).toEqual([]); + + const categories: Record = { + lone: { name: "Lone", parentCategory: undefined, order: 0 }, + }; + const tree = buildCategoryTree(categories, {}); + expect(tree[0].directModCount).toBe(0); + expect(tree[0].nestedModCount).toBe(0); + }); + + it("handles mods with categories that don't exist", () => { + const modsByCategory = { + fake: [makeMod(), makeMod()], + }; + + const categories: Record = { + real: { name: "Real", parentCategory: undefined, order: 0 }, + }; + + const tree = buildCategoryTree(categories, modsByCategory); + + expect(tree.length).toBe(1); + expect(tree[0].directModCount).toBe(0); + expect(tree[0].nestedModCount).toBe(0); + }); +}); diff --git a/src/renderer/src/extensions/category_management/util/getCategoriesFromNexusMods.test.ts b/src/renderer/src/extensions/category_management/util/getCategoriesFromNexusMods.test.ts new file mode 100644 index 0000000000..769a9b3ce7 --- /dev/null +++ b/src/renderer/src/extensions/category_management/util/getCategoriesFromNexusMods.test.ts @@ -0,0 +1,40 @@ +import type { Mock } from "vitest"; +import { afterEach, beforeEach, describe, it, vi, expect } from "vitest"; + +import getGameCategories from "./getCategoriesFromNexusMods"; + +vi.mock("@/util/api", () => ({ getApplication: () => ({ version: "1.2.3" }) })); + +describe("getCategoriesFromNexusMods", () => { + beforeEach(() => { + globalThis.fetch = vi.fn(); + }); + afterEach(() => { + vi.resetAllMocks(); + }); + + it("returns categories on success", async () => { + const categories = [{ id: 1, name: "Cat" }]; + + (globalThis.fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => ({ categories }), + }); + const res = await getGameCategories("domain", "token"); + expect(res).toEqual(categories); + }); + + it("throws on non-ok response", async () => { + (globalThis.fetch as Mock).mockResolvedValue({ ok: false, status: 500 }); + await expect(getGameCategories("domain", "token")).rejects.toThrow( + "Server responded with HTTP 500", + ); + }); + + it("throws a friendly error on network failure", async () => { + (globalThis.fetch as Mock).mockRejectedValue(new Error("Failed to fetch")); + await expect(getGameCategories("domain", "token")).rejects.toThrow( + "An unexpected network error occurred", + ); + }); +}); diff --git a/src/renderer/src/extensions/category_management/util/getCategoriesFromNexusMods.ts b/src/renderer/src/extensions/category_management/util/getCategoriesFromNexusMods.ts index 25a1fd6cb3..d0e589b5e6 100644 --- a/src/renderer/src/extensions/category_management/util/getCategoriesFromNexusMods.ts +++ b/src/renderer/src/extensions/category_management/util/getCategoriesFromNexusMods.ts @@ -25,7 +25,7 @@ export default async function getGameCategories( } catch (e: unknown) { log("warn", "Failed to get categories for game", e); if ((e as Error).message === "Failed to fetch") - throw new Error("An unexpcted network error occurred.", { cause: e }); + throw new Error("An unexpected network error occurred.", { cause: e }); throw e; } } diff --git a/src/renderer/src/extensions/category_management/views/CategoryDialog.test.tsx b/src/renderer/src/extensions/category_management/views/CategoryDialog.test.tsx new file mode 100644 index 0000000000..de6460a76c --- /dev/null +++ b/src/renderer/src/extensions/category_management/views/CategoryDialog.test.tsx @@ -0,0 +1,31 @@ +import { render, screen, cleanup } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import CategoryDialog from "./CategoryDialog"; + +// eslint-disable-next-line @eslint-react/component-hook-factories +vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) })); +vi.mock("./CategoryList", () => ({ + default: () =>
stub
, +})); + +afterEach(() => cleanup()); + +describe("CategoryDialog", () => { + it("does not render when not visible", () => { + render(); + expect(screen.queryByTestId("cat-list")).not.toBeInTheDocument(); + }); + + it("renders title and calls onHide when closed", async () => { + const onHide = vi.fn(); + render(); + expect(screen.getByText("Categories")).toBeInTheDocument(); + // Close button is provided by Modal; query and click like Modal.test + const close = document.querySelector(".nxm-modal-close"); + await userEvent.click(close); + expect(onHide).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/renderer/src/extensions/category_management/views/CategoryList.test.tsx b/src/renderer/src/extensions/category_management/views/CategoryList.test.tsx new file mode 100644 index 0000000000..d65bf60ca7 --- /dev/null +++ b/src/renderer/src/extensions/category_management/views/CategoryList.test.tsx @@ -0,0 +1,164 @@ +import { screen, cleanup, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React, { useState } from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import CategoryList from "./CategoryList"; + +type UseCategoryTreeResult = ReturnType; + +vi.mock("react-dnd", () => ({ + // eslint-disable-next-line @eslint-react/component-hook-factories + useDrag: () => [{ isDragging: false }, vi.fn()], + // eslint-disable-next-line @eslint-react/component-hook-factories + useDrop: () => [{ isOver: false, canDrop: false }, vi.fn()], +})); + +vi.mock("../hooks/CategoryTreeHook", () => ({ + default: vi.fn(), +})); + +import useCategoryTree from "../hooks/CategoryTreeHook"; + +const mockedUseCategoryTree = vi.mocked(useCategoryTree); + +const mockData = [ + { + categoryId: "c1", + title: "Category 1", + children: [], + expanded: false, + parentId: undefined, + order: 0, + directModCount: 0, + nestedModCount: 0, + subCategoryCount: 0, + }, + { + categoryId: "c2", + title: "Category 2", + children: [], + expanded: false, + parentId: undefined, + order: 1, + directModCount: 0, + nestedModCount: 0, + subCategoryCount: 0, + }, +]; + +const baseHookProps: UseCategoryTreeResult = { + t: (k: string) => k, + filteredTreeData: mockData, + searchString: "", + setSearchString: vi.fn(), + isFetching: false, + isFetchError: false, + toolbarActions: [], + toggleExpand: vi.fn(), + createCategory: vi.fn(), + removeCategory: vi.fn(), + moveCategory: vi.fn(), + importCategoriesFromNexusMods: vi.fn().mockResolvedValue(undefined), + startCreateParentCategory: vi.fn(), + addParentVisible: false, + newParentCategoryName: "", + setNewParentCategoryName: vi.fn(), + clearFetchError: vi.fn(), + onRenameCategory: vi.fn(), + sortAlphabetically: undefined, + treeData: [], + nonEmptyCategories: undefined, + expandedTreeData: [], + expanded: undefined, + showEmpty: false, + toggleEmpty: undefined, + expandAll: undefined, + collapseAll: undefined, + setExpanded: vi.fn(), + fetchError: { + title: "", + detail: "", + }, + setFetchError: vi.fn(), + setIsFetching: vi.fn(), + setIsFetchError: vi.fn(), +}; + +afterEach(() => { + cleanup(); +}); + +describe("CategoryList", () => { + it("calls setSearchString when searching", async () => { + const setSearchString = vi.fn(); + + mockedUseCategoryTree.mockImplementation(() => { + const [searchStringState, setSearchStringState] = useState(""); + return { + ...baseHookProps, + searchString: searchStringState, + setSearchString: (value: string) => { + setSearchStringState(value); + setSearchString(value); + }, + filteredTreeData: mockData, + }; + }); + const user = userEvent.setup(); + render(); + const searchInput = screen.getByDisplayValue(""); + expect(searchInput).toBeInTheDocument(); + await user.clear(searchInput); + await user.type(searchInput, "category 2"); + expect(setSearchString).toHaveBeenLastCalledWith("category 2"); + }); + + it("shows skeleton when fetching", () => { + mockedUseCategoryTree.mockReturnValue({ + ...baseHookProps, + isFetching: true, + filteredTreeData: [], + }); + render(); + + expect(document.querySelector(".animate-pulse")).toBeInTheDocument(); + expect(screen.queryByText("Category 1")).not.toBeInTheDocument(); + }); + + it("show fetch error and can retry", async () => { + const importCategoriesFromNexusMods = vi.fn().mockResolvedValue(undefined); + mockedUseCategoryTree.mockReturnValue({ + ...baseHookProps, + importCategoriesFromNexusMods, + isFetchError: true, + fetchError: { title: "Failed", detail: "Something blew up" }, + filteredTreeData: [], + }); + render(); + + expect(screen.getByText("Failed")).toBeInTheDocument(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(importCategoriesFromNexusMods).toHaveBeenCalled(); + }); + + it("adds new parent category", async () => { + const createCategory = vi.fn(); + const startCreateParentCategory = vi.fn(); + mockedUseCategoryTree.mockReturnValue({ + ...baseHookProps, + createCategory, + startCreateParentCategory, + addParentVisible: true, + newParentCategoryName: "New Parent", + }); + render(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(createCategory).toHaveBeenCalledWith("New Parent", 0, undefined); + expect(startCreateParentCategory).toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/src/extensions/category_management/views/CategoryListItem.test.tsx b/src/renderer/src/extensions/category_management/views/CategoryListItem.test.tsx new file mode 100644 index 0000000000..cd31421310 --- /dev/null +++ b/src/renderer/src/extensions/category_management/views/CategoryListItem.test.tsx @@ -0,0 +1,115 @@ +import { screen, cleanup, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import CategoryListItem from "./CategoryListItem"; + +// eslint-disable-next-line @eslint-react/component-hook-factories +vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) })); + +vi.mock("react-dnd", () => ({ + // eslint-disable-next-line @eslint-react/component-hook-factories + useDrag: () => [{ isDragging: false }, vi.fn()], + // eslint-disable-next-line @eslint-react/component-hook-factories + useDrop: () => [{ isOver: false, canDrop: false }, vi.fn()], +})); + +afterEach(() => { + cleanup(); +}); + +const mockCategory = { + categoryId: "1", + expanded: false, + parentId: undefined, + title: "A", + order: 0, + directModCount: 0, + nestedModCount: 0, + subCategoryCount: 0, + children: [], +}; + +const renderComponent = (props: Partial> = {}) => { + const t = vi.fn(); + const expand = vi.fn(); + const remove = vi.fn(); + const createSubcategory = vi.fn(); + const renameCategory = vi.fn(); + const moveCategory = vi.fn(); + + const allProps: React.ComponentProps = { + t, + category: mockCategory, + expand, + remove, + createSubcategory, + renameCategory, + moveCategory, + ...props, + }; + + render(); + + return { t, expand, remove, createSubcategory, renameCategory, moveCategory }; +}; + +describe("CategoryListItem", () => { + it("expand button is hidden on childless categories", () => { + renderComponent(); + const expand = document.querySelector(".nxm-category-expand"); + expect(expand).toBeNull(); + }); + + it("expand button is visible on categories with children", () => { + renderComponent({ + category: { ...mockCategory, children: [{ ...mockCategory, title: "B", categoryId: "2" }] }, + }); + const expand = document.querySelector(".nxm-category-expand"); + expect(expand).toBeInTheDocument(); + }); + + it("expand button opens children", async () => { + const { expand } = renderComponent({ + category: { ...mockCategory, children: [{ ...mockCategory, title: "B", categoryId: "2" }] }, + }); + const expandButton = document.querySelector(".nxm-category-expand"); + expect(expandButton).toBeInTheDocument(); + await userEvent.setup().click(expandButton); + expect(expand).toHaveBeenCalled(); + }); + + it("delete button deletes category", async () => { + const { remove } = renderComponent(); + const deleteButton = screen.getByRole("button", { name: "Delete" }); + expect(deleteButton).toBeInTheDocument(); + await userEvent.click(deleteButton); + expect(remove).toHaveBeenCalled(); + }); + + it("renames category", async () => { + const user = userEvent.setup(); + const { renameCategory } = renderComponent(); + const renameButton = screen.getByRole("button", { name: "Edit" }); + expect(renameButton).toBeInTheDocument(); + await user.click(renameButton); + const input = screen.getByDisplayValue("A"); + await user.clear(input); + await user.type(input, "New category name"); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(renameCategory).toHaveBeenCalledWith("1", "New category name"); + }); + + it("creates a subcategory", async () => { + const user = userEvent.setup(); + const { createSubcategory } = renderComponent(); + const subCategoryButton = screen.getByRole("button", { name: "New Sub-Category" }); + expect(subCategoryButton).toBeInTheDocument(); + await user.click(subCategoryButton); + const input = screen.getByDisplayValue(""); + await user.type(input, "New category name"); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(createSubcategory).toHaveBeenCalledWith("New category name", 0, "1"); + }); +}); diff --git a/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx index 68d4e1dd19..86336cd7e8 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx @@ -164,8 +164,9 @@ export default function CategoryListItem({ {children.length > 0 && (
-
+
startCreateParentCategory(!addParentVisible)} visible={addParentVisible} /> + } customNoResults={ } entityCount={filteredTreeData?.length ?? 0} diff --git a/src/renderer/src/extensions/category_management/views/CategoryListFetchError.tsx b/src/renderer/src/extensions/category_management/views/CategoryListFetchError.tsx index b8b950d8ef..f5c567e63b 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryListFetchError.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryListFetchError.tsx @@ -1,4 +1,5 @@ import { mdiFolderAlert, mdiUpdate } from "@mdi/js"; +import type { TFunction } from "i18next"; import React, { useEffect } from "react"; import { Button } from "@/ui/components/button/Button"; @@ -8,13 +9,14 @@ import { Typography } from "@/ui/components/typography/Typography"; interface ICustomCategoryFetchErrorProps { fetch: () => void; clear: () => void; + t: TFunction; error: { title: string; detail?: string; }; } -const CustomCategoryFetchError = ({ fetch, clear, error }: ICustomCategoryFetchErrorProps) => { +const CustomCategoryFetchError = ({ fetch, clear, error, t }: ICustomCategoryFetchErrorProps) => { useEffect(() => { const timer = window.setTimeout(clear, 10000); return () => window.clearTimeout(timer); @@ -41,7 +43,7 @@ const CustomCategoryFetchError = ({ fetch, clear, error }: ICustomCategoryFetchE size="sm" onClick={fetch} > - Try again + {t("Try again")}
); diff --git a/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx index 86336cd7e8..17ceabb3d5 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx @@ -246,7 +246,7 @@ export default function CategoryListItem({ appearance="moderate" aria-label="Save" brand="primary" - disabled={newSubcategoryName.length <= 2} + disabled={newSubcategoryName.length < 2} leftIconPath={mdiPlus} size="sm" onClick={() => createCategory(newSubcategoryName)} diff --git a/src/renderer/src/extensions/category_management/views/CategoryListNoResults.tsx b/src/renderer/src/extensions/category_management/views/CategoryListNoResults.tsx index 58673f7b3b..edbf6c5321 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryListNoResults.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryListNoResults.tsx @@ -1,5 +1,6 @@ import { mdiFolderPlus, mdiUpdate } from "@mdi/js"; import React from "react"; +import type { TFunction } from "react-i18next"; import { Button } from "@/ui/components/button/Button"; import { Typography } from "@/ui/components/typography/Typography"; @@ -7,16 +8,22 @@ import { Typography } from "@/ui/components/typography/Typography"; interface ICustomNoCategoryResultsProps { fetch: () => void; create: () => void; + t: TFunction; searchTerm?: string; } -const CustomNoCategoryResults = ({ fetch, create, searchTerm }: ICustomNoCategoryResultsProps) => { +const CustomNoCategoryResults = ({ + fetch, + create, + searchTerm, + t, +}: ICustomNoCategoryResultsProps) => { return (
- {!searchTerm && "No categories"} + {!searchTerm && t("No categories")} - {!!searchTerm && `No categories matching "${searchTerm}"`} + {!!searchTerm && t(`No categories matching "{{searchTerm}}"`, { searchTerm })}
); diff --git a/src/renderer/src/extensions/category_management/views/CategorySubtitle.test.tsx b/src/renderer/src/extensions/category_management/views/CategorySubtitle.test.tsx index 2a14c308e9..5958f3e573 100644 --- a/src/renderer/src/extensions/category_management/views/CategorySubtitle.test.tsx +++ b/src/renderer/src/extensions/category_management/views/CategorySubtitle.test.tsx @@ -47,6 +47,6 @@ describe("CategorySubtitle", () => { subCategoryCount: 1, }, }); - expect(screen.getByText("5 mod(s) (1 sub-categories with 10 mods)")).toBeInTheDocument(); + expect(screen.getByText("5 mod(s) (1 sub-categories with 10 mod(s))")).toBeInTheDocument(); }); }); diff --git a/src/renderer/src/extensions/category_management/views/CategorySubtitle.tsx b/src/renderer/src/extensions/category_management/views/CategorySubtitle.tsx index 2b8c4f644c..5210ad32c0 100644 --- a/src/renderer/src/extensions/category_management/views/CategorySubtitle.tsx +++ b/src/renderer/src/extensions/category_management/views/CategorySubtitle.tsx @@ -18,7 +18,7 @@ export default function CategorySubtitle({ category, t }: ICategorySubtitleProps if (subCategoryCount > 0) subtitle = subtitle + - t(" ({{ sub }} sub-categories with {{ nested }} mods)", { + t(" ({{ sub }} sub-categories with {{ nested }} mod(s))", { nested: nestedModCount, sub: subCategoryCount, });