From 600dc1a75bb18a7727b5cb73d77ca0447859d32d Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 13 Oct 2026 00:26:57 +0200 Subject: [PATCH] feat(frontend): optional nav tree sidebyside (#12974) * disable restriction (for now) * add option for a permanent navigation tree * revert unintended change * adjust search text to be less confusing when pinned --- docs/docs/settings/user.md | 1 + src/backend/InvenTree/common/setting/user.py | 6 + .../src/components/nav/NavigationTree.tsx | 234 ++++++++++++------ .../src/pages/Index/Settings/UserSettings.tsx | 1 + .../src/pages/part/CategoryDetail.tsx | 52 ++-- src/frontend/src/pages/part/PartDetail.tsx | 156 ++++++------ .../src/pages/stock/LocationDetail.tsx | 67 ++--- src/frontend/src/pages/stock/StockDetail.tsx | 84 ++++--- 8 files changed, 359 insertions(+), 242 deletions(-) diff --git a/docs/docs/settings/user.md b/docs/docs/settings/user.md index b15dc34f4e..f036e24380 100644 --- a/docs/docs/settings/user.md +++ b/docs/docs/settings/user.md @@ -31,6 +31,7 @@ The *Display Settings* screen shows general display configuration options: {{ usersetting("SHOW_FULL_CATEGORY_IN_TABLES")}} {{ usersetting("SHOW_BOM_SUBASSEMBLY_LEVELS")}} {{ usersetting("ENABLE_LAST_BREADCRUMB") }} +{{ usersetting("NAVIGATION_TREE_PINNED") }} {{ usersetting("SHOW_EXTRA_MODEL_INFO") }} {{ usersetting("SHOW_FULL_LOCATION_IN_TABLES") }} {{ usersetting("DISPLAY_ITEMS_FINAL_LEVEL") }} diff --git a/src/backend/InvenTree/common/setting/user.py b/src/backend/InvenTree/common/setting/user.py index c30dcc6c7f..01e2e9b4af 100644 --- a/src/backend/InvenTree/common/setting/user.py +++ b/src/backend/InvenTree/common/setting/user.py @@ -255,6 +255,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = { 'default': False, 'validator': bool, }, + 'NAVIGATION_TREE_PINNED': { + 'name': _('Pin Navigation Tree'), + 'description': _('Display navigation tree permanently'), + 'default': False, + 'validator': bool, + }, 'SHOW_EXTRA_MODEL_INFO': { 'name': _('Show Extra Model Information'), 'description': _('Display extra information in model selection dropdowns'), diff --git a/src/frontend/src/components/nav/NavigationTree.tsx b/src/frontend/src/components/nav/NavigationTree.tsx index ac214707d0..43640b3888 100644 --- a/src/frontend/src/components/nav/NavigationTree.tsx +++ b/src/frontend/src/components/nav/NavigationTree.tsx @@ -8,30 +8,41 @@ import { HoverCard, Loader, LoadingOverlay, + Paper, type RenderTreeNodePayload, Space, + Splitter, Stack, Text, TextInput, + Tooltip, Tree, type TreeNodeData, useTree } from '@mantine/core'; -import { useDebouncedValue } from '@mantine/hooks'; +import { useDebouncedValue, useMediaQuery } from '@mantine/hooks'; import { IconChevronDown, IconChevronRight, IconExclamationCircle, + IconPin, + IconPinnedOff, IconSearch, IconSitemap, IconX } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState +} from 'react'; import { useNavigate } from 'react-router-dom'; import { StylishText } from '@lib/components/StylishText'; -import type { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import type { ModelType } from '@lib/enums/ModelType'; import { apiUrl } from '@lib/functions/Api'; import { resolveItem } from '@lib/functions/Conversion'; @@ -42,6 +53,7 @@ import { } from '@lib/functions/Navigation'; import { t } from '@lingui/core/macro'; import { useApi } from '../../contexts/ApiContext'; +import { useUserSettingsState } from '../../states/SettingsStates'; import { ApiIcon } from '../items/ApiIcon'; /* @@ -54,7 +66,9 @@ export default function NavigationTree({ selectedId, modelType, childIdentifier, - endpoint + endpoint, + hidden, + children }: Readonly<{ title: string; opened: boolean; @@ -63,11 +77,39 @@ export default function NavigationTree({ modelType: ModelType; childIdentifier?: string; endpoint: ApiEndpoints; + hidden?: boolean; + children?: ReactNode; }>) { const api = useApi(); const navigate = useNavigate(); const treeState = useTree(); + const userSettings = useUserSettingsState(); + const canPin = useMediaQuery('(min-width: 350px)', true, { + getInitialValueInEffect: false + }); + const pinned = canPin && userSettings.isSet('NAVIGATION_TREE_PINNED', false); + const visible = !hidden && (pinned || opened); + + const setPinned = useCallback( + (value: boolean) => { + api + .patch( + apiUrl(ApiEndpoints.settings_user_list, 'NAVIGATION_TREE_PINNED'), + { + value: value + } + ) + .then(() => { + if (value) onClose(); + }) + .finally(() => { + userSettings.fetchSettings(); + }); + }, + [api, onClose, userSettings.fetchSettings] + ); + const [searchValue, setSearchValue] = useState(''); const [debouncedSearch] = useDebouncedValue(searchValue, 300); @@ -76,17 +118,17 @@ export default function NavigationTree({ // PKs of nodes whose children are currently being fetched const [loadingNodes, setLoadingNodes] = useState>(new Set()); - // Reset everything when the drawer opens or closes + // Reset everything when the tree is shown or hidden useEffect(() => { setSearchValue(''); setAllNodes([]); setLoadingNodes(new Set()); - }, [opened]); + }, [visible]); // Data query — browse mode loads root nodes only; search mode loads all matches + ancestors const query = useQuery({ - enabled: opened, - queryKey: [modelType, 'tree', opened, debouncedSearch, selectedId], + enabled: visible, + queryKey: [modelType, 'tree', visible, debouncedSearch, selectedId], queryFn: async () => api .get(apiUrl(endpoint), { @@ -350,68 +392,122 @@ export default function NavigationTree({ ] ); - return ( - + + + {title} + + {canPin && ( + + setPinned(!pinned)} + > + {pinned ? : } + + + )} + + ); + + const content = ( + + setSearchValue(event.currentTarget.value)} + leftSection={} + rightSection={ + searchValue ? ( + setSearchValue('')} + aria-label={t`Clear search`} + > + + + ) : null } - }} - title={ - - - {title} - - } - > - - setSearchValue(event.currentTarget.value)} - leftSection={} - rightSection={ - searchValue ? ( - setSearchValue('')} - aria-label={t`Clear search`} - > - - - ) : null - } + /> + + + {query.isError ? ( + }> + {t`Error loading navigation tree.`} + + ) : !query.isFetching && !query.isLoading && data.length === 0 ? ( + }> + {t`No results found`} + + ) : ( + - - - {query.isError ? ( - }> - {t`Error loading navigation tree.`} - - ) : !query.isFetching && !query.isLoading && data.length === 0 ? ( - }> - {t`No results found`} - - ) : ( - - )} - - + )} + + ); + + if (hidden) { + return <>{children}; + } + + if (pinned) { + return ( + { + console.log(event); + setPinned(!pinned); + }} + > + + + + {header} + {content} + + + + + {children} + + + ); + } + + return ( + <> + + {content} + + {children} + ); } diff --git a/src/frontend/src/pages/Index/Settings/UserSettings.tsx b/src/frontend/src/pages/Index/Settings/UserSettings.tsx index 277ed6a27d..376f6e9985 100644 --- a/src/frontend/src/pages/Index/Settings/UserSettings.tsx +++ b/src/frontend/src/pages/Index/Settings/UserSettings.tsx @@ -63,6 +63,7 @@ export default function UserSettings() { 'FORMS_CLOSE_USING_ESCAPE', 'DISPLAY_STOCKTAKE_TAB', 'ENABLE_LAST_BREADCRUMB', + 'NAVIGATION_TREE_PINNED', 'SHOW_EXTRA_MODEL_INFO', 'SHOW_FULL_LOCATION_IN_TABLES', 'SHOW_FULL_CATEGORY_IN_TABLES', diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index 44aa24df6e..8d13aa49d7 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -318,30 +318,34 @@ export default function CategoryDetail() { setTreeOpen(false); }} selectedId={category?.pk} - /> - } - breadcrumbs={breadcrumbs} - breadcrumbAction={() => { - setTreeOpen(true); - }} - actions={categoryActions} - editAction={editCategory.open} - editEnabled={ - !!category?.pk && user.hasChangePermission(ModelType.partcategory) - } - /> - + > + + } + breadcrumbs={breadcrumbs} + breadcrumbAction={() => { + setTreeOpen(true); + }} + actions={categoryActions} + editAction={editCategory.open} + editEnabled={ + !!category?.pk && + user.hasChangePermission(ModelType.partcategory) + } + /> + + + diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 1cc6c26cf9..87197243b4 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -797,83 +797,87 @@ export default function PartDetail() { {stockAdjustActions.modals.map((modal) => modal.modal)} - {user.hasViewRole(UserRoles.part_category) && ( - { - setTreeOpen(false); - }} - selectedId={part?.category} - /> - )} - { - const locking = !part.locked; - api - .patch(apiUrl(ApiEndpoints.part_list, part.pk), { - locked: locking - }) - .then(() => { - notifications.hide('part-lock'); - notifications.show({ - id: 'part-lock', - message: locking ? t`Part locked` : t`Part unlocked`, - color: 'green', - icon: locking ? ( - - ) : ( - - ) - }); - refreshInstance(); - }); - }} - > - {part?.locked ? : } - - ) : undefined - } - subtitle={part.description} - imageUrl={part.image} - thumbnailUrl={part.thumbnail} - badges={badges} - breadcrumbs={ - user.hasViewRole(UserRoles.part_category) - ? breadcrumbs - : undefined - } - lastCrumb={[ - { - name: part.name, - url: `/part/${part.pk}/` - } - ]} - breadcrumbAction={() => { - setTreeOpen(true); + diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index d928ca02db..4b80113726 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -438,38 +438,41 @@ export default function Stock() { opened={treeOpen} onClose={() => setTreeOpen(false)} selectedId={location?.pk} - /> - } - actions={location?.pk ? locationActions : undefined} - editAction={editLocation.open} - editEnabled={ - !!location?.pk && - user.hasChangePermission(ModelType.stocklocation) - } - breadcrumbs={breadcrumbs} - lastCrumb={[ - { - name: location.name, - url: `/stock/location/${location.pk}/` - } - ]} - breadcrumbAction={() => { - setTreeOpen(true); - }} - /> - + > + + } + actions={location?.pk ? locationActions : undefined} + editAction={editLocation.open} + editEnabled={ + !!location?.pk && + user.hasChangePermission(ModelType.stocklocation) + } + breadcrumbs={breadcrumbs} + lastCrumb={[ + { + name: location.name, + url: `/stock/location/${location.pk}/` + } + ]} + breadcrumbAction={() => { + setTreeOpen(true); + }} + /> + + + {stockAdjustActions.modals.map((modal) => modal.modal)} diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 7c2bd1a332..c06adae3e6 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -728,47 +728,49 @@ export default function StockDetail() { requiredPermission={ModelType.stockitem} > - {user.hasViewRole(UserRoles.stock_location) && ( - setTreeOpen(false)} - selectedId={stockitem?.location} - /> - )} - { - setTreeOpen(true); - }} - actions={stockActions} - /> - + {editStockItem.modal}