Skip to content
Merged
1 change: 1 addition & 0 deletions docs/docs/settings/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -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") }}
Expand Down
6 changes: 6 additions & 0 deletions src/backend/InvenTree/common/setting/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,12 @@ def label_printer_options():
'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'),
Expand Down
234 changes: 165 additions & 69 deletions src/frontend/src/components/nav/NavigationTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,41 @@
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';
Expand All @@ -42,6 +53,7 @@
} 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';

/*
Expand All @@ -54,7 +66,9 @@
selectedId,
modelType,
childIdentifier,
endpoint
endpoint,
hidden,
children
}: Readonly<{
title: string;
opened: boolean;
Expand All @@ -63,11 +77,39 @@
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);

Expand All @@ -76,17 +118,17 @@
// PKs of nodes whose children are currently being fetched
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(() => {
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), {
Expand Down Expand Up @@ -350,68 +392,122 @@
]
);

return (
<Drawer
opened={opened}
size='lg'
position='left'
onClose={onClose}
withCloseButton={true}
styles={{
header: {
width: '100%'
},
title: {
width: '100%'
const header = (
<Group justify='space-between' wrap='nowrap' w='100%'>
<Group justify='left' p='ms' gap='md' wrap='nowrap'>
<IconSitemap />
<StylishText size='lg'>{title}</StylishText>
</Group>
{canPin && (
<Tooltip label={pinned ? t`Unpin` : t`Pin`} position='bottom'>
<ActionIcon
variant='transparent'
aria-label={pinned ? 'nav-tree-unpin' : 'nav-tree-pin'}
onClick={() => setPinned(!pinned)}
>
{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={
<Group justify='left' p='ms' gap='md' wrap='nowrap'>
<IconSitemap />
<StylishText size='lg'>{title}</StylishText>
</Group>
}
>
<Stack gap='xs'>
<TextInput
aria-label='nav-tree-search'
placeholder={t`Search...`}
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
}
/>
<Divider />
<LoadingOverlay visible={query.isFetching || query.isLoading} />
{query.isError ? (
<Alert color='red' title={t`Error`} icon={<IconExclamationCircle />}>
{t`Error loading navigation tree.`}
</Alert>
) : !query.isFetching && !query.isLoading && data.length === 0 ? (
<Alert color='blue' icon={<IconSearch />}>
{t`No results found`}
</Alert>
) : (
<Tree
data={data}
tree={treeState}
renderNode={renderNode}
withLines
levelOffset={25}
/>
<Divider />
<LoadingOverlay visible={query.isFetching || query.isLoading} />
{query.isError ? (
<Alert color='red' title={t`Error`} icon={<IconExclamationCircle />}>
{t`Error loading navigation tree.`}
</Alert>
) : !query.isFetching && !query.isLoading && data.length === 0 ? (
<Alert color='blue' icon={<IconSearch />}>
{t`No results found`}
</Alert>
) : (
<Tree
data={data}
tree={treeState}
renderNode={renderNode}
withLines
levelOffset={25}
/>
)}
</Stack>
</Drawer>
)}
</Stack>
);

if (hidden) {
return <>{children}</>;
}

if (pinned) {
return (
<Splitter
onCollapseChange={(event) => {
console.log(event);
setPinned(!pinned);

Check warning on line 467 in src/frontend/src/components/nav/NavigationTree.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This always evaluates to truthy. Consider refactoring this code.

See more on https://sonarcloud.io/project/issues?id=inventree_InvenTree&issues=AaEQF5Sekn0hfqpjJqrx&open=AaEQF5Sekn0hfqpjJqrx&pullRequest=12974
}}
>
<Splitter.Pane defaultSize='350px' min={'200px'} collapsible>
<Paper
withBorder
p='xs'
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}
</>
);
}
1 change: 1 addition & 0 deletions src/frontend/src/pages/Index/Settings/UserSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
52 changes: 28 additions & 24 deletions src/frontend/src/pages/part/CategoryDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,30 +318,34 @@ export default function CategoryDetail() {
setTreeOpen(false);
}}
selectedId={category?.pk}
/>
<PageDetail
title={(category?.name ?? id) ? t`Part Category` : t`Parts`}
subtitle={category?.description}
icon={category?.icon && <ApiIcon name={category?.icon} />}
breadcrumbs={breadcrumbs}
breadcrumbAction={() => {
setTreeOpen(true);
}}
actions={categoryActions}
editAction={editCategory.open}
editEnabled={
!!category?.pk && user.hasChangePermission(ModelType.partcategory)
}
/>
<PanelGroup
pageKey='partcategory'
panels={panels}
model={ModelType.partcategory}
instance={category}
reloadInstance={refreshInstance}
id={category.pk ?? null}
defaultPanel={defaultPanel}
/>
>
<Stack gap='xs'>
<PageDetail
title={(category?.name ?? id) ? t`Part Category` : t`Parts`}
subtitle={category?.description}
icon={category?.icon && <ApiIcon name={category?.icon} />}
breadcrumbs={breadcrumbs}
breadcrumbAction={() => {
setTreeOpen(true);
}}
actions={categoryActions}
editAction={editCategory.open}
editEnabled={
!!category?.pk &&
user.hasChangePermission(ModelType.partcategory)
}
/>
<PanelGroup
pageKey='partcategory'
panels={panels}
model={ModelType.partcategory}
instance={category}
reloadInstance={refreshInstance}
id={category.pk ?? null}
defaultPanel={defaultPanel}
/>
</Stack>
</NavigationTree>
</Stack>
</InstanceDetail>
</>
Expand Down
Loading
Loading