Skip to content

Commit

Permalink
[PUI] Mantine tree (#7357)
Browse files Browse the repository at this point in the history
* Refactor part category tree

- New "NavigationTree" using native mantine components
- Make it generic, too

* Replace existing <StockLocationTree /> component

* Adjust API filtering for location tree endpoint

* Added playwright tests

* Update api filter classes

* Fix for DetailsImage

- Update to @mantine/core had changed the <AspectRatio> component

* fix for identifierString function

* Adjust playwright tests
  • Loading branch information
SchrodingersGat authored May 28, 2024
1 parent c90ee43 commit f3223c6
Show file tree
Hide file tree
Showing 18 changed files with 361 additions and 389 deletions.
2 changes: 2 additions & 0 deletions src/backend/InvenTree/InvenTree/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,5 @@ def get_ordering(self, request, queryset, view):
]

ORDER_FILTER = [rest_filters.DjangoFilterBackend, filters.OrderingFilter]

ORDER_FILTER_ALIAS = [rest_filters.DjangoFilterBackend, InvenTreeOrderingFilter]
5 changes: 4 additions & 1 deletion src/backend/InvenTree/part/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
)
from InvenTree.filters import (
ORDER_FILTER,
ORDER_FILTER_ALIAS,
SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS,
InvenTreeDateFilter,
Expand Down Expand Up @@ -303,10 +304,12 @@ class CategoryTree(ListAPI):
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategoryTree

filter_backends = ORDER_FILTER
filter_backends = ORDER_FILTER_ALIAS

ordering_fields = ['level', 'name', 'subcategories']

ordering_field_aliases = {'level': ['level', 'name'], 'name': ['name', 'level']}

# Order by tree level (top levels first) and then name
ordering = ['level', 'name']

Expand Down
6 changes: 4 additions & 2 deletions src/backend/InvenTree/stock/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
MetadataView,
)
from InvenTree.filters import (
ORDER_FILTER,
ORDER_FILTER_ALIAS,
SEARCH_ORDER_FILTER,
SEARCH_ORDER_FILTER_ALIAS,
InvenTreeDateFilter,
Expand Down Expand Up @@ -429,13 +429,15 @@ class StockLocationTree(ListAPI):
queryset = StockLocation.objects.all()
serializer_class = StockSerializers.LocationTreeSerializer

filter_backends = ORDER_FILTER
filter_backends = ORDER_FILTER_ALIAS

ordering_fields = ['level', 'name', 'sublocations']

# Order by tree level (top levels first) and then name
ordering = ['level', 'name']

ordering_field_aliases = {'level': ['level', 'name'], 'name': ['name', 'level']}

def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for the StockLocationTree endpoint."""
queryset = super().get_queryset(*args, **kwargs)
Expand Down
5 changes: 2 additions & 3 deletions src/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"@lingui/core": "^4.10.0",
"@lingui/react": "^4.10.0",
"@mantine/carousel": "^7.8.0",
"@mantine/core": "^7.8.0",
"@mantine/core": "^7.10.0",
"@mantine/dates": "^7.8.0",
"@mantine/dropzone": "^7.8.0",
"@mantine/form": "^7.8.0",
Expand All @@ -36,7 +36,6 @@
"@mantine/notifications": "^7.8.0",
"@mantine/spotlight": "^7.8.0",
"@mantine/vanilla-extract": "^7.8.0",
"@naisutech/react-tree": "^3.1.0",
"@sentry/react": "^7.110.0",
"@tabler/icons-react": "^3.2.0",
"@tanstack/react-query": "^5.29.2",
Expand All @@ -60,7 +59,7 @@
"react-router-dom": "^6.22.3",
"react-select": "^5.8.0",
"react-simplemde-editor": "^5.2.0",
"recharts": "^2.12.4",
"recharts": "2",
"styled-components": "^6.1.8",
"zustand": "^4.5.2"
},
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/src/components/details/DetailsImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {

return (
<>
<AspectRatio ref={ref} maw={IMAGE_DIMENSION} ratio={1}>
<AspectRatio ref={ref} maw={IMAGE_DIMENSION} ratio={1} pos="relative">
<>
<ApiImage
src={img}
Expand Down
7 changes: 6 additions & 1 deletion src/frontend/src/components/nav/BreadcrumbList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { IconMenu2 } from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';

import { identifierString } from '../../functions/conversion';
import { navigateToLink } from '../../functions/navigation';

export type Breadcrumb = {
Expand Down Expand Up @@ -47,7 +48,8 @@ export function BreadcrumbList({
<Group gap="xs">
{navCallback && (
<ActionIcon
key="nav-action"
key="nav-breadcrumb-action"
aria-label="nav-breadcrumb-action"
onClick={navCallback}
variant="transparent"
>
Expand All @@ -59,6 +61,9 @@ export function BreadcrumbList({
return (
<Anchor
key={index}
aria-label={`breadcrumb-${index}-${identifierString(
breadcrumb.name
)}`}
onClick={(event: any) =>
breadcrumb.url &&
navigateToLink(breadcrumb.url, navigate, event)
Expand Down
205 changes: 205 additions & 0 deletions src/frontend/src/components/nav/NavigationTree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import {
ActionIcon,
Anchor,
Divider,
Drawer,
Group,
LoadingOverlay,
RenderTreeNodePayload,
Space,
Stack,
Tree,
TreeNodeData,
useTree
} from '@mantine/core';
import {
IconChevronDown,
IconChevronRight,
IconPoint,
IconSitemap
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';

import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { navigateToLink } from '../../functions/navigation';
import { getDetailUrl } from '../../functions/urls';
import { apiUrl } from '../../states/ApiState';
import { StylishText } from '../items/StylishText';

/*
* A generic navigation tree component.
*/
export default function NavigationTree({
title,
opened,
onClose,
selectedId,
modelType,
endpoint
}: {
title: string;
opened: boolean;
onClose: () => void;
selectedId?: number | null;
modelType: ModelType;
endpoint: ApiEndpoints;
}) {
const navigate = useNavigate();
const treeState = useTree();

// Data query to fetch the tree data from server
const query = useQuery({
enabled: opened,
queryKey: [modelType, opened],
queryFn: async () =>
api
.get(apiUrl(endpoint), {
data: {
ordering: 'level'
}
})
.then((response) => response.data ?? [])
.catch((error) => {
console.error(`Error fetching ${modelType} tree`);
return [];
})
});

const follow = useCallback(
(node: TreeNodeData, event?: any) => {
const url = getDetailUrl(modelType, node.value);
if (event?.shiftKey || event?.ctrlKey) {
navigateToLink(url, navigate, event);
} else {
onClose();
navigate(url);
}
},
[modelType, navigate]
);

// Map returned query to a "tree" structure
const data: TreeNodeData[] = useMemo(() => {
/*
* Reconstruct the navigation tree from the provided data.
* It is required (and assumed) that the data is first sorted by level.
*/

let nodes: Record<number, any> = {};
let tree: TreeNodeData[] = [];

if (!query?.data?.length) {
return [];
}

for (let ii = 0; ii < query.data.length; ii++) {
let node = {
...query.data[ii],
children: [],
label: query.data[ii].name,
value: query.data[ii].pk.toString(),
selected: query.data[ii].pk === selectedId
};

const pk: number = node.pk;
const parent: number | null = node.parent;

if (!parent) {
// This is a top level node
tree.push(node);
} else {
// This is *not* a top level node, so the parent *must* already exist
nodes[parent]?.children.push(node);
}

// Finally, add this node
nodes[pk] = node;

if (pk === selectedId) {
// Expand all parents
let parent = nodes[node.parent];
while (parent) {
parent.expanded = true;
parent = nodes[parent.parent];
}
}
}

return tree;
}, [selectedId, query.data]);

const renderNode = useCallback(
(payload: RenderTreeNodePayload) => {
return (
<Group
justify="left"
key={payload.node.value}
wrap="nowrap"
onClick={() => {
if (payload.hasChildren) {
treeState.toggleExpanded(payload.node.value);
}
}}
>
<Space w={5 * payload.level} />
<ActionIcon
size="sm"
variant="transparent"
aria-label={`nav-tree-toggle-${payload.node.value}}`}
>
{payload.hasChildren ? (
payload.expanded ? (
<IconChevronDown />
) : (
<IconChevronRight />
)
) : (
<IconPoint />
)}
</ActionIcon>
<Anchor
onClick={(event: any) => follow(payload.node, event)}
aria-label={`nav-tree-item-${payload.node.value}`}
>
{payload.node.label}
</Anchor>
</Group>
);
},
[treeState]
);

return (
<Drawer
opened={opened}
size="md"
position="left"
onClose={onClose}
withCloseButton={true}
styles={{
header: {
width: '100%'
},
title: {
width: '100%'
}
}}
title={
<Group justify="left" p="ms" gap="md" wrap="nowrap">
<IconSitemap />
<StylishText size="lg">{title}</StylishText>
</Group>
}
>
<Stack gap="xs">
<Divider />
<LoadingOverlay visible={query.isFetching || query.isLoading} />
<Tree data={data} tree={treeState} renderNode={renderNode} />
</Stack>
</Drawer>
);
}
4 changes: 4 additions & 0 deletions src/frontend/src/components/nav/PanelGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
useParams
} from 'react-router-dom';

import { identifierString } from '../../functions/conversion';
import { navigateToLink } from '../../functions/navigation';
import { useLocalState } from '../../states/LocalState';
import { Boundary } from '../Boundary';
Expand Down Expand Up @@ -172,6 +173,9 @@ function BasePanelGroup({
<Tabs.Panel
key={panel.name}
value={panel.name}
aria-label={`nav-panel-${identifierString(
`${pageKey}-${panel.name}`
)}`}
p="sm"
style={{
overflowX: 'scroll',
Expand Down
Loading

0 comments on commit f3223c6

Please sign in to comment.