From bf0a77f8a051c351ed8e4f3cd483f146fc0f7474 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 19 Jun 2026 00:58:02 +0000 Subject: [PATCH] Add search input to NavigationTree --- .../src/components/nav/NavigationTree.tsx | 59 +++++++++++++++---- src/frontend/src/pages/part/PartDetail.tsx | 1 + 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/frontend/src/components/nav/NavigationTree.tsx b/src/frontend/src/components/nav/NavigationTree.tsx index 05ebddb63f..36b3bf2bdc 100644 --- a/src/frontend/src/components/nav/NavigationTree.tsx +++ b/src/frontend/src/components/nav/NavigationTree.tsx @@ -9,24 +9,29 @@ import { type RenderTreeNodePayload, Space, Stack, + TextInput, Tree, type TreeNodeData, useTree } from '@mantine/core'; +import { useDebouncedValue } from '@mantine/hooks'; import { IconChevronDown, IconChevronRight, IconExclamationCircle, - IconSitemap + IconSearch, + IconSitemap, + IconX } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; -import { useCallback, useMemo } from 'react'; +import { 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 type { ModelType } from '@lib/enums/ModelType'; import { apiUrl } from '@lib/functions/Api'; +import { resolveItem } from '@lib/functions/Conversion'; import { eventModified, getDetailUrl, @@ -45,6 +50,7 @@ export default function NavigationTree({ onClose, selectedId, modelType, + childIdentifier, endpoint }: Readonly<{ title: string; @@ -52,26 +58,40 @@ export default function NavigationTree({ onClose: () => void; selectedId?: number | null; modelType: ModelType; + childIdentifier: string; endpoint: ApiEndpoints; }>) { const api = useApi(); const navigate = useNavigate(); const treeState = useTree(); + const [searchValue, setSearchValue] = useState(''); + const [debouncedSearch] = useDebouncedValue(searchValue, 300); + // Data query to fetch the tree data from server const query = useQuery({ enabled: opened, - queryKey: [modelType, opened], - queryFn: async () => - api + queryKey: [modelType, opened, debouncedSearch], + queryFn: async () => { + const params: Record = { ordering: 'level' }; + return api .get(apiUrl(endpoint), { - data: { - ordering: 'level' + params: { + search: debouncedSearch || undefined, + max_level: debouncedSearch ? undefined : 0 } }) - .then((response) => response.data ?? []) + .then((response) => response.data ?? []); + } }); + // Expand all nodes when a search is active so ancestors are visible + useEffect(() => { + if (debouncedSearch) { + treeState.expandAllNodes(); + } + }, [debouncedSearch, query.data]); + const follow = useCallback( (node: TreeNodeData, event?: any) => { const url = getDetailUrl(modelType, node.value); @@ -143,7 +163,8 @@ export default function NavigationTree({ const renderNode = useCallback( (payload: RenderTreeNodePayload) => { const hasChildren: boolean = - payload.hasChildren && (payload.node as any).children.length > 0; + (payload.hasChildren && (payload.node as any).children.length > 0) || + resolveItem(payload.node, childIdentifier); return ( ); }, - [treeState] + [treeState, childIdentifier, follow] ); return ( @@ -208,6 +229,24 @@ export default function NavigationTree({ } > + setSearchValue(event.currentTarget.value)} + leftSection={} + rightSection={ + searchValue ? ( + setSearchValue('')} + aria-label={t`Clear search`} + > + + + ) : null + } + /> {query.isError ? ( diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 5182a1c249..af83e85522 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -1152,6 +1152,7 @@ export default function PartDetail() { {user.hasViewRole(UserRoles.part_category) && (