diff --git a/etc/vortex.api.md b/etc/vortex.api.md index ebfe5b3cd3..bba749a332 100644 --- a/etc/vortex.api.md +++ b/etc/vortex.api.md @@ -4520,9 +4520,9 @@ function linkAsync(src: string, dest: string, options?: ILinkFileOptions): Promi // Warning: (ae-forgotten-export) The symbol "ICategoryDictionary" needs to be exported by the entry point api.d.ts // // @public (undocumented) -const loadCategories: reduxAct.ComplexActionCreator2; // @public (undocumented) @@ -5061,9 +5061,9 @@ function relativeTime(date: Date, t: TFunction): string; function removeAsync(remPath: string, options?: IRemoveFileOptions): Promise_2; // @public (undocumented) -const removeCategory: reduxAct.ComplexActionCreator2; // @public @@ -5111,10 +5111,10 @@ function removeValueIf(state: T, path: Array, function renameAsync(sourcePath: string, destinationPath: string): Promise_2; // @public (undocumented) -const renameCategory: reduxAct.ComplexActionCreator3; // @public (undocumented) @@ -5327,16 +5327,16 @@ const setAutoStart: reduxAct.ComplexActionCreator1; // Warning: (ae-forgotten-export) The symbol "ICategory" needs to be exported by the entry point api.d.ts // // @public (undocumented) -const setCategory: reduxAct.ComplexActionCreator3; // @public (undocumented) -const setCategoryOrder: reduxAct.ComplexActionCreator2; // @public (undocumented) @@ -6416,9 +6416,9 @@ function unlinkAsync(filePath: string, options?: IRemoveFileOptions): Promise_2< const UPDATE_CHANNELS: readonly ["stable", "beta", "next", "none"]; // @public (undocumented) -const updateCategories: reduxAct.ComplexActionCreator2; // Warning: (ae-forgotten-export) The symbol "ValuesOf" needs to be exported by the entry point api.d.ts diff --git a/src/renderer/src/extensions/category_management/actions/category.ts b/src/renderer/src/extensions/category_management/actions/category.ts index d8850e8032..4614854cca 100644 --- a/src/renderer/src/extensions/category_management/actions/category.ts +++ b/src/renderer/src/extensions/category_management/actions/category.ts @@ -1,9 +1,8 @@ -import * as reduxAct from "redux-act"; +import { createAction } from "redux-act"; -import safeCreateAction from "../../../actions/safeCreateAction"; import type { ICategory, ICategoryDictionary } from "../types/ICategoryDictionary"; -export const loadCategories = safeCreateAction( +export const loadCategories = createAction( "LOAD_CATEGORIES", (gameId: string, gameCategories: ICategoryDictionary) => ({ gameId, @@ -11,7 +10,7 @@ export const loadCategories = safeCreateAction( }), ); -export const setCategory = safeCreateAction( +export const setCategory = createAction( "SET_CATEGORY", (gameId: string, id: string, category: ICategory) => ({ gameId, @@ -20,17 +19,17 @@ export const setCategory = safeCreateAction( }), ); -export const removeCategory = safeCreateAction("REMOVE_CATEGORY", (gameId: string, id: string) => ({ +export const removeCategory = createAction("REMOVE_CATEGORY", (gameId: string, id: string) => ({ gameId, id, })); -export const setCategoryOrder = safeCreateAction( +export const setCategoryOrder = createAction( "SET_CATEGORY_ORDER", (gameId: string, categoryIds: string[]) => ({ gameId, categoryIds }), ); -export const updateCategories = safeCreateAction( +export const updateCategories = createAction( "UPDATE_CATEGORIES", (gameId: string, gameCategories: ICategoryDictionary) => ({ gameId, @@ -38,7 +37,7 @@ export const updateCategories = safeCreateAction( }), ); -export const renameCategory = safeCreateAction( +export const renameCategory = createAction( "RENAME_CATEGORY", (gameId: string, categoryId: string, name: string) => ({ gameId, diff --git a/src/renderer/src/extensions/category_management/actions/session.ts b/src/renderer/src/extensions/category_management/actions/session.ts deleted file mode 100644 index 6328f65ea5..0000000000 --- a/src/renderer/src/extensions/category_management/actions/session.ts +++ /dev/null @@ -1,5 +0,0 @@ -import * as reduxAct from "redux-act"; - -import safeCreateAction from "../../../actions/safeCreateAction"; - -export const showCategoriesDialog = safeCreateAction("SHOW_CATEGORIES_DIALOG", (show) => show); 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..f74eee2b35 --- /dev/null +++ b/src/renderer/src/extensions/category_management/hooks/CategoryToolbarActionsHook.ts @@ -0,0 +1,95 @@ +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 { TFunction } from "@/util/i18n"; + +import type { ICategoriesTreeEntry } from "../types/ICategoriesTreeEntry"; +import { flattenTreeToIDs } from "../util/flattenCategoryTree"; + +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; + t: TFunction; +} + +export default function useCategoryToolbarActions(props: ICategoryToolbarActionsProps) { + const { + addParentVisible, + setAddParentVisible, + expanded, + showEmpty, + treeData, + expandAll, + collapseAll, + toggleEmpty, + sortAlphabetically, + importCategoriesFromNexusMods, + t, + } = props; + + const toolbarActions: IToolbarAction[] = useMemo( + () => [ + { + label: expanded.size === 0 ? t("Expand All") : t("Collapse All"), + iconPath: expanded.size === 0 ? mdiExpandAll : mdiCollapseAll, + showLabel: true, + onClick: () => + expanded.size === 0 ? expandAll(flattenTreeToIDs(treeData)) : collapseAll(), + }, + { + label: showEmpty ? t("Hide Empty") : t("Show Empty"), + iconPath: showEmpty ? mdiEyeOff : mdiEye, + showLabel: true, + onClick: toggleEmpty, + }, + { + label: t("Add Top Level"), + iconPath: mdiFolderPlus, + onClick: () => setAddParentVisible(!addParentVisible), + }, + { + label: t("Sort A-Z"), + iconPath: mdiSortAlphabeticalAscending, + onClick: sortAlphabetically, + }, + { + label: t("Fetch from Nexus Mods"), + iconPath: mdiSync, + onClick: () => { + void importCategoriesFromNexusMods(false); + }, + }, + ], + [ + showEmpty, + toggleEmpty, + collapseAll, + expandAll, + sortAlphabetically, + expanded, + importCategoriesFromNexusMods, + addParentVisible, + setAddParentVisible, + treeData, + t, + ], + ); + 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..4a5b3c1268 --- /dev/null +++ b/src/renderer/src/extensions/category_management/hooks/CategoryTreeActionsHook.ts @@ -0,0 +1,224 @@ +import { randomUUID } from "crypto"; + +import type { IModCategory } from "@nexusmods/nexus-api"; +import type { TFunction } from "i18next"; +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 { flattenTreeToIDs } from "../util/flattenCategoryTree"; +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; + t: TFunction; +} +export default function useCategoryTreeActions(props: ICategoryTreeActionsProps) { + const { + OAuthCredentials, + categories, + modsByCategory, + onSetCategory, + onRemoveCategory, + onSetCategoryOrder, + setIsFetching, + setFetchError, + isFetching, + setIsFetchError, + gameId, + domainName, + t, + } = props; + + const store = useStore(); + + const importCategoriesFromNexusMods = useCallback( + async (replaceAll?: boolean) => { + if (isFetching) return; + setIsFetching(true); + setIsFetchError(false); + 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) { + // 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: t("Failed to fetch categories from Nexus Mods"), + detail: err.message, + }); + } finally { + setIsFetching(false); + } + }, + [ + OAuthCredentials, + domainName, + gameId, + categories, + setFetchError, + setIsFetchError, + setIsFetching, + store, + isFetching, + t, + ], + ); + + 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; + } + + const newOrder = flattenTreeToIDs(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 new file mode 100644 index 0000000000..46fbbd30cd --- /dev/null +++ b/src/renderer/src/extensions/category_management/hooks/CategoryTreeHook.ts @@ -0,0 +1,58 @@ +import { useTranslation } from "react-i18next"; + +import useCategoryToolbarActions from "./CategoryToolbarActionsHook"; +import useCategoryTreeActions from "./CategoryTreeActionsHook"; +import useCategoryTreeData from "./CategoryTreeDataHook"; +import useCategoryTreeSelection from "./CategoryTreeSelectionHook"; +import useCategoryTreeState from "./CategoryTreeStateHook"; + +export default function useCategoryTree() { + const { t } = useTranslation("common"); + 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 actions = useCategoryTreeActions({ + t, + 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, + }); + + const toolbarActions = useCategoryToolbarActions({ + t, + expanded: state.expanded, + showEmpty: state.showEmpty, + treeData: tree.treeData, + setAddParentVisible: state.startCreateParentCategory, + ...state, + ...actions, + }); + + return { + t, + ...state, + ...tree, + ...actions, + toolbarActions, + onRenameCategory: selection.onRenameCategory, + }; +} diff --git a/src/renderer/src/extensions/category_management/hooks/CategoryTreeSelectionHook.ts b/src/renderer/src/extensions/category_management/hooks/CategoryTreeSelectionHook.ts new file mode 100644 index 0000000000..27f46c8c84 --- /dev/null +++ b/src/renderer/src/extensions/category_management/hooks/CategoryTreeSelectionHook.ts @@ -0,0 +1,88 @@ +import { useCallback, useMemo } from "react"; +import { useSelector, useStore } from "react-redux"; + +import { removeCategory, renameCategory, setCategory, setCategoryOrder } from "@/actions"; +import type { IMod, IState } from "@/types/api"; +import { getGame, nexusGameId } from "@/util/api"; +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) => { + store.dispatch(removeCategory(gameId, categoryId)); + }, + [store, gameId], + ); + + const onSetCategoryOrder = useCallback( + (gameId: string, categoryIds: string[]) => + store.dispatch(setCategoryOrder(gameId, categoryIds)), + [store], + ); + + return { + categories, + mods, + modsByCategory, + gameId, + domainName, + OAuthCredentials, + onSetCategoryOrder, + onRemoveCategory, + onRenameCategory, + onSetCategory, + }; +} diff --git a/src/renderer/src/extensions/category_management/hooks/CategoryTreeStateHook.ts b/src/renderer/src/extensions/category_management/hooks/CategoryTreeStateHook.ts new file mode 100644 index 0000000000..9873d9ed8a --- /dev/null +++ b/src/renderer/src/extensions/category_management/hooks/CategoryTreeStateHook.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/index.ts b/src/renderer/src/extensions/category_management/index.ts index 87b3f4691a..1fd080123c 100644 --- a/src/renderer/src/extensions/category_management/index.ts +++ b/src/renderer/src/extensions/category_management/index.ts @@ -1,6 +1,7 @@ -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"; @@ -13,12 +14,9 @@ import { setModAttribute } from "../mod_management/actions/mods"; import type { IModWithState } from "../mod_management/types/IModProps"; import { isLoggedIn } from "../nexus_integration/selectors"; import { loadCategories, updateCategories } from "./actions/category"; -import { showCategoriesDialog } from "./actions/session"; import { categoryReducer } from "./reducers/category"; -import { sessionReducer } from "./reducers/session"; import { allCategories } from "./selectors"; import type { ICategoryDictionary } from "./types/ICategoryDictionary"; -import type { ICategoriesTree } from "./types/ITrees"; import CategoryFilter from "./util/CategoryFilter"; import { resolveCategoryName, resolveCategoryPath } from "./util/retrieveCategoryPath"; import CategoryDialog from "./views/CategoryDialog"; @@ -26,16 +24,11 @@ import CategoryDialog from "./views/CategoryDialog"; // export for api export { resolveCategoryName, resolveCategoryPath }; -function getModCategory(mod: IModWithState) { - return getSafe(mod, ["attributes", "category"], undefined); -} +const getModCategory = (mod: IModWithState): string | number | undefined => + mod.attributes?.category; -function getModName(mod: IModWithState) { - return ( - getSafe(mod, ["attributes", "name"], undefined) || - getSafe(mod, ["attributes", "fileName"], undefined) - ); -} +const getModName = (mod: IModWithState): string | undefined => + mod.attributes?.name || mod.attributes?.fileName; function getCategoryChoices(state: IState) { const categories: ICategoryDictionary = allCategories(state); @@ -47,7 +40,7 @@ function getCategoryChoices(state: IState) { ); } -function undefSort(lhs: any, rhs: any) { +function undefSort(lhs?: string, rhs?: string) { return lhs !== undefined ? 1 : rhs !== undefined ? -1 : 0; } @@ -68,7 +61,7 @@ function sortCategories( lhs: IModWithState, rhs: IModWithState, collator: Intl.Collator, - state: any, + state: IState, sortDir: string, ): number { const lhsCat = resolveCategoryName(getModCategory(lhs), state); @@ -91,11 +84,10 @@ function init(context: IExtensionContext): boolean { }; context.registerReducer(["persistent", "categories"], categoryReducer); - context.registerReducer(["session", "categories"], sessionReducer); 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", { @@ -104,16 +96,15 @@ function init(context: IExtensionContext): boolean { description: "Mod Category", icon: "sitemap", placement: "table", - calc: (mod: IModWithState) => - resolveCategoryName(getModCategory(mod), context.api.store.getState()), + calc: (mod: IModWithState) => resolveCategoryName(getModCategory(mod), context.api.getState()), isToggleable: true, edit: {}, isSortable: true, isGroupable: (mod: IModWithState, t: TFunction) => - resolveCategoryName(getModCategory(mod), context.api.store.getState()) || t(""), + resolveCategoryName(getModCategory(mod), context.api.getState()) || t(""), filter: new CategoryFilter(), sortFuncRaw: (lhs: IModWithState, rhs: IModWithState, locale: string): number => - sortCategories(lhs, rhs, getCollator(locale), context.api.store.getState(), sortDirection), + sortCategories(lhs, rhs, getCollator(locale), context.api.getState(), sortDirection), }); context.registerTableAttribute("mods", { @@ -122,12 +113,11 @@ function init(context: IExtensionContext): boolean { description: "Mod Category", icon: "sitemap", supportsMultiple: true, - calc: (mod: IModWithState) => - resolveCategoryPath(getModCategory(mod), context.api.store.getState()), + calc: (mod: IModWithState) => resolveCategoryPath(getModCategory(mod), context.api.getState()), edit: { - choices: () => getCategoryChoices(context.api.store.getState()), - onChangeValue: (rows: IModWithState[], newValue: any) => { - const gameMode = activeGameId(context.api.store.getState()); + choices: () => getCategoryChoices(context.api.getState()), + onChangeValue: (rows: IModWithState[], newValue: string | number) => { + const gameMode = activeGameId(context.api.getState()); rows.forEach((row) => { if (row.state === "downloaded") { context.api.store.dispatch(setDownloadModInfo(row.id, "custom.category", newValue)); @@ -168,23 +158,23 @@ function init(context: IExtensionContext): boolean { } }); try { - context.api.events.on("update-categories", (gameId, categories, isUpdate) => { - if (isUpdate) { - context.api.store.dispatch(updateCategories(gameId, categories)); - } else { - context.api.store.dispatch(loadCategories(gameId, categories)); - } - }); + context.api.events.on( + "update-categories", + (gameId: string, categories: ICategoryDictionary, isUpdate: boolean) => { + if (isUpdate) { + context.api.store.dispatch(updateCategories(gameId, categories)); + } else { + context.api.store.dispatch(loadCategories(gameId, categories)); + } + }, + ); context.api.events.on("gamemode-activated", (gameMode: string) => { - const categories: ICategoriesTree[] = getSafe( - store.getState(), - ["persistent", "categories", gameMode], - undefined, - ); - if (categories === undefined && isLoggedIn(store.getState())) { + const categories: ICategoryDictionary = + context.api.getState().persistent.categories[gameMode]; + if (categories === undefined && isLoggedIn(context.api.getState())) { context.api.events.emit("retrieve-category-list", false, {}); - } else if (categories !== undefined && categories.length === 0) { + } else if (categories !== undefined && Object.values(categories).length === 0) { context.api.store.dispatch(updateCategories(gameMode, {})); } }); diff --git a/src/renderer/src/extensions/category_management/reducers/category.ts b/src/renderer/src/extensions/category_management/reducers/category.ts index 47b3ea5c37..4d9bedfc67 100644 --- a/src/renderer/src/extensions/category_management/reducers/category.ts +++ b/src/renderer/src/extensions/category_management/reducers/category.ts @@ -1,27 +1,45 @@ +import type { ICategory } from "@nexusmods/nexus-api"; + import type { IReducerSpec } from "../../../types/IExtensionContext"; -import { deleteOrNop, getSafe, setOrNop, setSafe } from "../../../util/storeHelper"; +import { deleteOrNop, setOrNop, setSafe } from "../../../util/storeHelper"; import * as actions from "../actions/category"; +import type { ICategoryState } from "../types/ICategoryDictionary"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ /** * reducer for changes to ephemeral session state */ export const categoryReducer: IReducerSpec = { reducers: { - [actions.loadCategories as any]: (state, payload) => - setOrNop(state, [payload.gameId], payload.gameCategories), - [actions.setCategory as any]: (state, payload) => - setSafe(state, [payload.gameId, payload.id], payload.category), - [actions.removeCategory as any]: (state, payload) => - deleteOrNop(state, [payload.gameId, payload.id]), - [actions.updateCategories as any]: (state, payload) => - setSafe(state, [payload.gameId], payload.gameCategories), - [actions.renameCategory as any]: (state, payload) => - setOrNop(state, [payload.gameId, payload.categoryId, "name"], payload.name), - [actions.setCategoryOrder as any]: (state, payload) => { - const { gameId, categoryIds }: { gameId: string; categoryIds: string[] } = payload; + [actions.loadCategories as any]: ( + state: ICategoryState, + payload: { gameId: string; gameCategories: unknown[] }, + ) => setOrNop(state, [payload.gameId], payload.gameCategories), + [actions.setCategory as any]: ( + state, + payload: { gameId: string; id: string; category: ICategory }, + ) => setSafe(state, [payload.gameId, payload.id], payload.category), + [actions.removeCategory as any]: ( + state: ICategoryState, + payload: { gameId: string; id: string }, + ) => deleteOrNop(state, [payload.gameId, payload.id]), + [actions.updateCategories as any]: ( + state: ICategoryState, + payload: { gameId: string; gameCategories: unknown[] }, + ) => setSafe(state, [payload.gameId], payload.gameCategories), + [actions.renameCategory as any]: ( + state: ICategoryState, + payload: { gameId: string; categoryId: string; name: string }, + ) => setOrNop(state, [payload.gameId, payload.categoryId, "name"], payload.name), + [actions.setCategoryOrder as any]: ( + state: ICategoryState, + payload: { gameId: string; categoryIds: string[] }, + ) => { + const { gameId, categoryIds } = payload; let newState = state; categoryIds.forEach((id, idx) => { - const oldOrder = getSafe(newState, [gameId, id, "order"], undefined); + const oldOrder = state[gameId]?.[id]?.order; if (oldOrder !== undefined && oldOrder !== idx) { newState = setSafe(newState, [gameId, id, "order"], idx); } diff --git a/src/renderer/src/extensions/category_management/reducers/session.ts b/src/renderer/src/extensions/category_management/reducers/session.ts deleted file mode 100644 index 508e2ef05e..0000000000 --- a/src/renderer/src/extensions/category_management/reducers/session.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { IReducerSpec } from "../../../types/IExtensionContext"; -import { setSafe } from "../../../util/storeHelper"; -import * as actions from "../actions/session"; - -/** - * reducer for changes to ephemeral session state - */ -export const sessionReducer: IReducerSpec = { - reducers: { - [actions.showCategoriesDialog as any]: (state, payload) => - setSafe(state, ["showDialog"], payload), - }, - defaults: { - showDialog: false, - }, -}; diff --git a/src/renderer/src/extensions/category_management/selectors.ts b/src/renderer/src/extensions/category_management/selectors.ts index 12fb220f25..522dfdf8dc 100644 --- a/src/renderer/src/extensions/category_management/selectors.ts +++ b/src/renderer/src/extensions/category_management/selectors.ts @@ -1,7 +1,9 @@ +import type { IState } from "@/types/api"; + import { activeGameId } from "../../util/selectors"; +import type { ICategoryDictionary } from "./types/ICategoryDictionary"; -export function allCategories(state: any) { +export function allCategories(state: IState): ICategoryDictionary { const gameMode = activeGameId(state); - const categories = state.persistent.categories[gameMode]; - return categories !== undefined ? categories : []; + return state.persistent.categories[gameMode] ?? {}; } 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/ICategoryDictionary.ts b/src/renderer/src/extensions/category_management/types/ICategoryDictionary.ts index 33444a8eea..69874acee5 100644 --- a/src/renderer/src/extensions/category_management/types/ICategoryDictionary.ts +++ b/src/renderer/src/extensions/category_management/types/ICategoryDictionary.ts @@ -7,3 +7,7 @@ export interface ICategory { export interface ICategoryDictionary { [id: string]: ICategory; } + +export interface ICategoryState { + [gameId: string]: ICategoryDictionary; +} 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.test.ts b/src/renderer/src/extensions/category_management/util/buildCategoryTree.test.ts new file mode 100644 index 0000000000..c6962d2057 --- /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) => l.localeCompare(r)); + expect(tree.map((t) => t.categoryId)).toEqual(["a", "b"]); + }); + + 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/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 8290258eef..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[] }, -) { - const children = Object.keys(categories) - .filter((id) => rootId === categories[id].parentCategory) - .sort((lhs: string, rhs: string) => categories[lhs].order - categories[rhs].order); - - const childrenList = []; - - 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 = getSafe(mods, [current, "attributes", "category"], undefined); - if (category === undefined) { - return prev; - } - return pushSafe(prev, [category], current); - }, - {}, - ); - - const sortFunc = (lhs, rhs) => - 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 = []; - - 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/flattenCategoryTree.test.ts b/src/renderer/src/extensions/category_management/util/flattenCategoryTree.test.ts new file mode 100644 index 0000000000..4d2eb848bc --- /dev/null +++ b/src/renderer/src/extensions/category_management/util/flattenCategoryTree.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import type { ICategoriesTreeEntry } from "../types/ICategoriesTreeEntry"; +import { flattenTreeToIDs } from "./flattenCategoryTree"; + +describe("flattenTreeToIDs", () => { + it("flattens multiple layers of nesting and returns all IDs", () => { + const categories: ICategoriesTreeEntry[] = [ + { + categoryId: "grand", + title: "Grand", + order: 0, + children: [ + { + categoryId: "parent", + title: "Parent", + order: 0, + children: [ + { + categoryId: "child", + title: "Child", + order: 0, + children: [], + expanded: false, + parentId: undefined, + nestedModCount: 0, + subCategoryCount: 0, + directModCount: 1, + }, + ], + expanded: false, + parentId: undefined, + nestedModCount: 1, + subCategoryCount: 1, + directModCount: 0, + }, + ], + expanded: false, + parentId: undefined, + nestedModCount: 1, + subCategoryCount: 1, + directModCount: 0, + }, + ]; + const flattened = flattenTreeToIDs(categories); + expect(flattened).toEqual(["grand", "parent", "child"]); + }); +}); diff --git a/src/renderer/src/extensions/category_management/util/flattenCategoryTree.ts b/src/renderer/src/extensions/category_management/util/flattenCategoryTree.ts new file mode 100644 index 0000000000..b354933108 --- /dev/null +++ b/src/renderer/src/extensions/category_management/util/flattenCategoryTree.ts @@ -0,0 +1,12 @@ +import type { ICategoriesTreeEntry } from "../types/ICategoriesTreeEntry"; + +/** + * Takes the category tree and flattems it out into a single array of the IDs + * Recursively collects IDs from all nested children. + * @param {ICategoriesTreeEntry[]} nodes:ICategoriesTreeEntry[] + * @returns {string[]} + */ +const flattenTreeToIDs = (nodes: ICategoriesTreeEntry[]): string[] => + nodes.flatMap((node) => [node.categoryId, ...flattenTreeToIDs(node.children)]); + +export { flattenTreeToIDs }; 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 1d46941ca2..0000000000 --- a/src/renderer/src/extensions/category_management/util/generateSubtitle.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { TFunction } from "i18next"; - -import { getSafe } from "../../../util/storeHelper"; -import { truthy } from "../../../util/util"; -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.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 new file mode 100644 index 0000000000..d0e589b5e6 --- /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 unexpected network error occurred.", { cause: e }); + throw e; + } +} diff --git a/src/renderer/src/extensions/category_management/views/CategoryAddParent.tsx b/src/renderer/src/extensions/category_management/views/CategoryAddParent.tsx new file mode 100644 index 0000000000..337af7d3e7 --- /dev/null +++ b/src/renderer/src/extensions/category_management/views/CategoryAddParent.tsx @@ -0,0 +1,77 @@ +import { mdiCancel, mdiPlus } from "@mdi/js"; +import type { TFunction } from "i18next"; +import React, { useCallback } from "react"; + +import { Input } from "@/ui/components/form/input/Input"; +import { Toolbar } from "@/ui/components/toolbar/Toolbar"; +import type { IToolbarAction } from "@/ui/components/toolbar/ToolbarGroup"; +import { ToolbarGroup } from "@/ui/components/toolbar/ToolbarGroup"; + +interface ICategoryAddParentProps { + visible: boolean; + toggle: () => void; + create: (name: string, order: number, parent?: string) => void; + newName: string; + setNewName: (newName: string) => void; + t: TFunction; +} + +export function CategoryAddParent({ + visible, + toggle, + create, + newName, + setNewName, + t, +}: ICategoryAddParentProps) { + const makeCategory = useCallback(() => { + create(newName, 0, undefined); + toggle(); + }, [newName, toggle, create]); + + const actions: IToolbarAction[] = [ + { + label: t("Create"), + iconPath: mdiPlus, + showLabel: true, + onClick: makeCategory, + brand: "primary", + disabled: newName.length < 2, + }, + { + label: t("Cancel"), + iconPath: mdiCancel, + showLabel: true, + onClick: toggle, + }, + ]; + + if (!visible) return null; + + return ( +
+
+
+ setNewName(e.target.value)} + /> +
+ +
+ + + +
+
+
+ ); +} 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/CategoryDialog.tsx b/src/renderer/src/extensions/category_management/views/CategoryDialog.tsx index ef317f7487..3936f45096 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryDialog.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryDialog.tsx @@ -1,54 +1,23 @@ 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"; - -interface IConnectedProps { - showDialog: boolean; -} - -interface IActionProps { - onShowSelf: (show: boolean) => void; -} +import { Modal } from "@/ui/components/modal/Modal"; -type IProps = IConnectedProps & IActionProps; - -class CategoryDialog extends ComponentEx { - public render(): JSX.Element { - const { t, showDialog } = this.props; - - return ( - - - {t("Categories")} - - - - - - ); - } +import CategoryList from "./CategoryList"; - private hide = () => { - this.props.onShowSelf(false); - }; +interface IProps { + visible: boolean; + onHide: () => void; } -function mapStateToProps(state: any): IConnectedProps { - return { - showDialog: state.session.categories.showDialog, - }; -} +function CategoryDialog({ visible, onHide }: IProps) { + const { t } = useTranslation("common"); -function mapDispatchToProps(dispatch: Redux.Dispatch): IActionProps { - return { - onShowSelf: (show: boolean) => dispatch(showCategoriesDialog(show)), - }; + return ( + onHide()}> + + + ); } -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.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/CategoryList.tsx b/src/renderer/src/extensions/category_management/views/CategoryList.tsx index 00c15e63c9..2f926797c8 100644 --- a/src/renderer/src/extensions/category_management/views/CategoryList.tsx +++ b/src/renderer/src/extensions/category_management/views/CategoryList.tsx @@ -1,645 +1,118 @@ -import { unknownToError } from "@vortex/shared"; -import type PromiseBB from "bluebird"; +import { mdiMagnify } from "@mdi/js"; import * as React from "react"; -import { FormControl } from "react-bootstrap"; -import type * as SortableTreeT from "react-sortable-tree"; -import type { - OnDragPreviousAndNextLocation, - OnMovePreviousAndNextLocation, - NodeData, - FullTree, -} from "react-sortable-tree"; -import SortableTree from "react-sortable-tree"; -import type * as Redux from "redux"; -import type { ThunkDispatch } from "redux-thunk"; - -import { showDialog } from "../../../actions/notifications"; -import ActionDropdown from "../../../controls/ActionDropdown"; -import { ComponentEx, connect, translate } from "../../../controls/ComponentEx"; -import Icon from "../../../controls/Icon"; -import IconBar from "../../../controls/IconBar"; -import { IconButton } from "../../../controls/TooltipControls"; -import type { IActionDefinition } from "../../../types/IActionDefinition"; -import type { IComponentContext } from "../../../types/IComponentContext"; -import type { - DialogActions, - DialogType, - IConditionResult, - IDialogContent, - IDialogResult, - IInput, -} from "../../../types/IDialog"; -import type { IErrorOptions } from "../../../types/IExtensionContext"; -import type { IState } from "../../../types/IState"; -import lazyRequire from "../../../util/lazyRequire"; -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 type { ICategory, ICategoryDictionary } from "../types/ICategoryDictionary"; -import type { ICategoriesTree } from "../types/ITrees"; -import createTreeDataObject from "../util/createTreeDataObject"; - -const nop = () => undefined; - -interface ISearchMatch { - node: ICategoriesTree; - path: string[]; - treeIndex: number; -} - -interface INodeExtraArgs { - categoryId: string; - parentId: string; - order: number; -} - -interface IActionProps { - onShowError: (message: string, details: string | Error, options: IErrorOptions) => void; - onSetCategory: (gameId: string, categoryId: string, category: ICategory) => void; - onRemoveCategory: (gameId: string, categoryId: string) => void; - onSetCategoryOrder: (gameId: string, categoryIds: string[]) => void; - onRenameCategory: (activeGameId: string, categoryId: string, newCategory: {}) => void; - onShowDialog: ( - type: DialogType, - title: string, - content: IDialogContent, - actions: DialogActions, - ) => PromiseBB; -} - -interface IConnectedProps { - gameMode: string; - language: string; - categories: ICategoryDictionary; - mods: { [modId: string]: IMod }; -} - -interface IComponentState { - treeData: ICategoriesTree[]; - expandedTreeData: ICategoriesTree[]; - expanded: string[]; - showEmpty: boolean; - searchString: string; - searchFocusIndex: number; - searchFoundCount: number; -} - -type IProps = IConnectedProps & IActionProps; +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 useCategoryTree from "../hooks/CategoryTreeHook"; +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. * */ -class CategoryList extends ComponentEx { - declare public context: IComponentContext; - private mButtons: IActionDefinition[]; - - constructor(props) { - super(props); - this.initState({ - treeData: [], - expandedTreeData: [], - expanded: [], - showEmpty: true, - searchString: "", - searchFocusIndex: 0, - searchFoundCount: 0, - }); - - const { t } = props; - - this.mButtons = [ - { - title: "Expand All", - icon: "expand-all", - action: this.expandAll, - }, - { - title: "Collapse All", - icon: "collapse-all", - action: this.collapseAll, - }, - { - title: "Add Root", - icon: "folder-add", - action: this.addRootCategory, - }, - { - title: "Show/Hide Empty", - icon: "hide", - action: this.toggleShowEmpty, - }, - { - title: "Sort Alphabetically", - icon: "loot-sort", - action: this.sortAlphabetically, - }, - ]; - } - - public componentDidMount() { - this.refreshTree(this.props); - } - - public UNSAFE_componentWillReceiveProps(newProps: IProps) { - if (this.props.categories !== newProps.categories) { - this.refreshTree(newProps); - } - } - - public render(): JSX.Element { - const { t } = this.props; - const { expandedTreeData, searchString, searchFocusIndex, searchFoundCount } = this.state; - - return ( -
- -
-
- - - - {t("{{ pos }} of {{ total }}", { - replace: { - pos: searchFoundCount > 0 ? searchFocusIndex + 1 : 0, - total: searchFoundCount || 0, - }, - })} - -
- - -
- { + if (isFetching) return; + importCategoriesFromNexusMods().catch(() => undefined); + }; + + return ( +
+ + + + +
+ + + setSearchString(e.target.value)} />
- ); - } - - // tslint:disable-next-line:no-shadowed-variable - private searchMethod = ({ - node, - path, - treeIndex, - searchQuery, - }: { - node: ICategoriesTree; - path: number[] | string[]; - treeIndex: number; - searchQuery: any; - }) => { - return ( - searchQuery.length > 0 && node.title.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1 - ); - }; - - private updateExpandedTreeData = (categories: ICategoryDictionary) => { - const { expanded, showEmpty, treeData } = this.nextState; - this.nextState.expandedTreeData = this.applyExpand( - treeData, - showEmpty, - new Set(expanded), - categories, - ); - }; - - private getNonEmptyCategories(treeData: ICategoriesTree[], ancestry: string[]): string[] { - let res: string[] = []; - treeData.forEach((category) => { - if (category.modCount > 0) { - res.push(category.categoryId); - res = res.concat(ancestry); - } - res = res.concat( - this.getNonEmptyCategories(category.children, [].concat(ancestry, [category.categoryId])), - ); - }); - return res; - } - - private applyExpand( - treeData: ICategoriesTree[], - showEmpty: boolean, - expanded: Set, - categories: ICategoryDictionary, - ): ICategoriesTree[] { - const filtered: Set = new Set( - showEmpty ? Object.keys(categories) : this.getNonEmptyCategories(treeData, []), - ); - - return treeData - .map((obj) => { - if (!filtered.has(obj.categoryId)) { - return undefined; - } - const copy: ICategoriesTree = { ...obj }; - copy.expanded = expanded.has(copy.categoryId); - copy.children = this.applyExpand(copy.children, showEmpty, expanded, categories); - return copy; - }) - .filter((obj) => obj !== undefined); - } - - private toggleShowEmpty = () => { - const { t, categories, mods, onShowError } = this.props; - const { showEmpty } = this.state; - - try { - const newTree = createTreeDataObject(t, categories, mods); - this.nextState.treeData = newTree; - this.nextState.showEmpty = !showEmpty; - this.updateExpandedTreeData(categories); - } catch (err) { - onShowError("An error occurred hiding/showing the empty categories", unknownToError(err), { - allowReport: false, - }); - } - }; - - private sortAlphabetically = () => { - const { t, gameMode, categories, mods, onShowError, onSetCategoryOrder } = this.props; - - try { - const newTree: ICategoriesTree[] = createTreeDataObject(t, categories, mods, (a, b) => - categories[a].name.localeCompare(categories[b].name), - ); - - const newOrder = (base: ICategoriesTree[]): string[] => { - return [].concat(...base.map((node) => [node.categoryId, ...newOrder(node.children)])); - }; - - onSetCategoryOrder(gameMode, newOrder(newTree)); - } catch (err) { - onShowError("Failed to sort categories", unknownToError(err), { - allowReport: false, - }); - } - }; - - private expandAll = () => { - const { categories } = this.props; - this.nextState.expanded = Object.keys(categories); - this.updateExpandedTreeData(categories); - }; - - private collapseAll = () => { - this.nextState.expanded = []; - this.updateExpandedTreeData(this.props.categories); - }; - - private renameCategory = (categoryId: string) => { - const { categories, gameMode, onShowDialog, onRenameCategory } = this.props; - - const category = categories[categoryId]; - // one user seems to have managed to get this called on a category that (no longer?) - // exists - if (category === undefined) { - return; - } - - onShowDialog( - "info", - "Rename Category", - { - input: [{ id: "newCategory", value: category.name, label: "Category" }], - condition: this.validateCategoryDialog, - }, - [{ label: "Cancel" }, { label: "Rename" }], - ).then((result: IDialogResult) => { - if (result.action === "Rename") { - onRenameCategory(gameMode, categoryId, result.input.newCategory); - } - }); - }; - - private addCategory = (parentId: string) => { - const { categories, gameMode, onSetCategory, onShowDialog } = this.props; - const lastIndex = this.searchLastRootId(categories); - - if (Array.isArray(parentId)) { - parentId = parentId[0]; - } - - onShowDialog( - "question", - "Add Child Category", - { - input: [ - { id: "newCategory", value: "", label: "Category Name" }, - { - id: "newCategoryId", - value: lastIndex.toString(), - label: "Category ID", - }, - ], - condition: this.validateCategoryDialog, - }, - [{ label: "Cancel" }, { label: "Add" }], - ).then((result: IDialogResult) => { - if (result.action === "Add") { - onSetCategory(gameMode, result.input.newCategoryId, { - name: result.input.newCategory, - parentCategory: parentId, - order: 0, - }); - } - }); - }; - private hasEmptyInput = (input: IInput): IConditionResult => { - const { t } = this.props; - return input.value === "" - ? { - id: input.id, - actions: ["Add", "Rename"], - errorText: t("{{label}} cannot be empty.", { - replace: { label: input.label ? input.label : "Field" }, - }), - } - : undefined; - }; - - private idExists = (input: IInput): IConditionResult => { - const { t, categories } = this.props; - return categories[input.value] !== undefined - ? { id: input.id, actions: ["Add"], errorText: t("ID already used.") } - : undefined; - }; - - private validateCategoryDialog = (content: IDialogContent): IConditionResult[] => { - const results: IConditionResult[] = []; - content.input.forEach((inp) => { - results.push(this.hasEmptyInput(inp)); - if (inp.id === "newCategoryId") { - results.push(this.idExists(inp)); - } - }); - - return results.filter((res) => res !== undefined); - }; - - private addRootCategory = () => { - const { categories, gameMode, onSetCategory, onShowDialog, onShowError } = this.props; - const lastIndex = this.searchLastRootId(categories); - - onShowDialog( - "question", - "Add new Root Category", - { - input: [ - { id: "newCategory", value: "", label: "Category Name" }, - { - id: "newCategoryId", - value: lastIndex.toString(), - label: "Category ID", - }, - ], - condition: this.validateCategoryDialog, - }, - [{ label: "Cancel" }, { label: "Add", default: true }], - ).then((result: IDialogResult) => { - if (result.action === "Add") { - onSetCategory(gameMode, result.input.newCategoryId, { - name: result.input.newCategory, - parentCategory: undefined, - order: 0, - }); - } - }); - }; - - private searchLastRootId(categories: ICategoryDictionary) { - let maxId = 1000; - if (categories !== undefined) { - Object.keys(categories).filter((id: string) => { - if (parseInt(id, 10) > maxId) { - maxId = parseInt(id, 10); - } - }); - } - return maxId + 1; - } - - private selectPrevMatch = () => { - const { searchFocusIndex, searchFoundCount } = this.state; - - this.nextState.searchFocusIndex = (searchFoundCount + searchFocusIndex - 1) % searchFoundCount; - }; - - private selectNextMatch = () => { - const { searchFocusIndex, searchFoundCount } = this.state; - - this.nextState.searchFocusIndex = (searchFocusIndex + 1) % searchFoundCount; - }; - - private refreshTree(props: IProps) { - const { t } = this.props; - const { categories, mods } = props; - - if (categories !== undefined) { - if (Object.keys(categories).length !== 0) { - this.nextState.treeData = createTreeDataObject(t, categories, mods); - this.updateExpandedTreeData(categories); - } - } - } - - private startSearch = (event) => { - this.nextState.searchString = event.target.value; - }; - - private searchFinishCallback = (matches: ISearchMatch[]) => { - const { searchFocusIndex } = this.state; - // important: Avoid updating the state if the values haven't changed because - // changing the state causes a re-render and a re-render causes the tree to search - // again (why?) which causes a new finish callback -> infinite loop - if (this.state.searchFoundCount !== matches.length) { - this.nextState.searchFoundCount = matches.length; - } - const newFocusIndex = matches.length > 0 ? searchFocusIndex % matches.length : 0; - if (this.state.searchFocusIndex !== newFocusIndex) { - this.nextState.searchFocusIndex = newFocusIndex; - } - }; - - private removeCategory = (id: string) => { - const { categories, gameMode, onRemoveCategory } = this.props; - let userConfirmed = false; - id = Array.isArray(id) ? id[0] : id; - const catKeys = Object.keys(categories); - const childrenIds = catKeys.filter((key) => categories[key].parentCategory === id); - const removeCat = () => { - childrenIds.forEach((iterId) => this.removeCategory(iterId)); - onRemoveCategory(gameMode, id); - }; - if (userConfirmed) { - removeCat(); - } - if (childrenIds.length > 0) { - this.context.api - .showDialog( - "question", - "Remove Category", - { - text: - "You're attempting to remove a category with one or more nested categories " + - "Which may in turn, also contain their own sub-categories. Are you sure you wish to proceed ?", - }, - [{ label: "Cancel", default: true }, { label: "Remove Category" }], - ) - .then((res) => { - if (res.action !== "Cancel") { - userConfirmed = true; - removeCat(); - } - }); - } else { - onRemoveCategory(gameMode, id); - } - }; - - private generateNodeProps = (rowInfo: SortableTreeT.ExtendedNodeData<{ categoryId: string }>) => { - const { t } = this.props; - const actions: IActionDefinition[] = [ - { - icon: "edit", - title: "Rename", - action: this.renameCategory, - }, - { - icon: "folder-add", - title: "Add Child", - action: this.addCategory, - }, - { - icon: "remove", - title: "Remove", - action: this.removeCategory, - }, - ]; - return { - buttons: [ - + , - ], - }; - }; - - private getNodeKey = (args: { node: ICategoriesTree; treeIndex: number }) => { - return args.node.categoryId; - }; - - private toggleVisibility = (args: { - treeData: ICategoriesTree[]; - node: ICategoriesTree; - expanded: boolean; - }) => { - if (args.expanded) { - this.nextState.expanded.push(args.node.categoryId); - } else { - this.nextState.expanded.splice(this.nextState.expanded.indexOf(args.node.categoryId)); - } - - this.updateExpandedTreeData(this.props.categories); - }; - - private canDrop = (data: OnDragPreviousAndNextLocation & NodeData) => { - const { nextPath, node } = data; - return !(nextPath ?? []).slice(0, -1).includes(node["categoryId"]); - }; - - private moveNode = (data: NodeData & FullTree & OnMovePreviousAndNextLocation) => { - const { gameMode, onSetCategory, onSetCategoryOrder } = this.props; - const { path, node, treeData } = data; - if (path[path.length - 2] !== node["parentId"]) { - onSetCategory(gameMode, node["categoryId"], { - name: node.title as string, - order: node["order"], - parentCategory: (path as string[])[path.length - 2], - }); - } else { - const newOrder = (base: ICategoriesTree[]): string[] => { - return [].concat(...base.map((node) => [node.categoryId, ...newOrder(node.children)])); - }; - onSetCategoryOrder(gameMode, newOrder(treeData as ICategoriesTree[])); - } - }; -} - -const emptyObj = {}; - -function mapStateToProps(state: IState): IConnectedProps { - const gameMode = activeGameId(state); - return { - gameMode, - language: state.settings.interface.language, - categories: state.persistent.categories[gameMode] || emptyObj, - mods: state.persistent.mods[gameMode], - }; -} + toggle={() => startCreateParentCategory(!addParentVisible)} + visible={addParentVisible} + /> -function mapDispatchToProps(dispatch: ThunkDispatch): IActionProps { - return { - onRenameCategory: (gameId: string, categoryId: string, newCategory: string) => - dispatch(renameCategory(gameId, categoryId, newCategory)), - onSetCategory: (gameId: string, categoryId: string, category: ICategory) => - dispatch(setCategory(gameId, categoryId, category)), - onRemoveCategory: (gameId: string, categoryId: string) => - dispatch(removeCategory(gameId, categoryId)), - onSetCategoryOrder: (gameId: string, categoryIds: string[]) => - dispatch(setCategoryOrder(gameId, categoryIds)), - onShowError: (message: string, details: string | Error, options: IErrorOptions) => - showError(dispatch, message, details, options), - onShowDialog: (type, title, content, actions) => - dispatch(showDialog(type, title, content, actions)), - }; + + } + customNoResults={ + + } + entityCount={filteredTreeData?.length ?? 0} + isError={isFetchError} + isLoading={isFetching} + skeletonCount={10} + SkeletonTile={CategoryListSkeletonTile} + > + {filteredTreeData?.map((c) => ( + + ))} + +
+
+ ); } - -export default translate(["common"])( - connect(mapStateToProps, mapDispatchToProps)(CategoryList), -) as React.ComponentClass<{}>; diff --git a/src/renderer/src/extensions/category_management/views/CategoryListFetchError.tsx b/src/renderer/src/extensions/category_management/views/CategoryListFetchError.tsx new file mode 100644 index 0000000000..f5c567e63b --- /dev/null +++ b/src/renderer/src/extensions/category_management/views/CategoryListFetchError.tsx @@ -0,0 +1,52 @@ +import { mdiFolderAlert, mdiUpdate } from "@mdi/js"; +import type { TFunction } from "i18next"; +import React, { useEffect } from "react"; + +import { Button } from "@/ui/components/button/Button"; +import { Icon } from "@/ui/components/icon/Icon"; +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, t }: ICustomCategoryFetchErrorProps) => { + useEffect(() => { + const timer = window.setTimeout(clear, 10000); + return () => window.clearTimeout(timer); + }, [clear]); + + return ( +
+ + + + {error.title} + + + {!!error.detail && ( + + {error.detail} + + )} + + +
+ ); +}; + +export default CustomCategoryFetchError; 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 new file mode 100644 index 0000000000..17ceabb3d5 --- /dev/null +++ b/src/renderer/src/extensions/category_management/views/CategoryListItem.tsx @@ -0,0 +1,292 @@ +import { + mdiCancel, + mdiChevronDown, + mdiChevronRight, + mdiDelete, + mdiDrag, + mdiFolderPlus, + mdiPlus, + mdiRename, + mdiSubdirectoryArrowRight, +} from "@mdi/js"; +import type { TFunction } from "i18next"; +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"; +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 { ICategoriesTreeEntry } from "../types/ICategoriesTreeEntry"; +import CategorySubtitle from "./CategorySubtitle"; + +const CATEGORY_ITEM = "CATEGORY_ITEM"; + +interface ICategoryListItemProps { + 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, + position: "before" | "after" | "inside", + ) => void; +} + +export default function CategoryListItem({ + t, + category, + expand, + remove, + createSubcategory, + moveCategory, + renameCategory, +}: ICategoryListItemProps) { + 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({ + 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 actions: IToolbarAction[] = [ + { + label: "Edit", + iconPath: mdiRename, + 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); + setNewSubcategoryName(""); + setNewName(title); + } else if (type === "name") { + setEditName(true); + setAddNew(false); + setNewSubcategoryName(""); + } else if (type === "subcategory") { + setAddNew(true); + setEditName(false); + setNewName(title); + } + }; + + const createCategory = (name: string) => { + setEditMode(); + setNewSubcategoryName(""); + createSubcategory(name, 0, categoryId); + expand(categoryId); + }; + + const rename = () => { + renameCategory(categoryId, newName); + setEditMode(); + }; + + return ( +
+
+
+
+ + + {!!parentId && } + + {children.length > 0 && ( +
+ +
+ {!editName && ( + + {title} + + )} + + {editName && ( +
+ setNewName(e.target.value)} + /> + +
+ )} + + +
+
+ +
+ + + +
+
+ + {addNew && ( +
+ setNewSubcategoryName(e.target.value)} + /> + +
+ )} + + {children && expanded && ( +
+ {children.map((c) => ( + expand(c.categoryId)} + key={c.categoryId} + moveCategory={moveCategory} + remove={remove} + renameCategory={renameCategory} + t={t} + /> + ))} +
+ )} +
+ ); +} + +export const CategoryListSkeletonTile = () => ( +
+
+ +
+
+); 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..edbf6c5321 --- /dev/null +++ b/src/renderer/src/extensions/category_management/views/CategoryListNoResults.tsx @@ -0,0 +1,52 @@ +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"; + +interface ICustomNoCategoryResultsProps { + fetch: () => void; + create: () => void; + t: TFunction; + searchTerm?: string; +} + +const CustomNoCategoryResults = ({ + fetch, + create, + searchTerm, + t, +}: ICustomNoCategoryResultsProps) => { + return ( +
+ + {!searchTerm && t("No categories")} + + {!!searchTerm && t(`No categories matching "{{searchTerm}}"`, { searchTerm })} + + + + + +
+ ); +}; + +export default CustomNoCategoryResults; diff --git a/src/renderer/src/extensions/category_management/views/CategorySubtitle.test.tsx b/src/renderer/src/extensions/category_management/views/CategorySubtitle.test.tsx new file mode 100644 index 0000000000..5958f3e573 --- /dev/null +++ b/src/renderer/src/extensions/category_management/views/CategorySubtitle.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import type { TFunction } from "react-i18next"; +import { describe, expect, it } from "vitest"; + +import CategorySubtitle from "./CategorySubtitle"; + +const renderComponent = ({ + category, +}: Pick, "category">) => { + const t = ((key: string, vars?: Record) => { + if (!vars) return key; + return key + .replace("{{ count }}", String(vars.count ?? "")) + .replace("{{ sub }}", String(vars.sub ?? "")) + .replace("{{ nested }}", String(vars.nested ?? "")); + }) as unknown as TFunction; + + render(); + return { t }; +}; + +const mockCategory = { + title: "Category A", + categoryId: "A", + expanded: false, + children: [], + parentId: undefined, + order: 0, + nestedModCount: 0, + subCategoryCount: 0, + directModCount: 0, +}; + +describe("CategorySubtitle", () => { + it("shows the correct direct mod count", () => { + renderComponent({ category: { ...mockCategory, directModCount: 5 } }); + expect(screen.getByText("5 mod(s)")).toBeInTheDocument(); + }); + + it("shows nested/subcategory counts", () => { + renderComponent({ + category: { + ...mockCategory, + directModCount: 5, + nestedModCount: 10, + subCategoryCount: 1, + }, + }); + 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 new file mode 100644 index 0000000000..5210ad32c0 --- /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 }} mod(s))", { + nested: nestedModCount, + sub: subCategoryCount, + }); + + return ( + + {subtitle} + + ); +}