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
This commit is contained in:
Matthias Mair authored and GitHub committed 2026-10-13 09:26:57 +11:00
1 parent 47f2e6acdd
commit 600dc1a75b
8 files changed
+359 -242

No files matched your search

+1
View File
@@ -31,6 +31,7 @@ The *Display Settings* screen shows general display configuration options:
{{ usersetting("SHOW_FULL_CATEGORY_IN_TABLES")}} {{ usersetting("SHOW_FULL_CATEGORY_IN_TABLES")}}
{{ usersetting("SHOW_BOM_SUBASSEMBLY_LEVELS")}} {{ usersetting("SHOW_BOM_SUBASSEMBLY_LEVELS")}}
{{ usersetting("ENABLE_LAST_BREADCRUMB") }} {{ usersetting("ENABLE_LAST_BREADCRUMB") }}
{{ usersetting("NAVIGATION_TREE_PINNED") }}
{{ usersetting("SHOW_EXTRA_MODEL_INFO") }} {{ usersetting("SHOW_EXTRA_MODEL_INFO") }}
{{ usersetting("SHOW_FULL_LOCATION_IN_TABLES") }} {{ usersetting("SHOW_FULL_LOCATION_IN_TABLES") }}
{{ usersetting("DISPLAY_ITEMS_FINAL_LEVEL") }} {{ usersetting("DISPLAY_ITEMS_FINAL_LEVEL") }}
@@ -255,6 +255,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
}, },
'NAVIGATION_TREE_PINNED': {
'name': _('Pin Navigation Tree'),
'description': _('Display navigation tree permanently'),
'default': False,
'validator': bool,
},
'SHOW_EXTRA_MODEL_INFO': { 'SHOW_EXTRA_MODEL_INFO': {
'name': _('Show Extra Model Information'), 'name': _('Show Extra Model Information'),
'description': _('Display extra information in model selection dropdowns'), 'description': _('Display extra information in model selection dropdowns'),
@@ -8,30 +8,41 @@ import {
HoverCard, HoverCard,
Loader, Loader,
LoadingOverlay, LoadingOverlay,
Paper,
type RenderTreeNodePayload, type RenderTreeNodePayload,
Space, Space,
Splitter,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Tooltip,
Tree, Tree,
type TreeNodeData, type TreeNodeData,
useTree useTree
} from '@mantine/core'; } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks'; import { useDebouncedValue, useMediaQuery } from '@mantine/hooks';
import { import {
IconChevronDown, IconChevronDown,
IconChevronRight, IconChevronRight,
IconExclamationCircle, IconExclamationCircle,
IconPin,
IconPinnedOff,
IconSearch, IconSearch,
IconSitemap, IconSitemap,
IconX IconX
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; 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 { useNavigate } from 'react-router-dom';
import { StylishText } from '@lib/components/StylishText'; 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 type { ModelType } from '@lib/enums/ModelType';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { resolveItem } from '@lib/functions/Conversion'; import { resolveItem } from '@lib/functions/Conversion';
@@ -42,6 +53,7 @@ import {
} from '@lib/functions/Navigation'; } from '@lib/functions/Navigation';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useApi } from '../../contexts/ApiContext'; import { useApi } from '../../contexts/ApiContext';
import { useUserSettingsState } from '../../states/SettingsStates';
import { ApiIcon } from '../items/ApiIcon'; import { ApiIcon } from '../items/ApiIcon';
/* /*
@@ -54,7 +66,9 @@ export default function NavigationTree({
selectedId, selectedId,
modelType, modelType,
childIdentifier, childIdentifier,
endpoint endpoint,
hidden,
children
}: Readonly<{ }: Readonly<{
title: string; title: string;
opened: boolean; opened: boolean;
@@ -63,11 +77,39 @@ export default function NavigationTree({
modelType: ModelType; modelType: ModelType;
childIdentifier?: string; childIdentifier?: string;
endpoint: ApiEndpoints; endpoint: ApiEndpoints;
hidden?: boolean;
children?: ReactNode;
}>) { }>) {
const api = useApi(); const api = useApi();
const navigate = useNavigate(); const navigate = useNavigate();
const treeState = useTree(); 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 [searchValue, setSearchValue] = useState('');
const [debouncedSearch] = useDebouncedValue(searchValue, 300); const [debouncedSearch] = useDebouncedValue(searchValue, 300);
@@ -76,17 +118,17 @@ export default function NavigationTree({
// PKs of nodes whose children are currently being fetched // PKs of nodes whose children are currently being fetched
const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set()); const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set());
// Reset everything when the drawer opens or closes // Reset everything when the tree is shown or hidden
useEffect(() => { useEffect(() => {
setSearchValue(''); setSearchValue('');
setAllNodes([]); setAllNodes([]);
setLoadingNodes(new Set()); setLoadingNodes(new Set());
}, [opened]); }, [visible]);
// Data query — browse mode loads root nodes only; search mode loads all matches + ancestors // Data query — browse mode loads root nodes only; search mode loads all matches + ancestors
const query = useQuery({ const query = useQuery({
enabled: opened, enabled: visible,
queryKey: [modelType, 'tree', opened, debouncedSearch, selectedId], queryKey: [modelType, 'tree', visible, debouncedSearch, selectedId],
queryFn: async () => queryFn: async () =>
api api
.get(apiUrl(endpoint), { .get(apiUrl(endpoint), {
@@ -350,68 +392,122 @@ export default function NavigationTree({
] ]
); );
return ( const header = (
<Drawer <Group justify='space-between' wrap='nowrap' w='100%'>
opened={opened} <Group justify='left' p='ms' gap='md' wrap='nowrap'>
size='lg' <IconSitemap />
position='left' <StylishText size='lg'>{title}</StylishText>
onClose={onClose} </Group>
withCloseButton={true} {canPin && (
styles={{ <Tooltip label={pinned ? t`Unpin` : t`Pin`} position='bottom'>
header: { <ActionIcon
width: '100%' variant='transparent'
}, aria-label={pinned ? 'nav-tree-unpin' : 'nav-tree-pin'}
title: { onClick={() => setPinned(!pinned)}
width: '100%' >
{pinned ? <IconPinnedOff /> : <IconPin />}
</ActionIcon>
</Tooltip>
)}
</Group>
);
const content = (
<Stack gap='xs' pos='relative'>
<TextInput
aria-label='nav-tree-search'
placeholder={t`Filter ${{ title }}...`}
value={searchValue}
onChange={(event) => setSearchValue(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
rightSection={
searchValue ? (
<ActionIcon
size='sm'
variant='transparent'
onClick={() => setSearchValue('')}
aria-label={t`Clear search`}
>
<IconX size={14} />
</ActionIcon>
) : null
} }
}} />
title={ <Divider />
<Group justify='left' p='ms' gap='md' wrap='nowrap'> <LoadingOverlay visible={query.isFetching || query.isLoading} />
<IconSitemap /> {query.isError ? (
<StylishText size='lg'>{title}</StylishText> <Alert color='red' title={t`Error`} icon={<IconExclamationCircle />}>
</Group> {t`Error loading navigation tree.`}
} </Alert>
> ) : !query.isFetching && !query.isLoading && data.length === 0 ? (
<Stack gap='xs'> <Alert color='blue' icon={<IconSearch />}>
<TextInput {t`No results found`}
aria-label='nav-tree-search' </Alert>
placeholder={t`Search...`} ) : (
value={searchValue} <Tree
onChange={(event) => setSearchValue(event.currentTarget.value)} data={data}
leftSection={<IconSearch size={16} />} tree={treeState}
rightSection={ renderNode={renderNode}
searchValue ? ( withLines
<ActionIcon levelOffset={25}
size='sm'
variant='transparent'
onClick={() => setSearchValue('')}
aria-label={t`Clear search`}
>
<IconX size={14} />
</ActionIcon>
) : null
}
/> />
<Divider /> )}
<LoadingOverlay visible={query.isFetching || query.isLoading} /> </Stack>
{query.isError ? ( );
<Alert color='red' title={t`Error`} icon={<IconExclamationCircle />}>
{t`Error loading navigation tree.`} if (hidden) {
</Alert> return <>{children}</>;
) : !query.isFetching && !query.isLoading && data.length === 0 ? ( }
<Alert color='blue' icon={<IconSearch />}>
{t`No results found`} if (pinned) {
</Alert> return (
) : ( <Splitter
<Tree onCollapseChange={(event) => {
data={data} console.log(event);
tree={treeState} setPinned(!pinned);
renderNode={renderNode} }}
withLines >
levelOffset={25} <Splitter.Pane defaultSize='350px' min={'200px'} collapsible>
/> <Paper
)} withBorder
</Stack> p='xs'
</Drawer> aria-label='nav-tree-pinned'
style={{ flexShrink: 0 }}
>
<Stack gap='xs'>
{header}
{content}
</Stack>
</Paper>
</Splitter.Pane>
<Splitter.Pane defaultSize={50} min={20}>
{children}
</Splitter.Pane>
</Splitter>
);
}
return (
<>
<Drawer
opened={opened}
size='lg'
position='left'
onClose={onClose}
withCloseButton={true}
styles={{
header: {
width: '100%'
},
title: {
width: '100%'
}
}}
title={header}
>
{content}
</Drawer>
{children}
</>
); );
} }
@@ -63,6 +63,7 @@ export default function UserSettings() {
'FORMS_CLOSE_USING_ESCAPE', 'FORMS_CLOSE_USING_ESCAPE',
'DISPLAY_STOCKTAKE_TAB', 'DISPLAY_STOCKTAKE_TAB',
'ENABLE_LAST_BREADCRUMB', 'ENABLE_LAST_BREADCRUMB',
'NAVIGATION_TREE_PINNED',
'SHOW_EXTRA_MODEL_INFO', 'SHOW_EXTRA_MODEL_INFO',
'SHOW_FULL_LOCATION_IN_TABLES', 'SHOW_FULL_LOCATION_IN_TABLES',
'SHOW_FULL_CATEGORY_IN_TABLES', 'SHOW_FULL_CATEGORY_IN_TABLES',
+28 -24
View File
@@ -318,30 +318,34 @@ export default function CategoryDetail() {
setTreeOpen(false); setTreeOpen(false);
}} }}
selectedId={category?.pk} selectedId={category?.pk}
/> >
<PageDetail <Stack gap='xs'>
title={(category?.name ?? id) ? t`Part Category` : t`Parts`} <PageDetail
subtitle={category?.description} title={(category?.name ?? id) ? t`Part Category` : t`Parts`}
icon={category?.icon && <ApiIcon name={category?.icon} />} subtitle={category?.description}
breadcrumbs={breadcrumbs} icon={category?.icon && <ApiIcon name={category?.icon} />}
breadcrumbAction={() => { breadcrumbs={breadcrumbs}
setTreeOpen(true); breadcrumbAction={() => {
}} setTreeOpen(true);
actions={categoryActions} }}
editAction={editCategory.open} actions={categoryActions}
editEnabled={ editAction={editCategory.open}
!!category?.pk && user.hasChangePermission(ModelType.partcategory) editEnabled={
} !!category?.pk &&
/> user.hasChangePermission(ModelType.partcategory)
<PanelGroup }
pageKey='partcategory' />
panels={panels} <PanelGroup
model={ModelType.partcategory} pageKey='partcategory'
instance={category} panels={panels}
reloadInstance={refreshInstance} model={ModelType.partcategory}
id={category.pk ?? null} instance={category}
defaultPanel={defaultPanel} reloadInstance={refreshInstance}
/> id={category.pk ?? null}
defaultPanel={defaultPanel}
/>
</Stack>
</NavigationTree>
</Stack> </Stack>
</InstanceDetail> </InstanceDetail>
</> </>
+80 -76
View File
@@ -797,83 +797,87 @@ export default function PartDetail() {
{stockAdjustActions.modals.map((modal) => modal.modal)} {stockAdjustActions.modals.map((modal) => modal.modal)}
<InstanceDetail query={instanceQuery} requiredRole={UserRoles.part}> <InstanceDetail query={instanceQuery} requiredRole={UserRoles.part}>
<Stack gap='xs'> <Stack gap='xs'>
{user.hasViewRole(UserRoles.part_category) && ( <NavigationTree
<NavigationTree hidden={!user.hasViewRole(UserRoles.part_category)}
title={t`Part Categories`} title={t`Part Categories`}
childIdentifier='subcategories' childIdentifier='subcategories'
modelType={ModelType.partcategory} modelType={ModelType.partcategory}
endpoint={ApiEndpoints.category_tree} endpoint={ApiEndpoints.category_tree}
opened={treeOpen} opened={treeOpen}
onClose={() => { onClose={() => {
setTreeOpen(false); setTreeOpen(false);
}}
selectedId={part?.category}
/>
)}
<PageDetail
title={`${t`Part`}: ${part.full_name}`}
icon={
lockingEnabled ? (
<ActionIcon
aria-label='part-lock-icon'
variant='transparent'
disabled={!user.hasChangeRole(UserRoles.part)}
onClick={() => {
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 ? (
<IconLock size='1rem' />
) : (
<IconLockOpen size='1rem' />
)
});
refreshInstance();
});
}}
>
{part?.locked ? <IconLock /> : <IconLockOpen />}
</ActionIcon>
) : 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);
}} }}
editAction={editPart.open} selectedId={part?.category}
editEnabled={user.hasChangeRole(UserRoles.part)} >
actions={partActions} <Stack gap='xs'>
/> <PageDetail
<PanelGroup title={`${t`Part`}: ${part.full_name}`}
pageKey='part' icon={
panels={partPanels} lockingEnabled ? (
instance={part} <ActionIcon
reloadInstance={refreshInstance} aria-label='part-lock-icon'
model={ModelType.part} variant='transparent'
id={part.pk} disabled={!user.hasChangeRole(UserRoles.part)}
/> onClick={() => {
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 ? (
<IconLock size='1rem' />
) : (
<IconLockOpen size='1rem' />
)
});
refreshInstance();
});
}}
>
{part?.locked ? <IconLock /> : <IconLockOpen />}
</ActionIcon>
) : 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);
}}
editAction={editPart.open}
editEnabled={user.hasChangeRole(UserRoles.part)}
actions={partActions}
/>
<PanelGroup
pageKey='part'
panels={partPanels}
instance={part}
reloadInstance={refreshInstance}
model={ModelType.part}
id={part.pk}
/>
</Stack>
</NavigationTree>
</Stack> </Stack>
</InstanceDetail> </InstanceDetail>
</> </>
+35 -32
View File
@@ -438,38 +438,41 @@ export default function Stock() {
opened={treeOpen} opened={treeOpen}
onClose={() => setTreeOpen(false)} onClose={() => setTreeOpen(false)}
selectedId={location?.pk} selectedId={location?.pk}
/> >
<PageDetail <Stack>
title={(location?.name ?? id) ? t`Stock Location` : t`Stock`} <PageDetail
subtitle={location?.description} title={(location?.name ?? id) ? t`Stock Location` : t`Stock`}
icon={location?.icon && <ApiIcon name={location?.icon} />} subtitle={location?.description}
actions={location?.pk ? locationActions : undefined} icon={location?.icon && <ApiIcon name={location?.icon} />}
editAction={editLocation.open} actions={location?.pk ? locationActions : undefined}
editEnabled={ editAction={editLocation.open}
!!location?.pk && editEnabled={
user.hasChangePermission(ModelType.stocklocation) !!location?.pk &&
} user.hasChangePermission(ModelType.stocklocation)
breadcrumbs={breadcrumbs} }
lastCrumb={[ breadcrumbs={breadcrumbs}
{ lastCrumb={[
name: location.name, {
url: `/stock/location/${location.pk}/` name: location.name,
} url: `/stock/location/${location.pk}/`
]} }
breadcrumbAction={() => { ]}
setTreeOpen(true); breadcrumbAction={() => {
}} setTreeOpen(true);
/> }}
<PanelGroup />
pageKey='stocklocation' <PanelGroup
panels={locationPanels} pageKey='stocklocation'
model={ModelType.stocklocation} panels={locationPanels}
reloadInstance={refreshInstance} model={ModelType.stocklocation}
id={location?.pk} reloadInstance={refreshInstance}
instance={location} id={location?.pk}
pluginPanelWithoutId instance={location}
defaultPanel={defaultPanel} pluginPanelWithoutId
/> defaultPanel={defaultPanel}
/>
</Stack>
</NavigationTree>
</Stack> </Stack>
{stockAdjustActions.modals.map((modal) => modal.modal)} {stockAdjustActions.modals.map((modal) => modal.modal)}
</InstanceDetail> </InstanceDetail>
+43 -41
View File
@@ -728,47 +728,49 @@ export default function StockDetail() {
requiredPermission={ModelType.stockitem} requiredPermission={ModelType.stockitem}
> >
<Stack> <Stack>
{user.hasViewRole(UserRoles.stock_location) && ( <NavigationTree
<NavigationTree hidden={!user.hasViewRole(UserRoles.stock_location)}
title={t`Stock Locations`} title={t`Stock Locations`}
modelType={ModelType.stocklocation} modelType={ModelType.stocklocation}
endpoint={ApiEndpoints.stock_location_tree} endpoint={ApiEndpoints.stock_location_tree}
childIdentifier='sublocations' childIdentifier='sublocations'
opened={treeOpen} opened={treeOpen}
onClose={() => setTreeOpen(false)} onClose={() => setTreeOpen(false)}
selectedId={stockitem?.location} selectedId={stockitem?.location}
/> >
)} <Stack>
<PageDetail <PageDetail
title={t`Stock Item`} title={t`Stock Item`}
subtitle={stockitem.part_detail?.full_name} subtitle={stockitem.part_detail?.full_name}
imageUrl={stockitem.part_detail?.thumbnail} imageUrl={stockitem.part_detail?.thumbnail}
editAction={editStockItem.open} editAction={editStockItem.open}
editEnabled={user.hasChangePermission(ModelType.stockitem)} editEnabled={user.hasChangePermission(ModelType.stockitem)}
badges={stockBadges} badges={stockBadges}
breadcrumbs={ breadcrumbs={
user.hasViewRole(UserRoles.stock_location) user.hasViewRole(UserRoles.stock_location)
? breadcrumbs ? breadcrumbs
: undefined : undefined
} }
lastCrumb={[ lastCrumb={[
{ {
name: stockitem.name, name: stockitem.name,
url: `/stock/item/${stockitem.pk}/` url: `/stock/item/${stockitem.pk}/`
} }
]} ]}
breadcrumbAction={() => { breadcrumbAction={() => {
setTreeOpen(true); setTreeOpen(true);
}} }}
actions={stockActions} actions={stockActions}
/> />
<PanelGroup <PanelGroup
pageKey='stockitem' pageKey='stockitem'
panels={stockPanels} panels={stockPanels}
model={ModelType.stockitem} model={ModelType.stockitem}
id={stockitem.pk} id={stockitem.pk}
instance={stockitem} instance={stockitem}
/> />
</Stack>
</NavigationTree>
</Stack> </Stack>
</InstanceDetail> </InstanceDetail>
{editStockItem.modal} {editStockItem.modal}