mirror of
https://github.com/inventree/InvenTree.git
synced 2026-10-14 14:11:55 +00:00
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:
1 parent
47f2e6acdd
commit
600dc1a75b
8 files changed
+154
-37
No files matched your search
@@ -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") }}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<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), {
|
||||
@@ -350,32 +392,31 @@ export default function NavigationTree({
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
size='lg'
|
||||
position='left'
|
||||
onClose={onClose}
|
||||
withCloseButton={true}
|
||||
styles={{
|
||||
header: {
|
||||
width: '100%'
|
||||
},
|
||||
title: {
|
||||
width: '100%'
|
||||
}
|
||||
}}
|
||||
title={
|
||||
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)}
|
||||
>
|
||||
<Stack gap='xs'>
|
||||
{pinned ? <IconPinnedOff /> : <IconPin />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Stack gap='xs' pos='relative'>
|
||||
<TextInput
|
||||
aria-label='nav-tree-search'
|
||||
placeholder={t`Search...`}
|
||||
placeholder={t`Filter ${{ title }}...`}
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
@@ -412,6 +453,61 @@ export default function NavigationTree({
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (hidden) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (pinned) {
|
||||
return (
|
||||
<Splitter
|
||||
onCollapseChange={(event) => {
|
||||
console.log(event);
|
||||
setPinned(!pinned);
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -318,7 +318,8 @@ export default function CategoryDetail() {
|
||||
setTreeOpen(false);
|
||||
}}
|
||||
selectedId={category?.pk}
|
||||
/>
|
||||
>
|
||||
<Stack gap='xs'>
|
||||
<PageDetail
|
||||
title={(category?.name ?? id) ? t`Part Category` : t`Parts`}
|
||||
subtitle={category?.description}
|
||||
@@ -330,7 +331,8 @@ export default function CategoryDetail() {
|
||||
actions={categoryActions}
|
||||
editAction={editCategory.open}
|
||||
editEnabled={
|
||||
!!category?.pk && user.hasChangePermission(ModelType.partcategory)
|
||||
!!category?.pk &&
|
||||
user.hasChangePermission(ModelType.partcategory)
|
||||
}
|
||||
/>
|
||||
<PanelGroup
|
||||
@@ -343,6 +345,8 @@ export default function CategoryDetail() {
|
||||
defaultPanel={defaultPanel}
|
||||
/>
|
||||
</Stack>
|
||||
</NavigationTree>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -797,8 +797,8 @@ export default function PartDetail() {
|
||||
{stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
<InstanceDetail query={instanceQuery} requiredRole={UserRoles.part}>
|
||||
<Stack gap='xs'>
|
||||
{user.hasViewRole(UserRoles.part_category) && (
|
||||
<NavigationTree
|
||||
hidden={!user.hasViewRole(UserRoles.part_category)}
|
||||
title={t`Part Categories`}
|
||||
childIdentifier='subcategories'
|
||||
modelType={ModelType.partcategory}
|
||||
@@ -808,8 +808,8 @@ export default function PartDetail() {
|
||||
setTreeOpen(false);
|
||||
}}
|
||||
selectedId={part?.category}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Stack gap='xs'>
|
||||
<PageDetail
|
||||
title={`${t`Part`}: ${part.full_name}`}
|
||||
icon={
|
||||
@@ -828,7 +828,9 @@ export default function PartDetail() {
|
||||
notifications.hide('part-lock');
|
||||
notifications.show({
|
||||
id: 'part-lock',
|
||||
message: locking ? t`Part locked` : t`Part unlocked`,
|
||||
message: locking
|
||||
? t`Part locked`
|
||||
: t`Part unlocked`,
|
||||
color: 'green',
|
||||
icon: locking ? (
|
||||
<IconLock size='1rem' />
|
||||
@@ -875,6 +877,8 @@ export default function PartDetail() {
|
||||
id={part.pk}
|
||||
/>
|
||||
</Stack>
|
||||
</NavigationTree>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -438,7 +438,8 @@ export default function Stock() {
|
||||
opened={treeOpen}
|
||||
onClose={() => setTreeOpen(false)}
|
||||
selectedId={location?.pk}
|
||||
/>
|
||||
>
|
||||
<Stack>
|
||||
<PageDetail
|
||||
title={(location?.name ?? id) ? t`Stock Location` : t`Stock`}
|
||||
subtitle={location?.description}
|
||||
@@ -471,6 +472,8 @@ export default function Stock() {
|
||||
defaultPanel={defaultPanel}
|
||||
/>
|
||||
</Stack>
|
||||
</NavigationTree>
|
||||
</Stack>
|
||||
{stockAdjustActions.modals.map((modal) => modal.modal)}
|
||||
</InstanceDetail>
|
||||
</>
|
||||
|
||||
@@ -728,8 +728,8 @@ export default function StockDetail() {
|
||||
requiredPermission={ModelType.stockitem}
|
||||
>
|
||||
<Stack>
|
||||
{user.hasViewRole(UserRoles.stock_location) && (
|
||||
<NavigationTree
|
||||
hidden={!user.hasViewRole(UserRoles.stock_location)}
|
||||
title={t`Stock Locations`}
|
||||
modelType={ModelType.stocklocation}
|
||||
endpoint={ApiEndpoints.stock_location_tree}
|
||||
@@ -737,8 +737,8 @@ export default function StockDetail() {
|
||||
opened={treeOpen}
|
||||
onClose={() => setTreeOpen(false)}
|
||||
selectedId={stockitem?.location}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
<PageDetail
|
||||
title={t`Stock Item`}
|
||||
subtitle={stockitem.part_detail?.full_name}
|
||||
@@ -770,6 +770,8 @@ export default function StockDetail() {
|
||||
instance={stockitem}
|
||||
/>
|
||||
</Stack>
|
||||
</NavigationTree>
|
||||
</Stack>
|
||||
</InstanceDetail>
|
||||
{editStockItem.modal}
|
||||
{deleteStockItem.modal}
|
||||
|
||||
Reference in new issue
Block a user