From 0b9187c8f0223af5ecb9edab1606cb4dfb2527f3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 21 Jun 2026 03:26:42 +0000 Subject: [PATCH] Add TreeField component --- src/frontend/lib/types/Forms.tsx | 4 +- .../components/forms/fields/ApiFormField.tsx | 20 +++ .../src/components/forms/fields/TreeField.tsx | 151 ++++++++++++++++++ src/frontend/src/forms/PartForms.tsx | 4 +- 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 src/frontend/src/components/forms/fields/TreeField.tsx diff --git a/src/frontend/lib/types/Forms.tsx b/src/frontend/lib/types/Forms.tsx index 03acfb0ad9..b9574225e5 100644 --- a/src/frontend/lib/types/Forms.tsx +++ b/src/frontend/lib/types/Forms.tsx @@ -100,7 +100,9 @@ export type ApiFormFieldType = { | 'nested object' | 'dependent field' | 'table' - | 'tags'; + | 'tags' + | 'location' + | 'category'; api_url?: string; pk_field?: string; model?: ModelType; diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 85e128a1fd..b5196609a8 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -4,6 +4,7 @@ import { useId } from '@mantine/hooks'; import { useCallback, useEffect, useMemo } from 'react'; import { type Control, type FieldValues, useController } from 'react-hook-form'; +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms'; import { IconFileUpload } from '@tabler/icons-react'; import type { NavigateFunction } from 'react-router-dom'; @@ -19,6 +20,7 @@ import { RelatedModelField } from './RelatedModelField'; import { TableField } from './TableField'; import TagsField from './TagsField'; import TextField from './TextField'; +import { TreeField } from './TreeField'; /** * Render an individual form field @@ -254,6 +256,24 @@ export function ApiFormField({ return ( ); + case 'location': + return ( + + ); + case 'category': + return ( + + ); default: return ( diff --git a/src/frontend/src/components/forms/fields/TreeField.tsx b/src/frontend/src/components/forms/fields/TreeField.tsx new file mode 100644 index 0000000000..ef2fa218d1 --- /dev/null +++ b/src/frontend/src/components/forms/fields/TreeField.tsx @@ -0,0 +1,151 @@ +import { t } from '@lingui/core/macro'; +import { Group, Text, type TreeNodeData, TreeSelect } from '@mantine/core'; +import { useDebouncedValue } from '@mantine/hooks'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { FieldValues, UseControllerReturn } from 'react-hook-form'; + +import type { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import { apiUrl } from '@lib/functions/Api'; +import type { ApiFormFieldType } from '@lib/types/Forms'; +import { useApi } from '../../../contexts/ApiContext'; +import { ApiIcon } from '../../items/ApiIcon'; + +/** + * A form field that renders a hierarchical tree selector backed by a tree API endpoint. + * Supports server-side search: when the user types, the API is queried with the search term + * and the backend returns matching nodes plus their ancestors for context. + */ +export function TreeField({ + controller, + definition, + fieldName, + endpoint +}: Readonly<{ + controller: UseControllerReturn; + definition: ApiFormFieldType; + fieldName: string; + endpoint: ApiEndpoints; +}>) { + const api = useApi(); + const { + field, + fieldState: { error } + } = controller; + + const [searchValue, setSearchValue] = useState(''); + const [debouncedSearch] = useDebouncedValue(searchValue, 300); + const [expandedValues, setExpandedValues] = useState([]); + + const query = useQuery({ + queryKey: ['tree-field', fieldName, endpoint, debouncedSearch], + queryFn: () => + api + .get(apiUrl(endpoint), { + params: { + ordering: 'level', + search: debouncedSearch || undefined + } + }) + .then((res) => res.data ?? []) + }); + + const nodes: any[] = useMemo(() => query.data ?? [], [query.data]); + + // O(1) lookup map for renderNode + const nodeMap = useMemo(() => { + const map: Record = {}; + for (const n of nodes) { + map[n.pk.toString()] = n; + } + return map; + }, [nodes]); + + // Expand all returned nodes when a search is active so users can see all matches. + // Collapse back to root when the search is cleared. + useEffect(() => { + if (debouncedSearch) { + setExpandedValues(nodes.map((n: any) => n.pk.toString())); + } else { + setExpandedValues([]); + } + }, [debouncedSearch, nodes]); + + // Convert the flat API response (sorted by level) into the nested TreeNodeData structure. + const treeData: TreeNodeData[] = useMemo(() => { + const map: Record = {}; + const tree: TreeNodeData[] = []; + + const sorted = [...nodes].sort((a, b) => a.level - b.level); + + for (const raw of sorted) { + const node = { + value: raw.pk.toString(), + label: raw.name as string, + children: [] as TreeNodeData[] + }; + + map[raw.pk] = node; + + if (!raw.parent) { + tree.push(node); + } else { + map[raw.parent]?.children.push(node); + } + } + + return tree; + }, [nodes]); + + const onChange = useCallback( + (val: string | null) => { + const pk = val ? Number.parseInt(val) : null; + field.onChange(pk); + definition.onValueChange?.(pk); + }, + [field, definition] + ); + + const selectValue = useMemo( + () => (field.value != null ? field.value.toString() : null), + [field.value] + ); + + return ( + true} + clearable={!definition.required} + expandedValues={expandedValues} + onExpandedChange={setExpandedValues} + expandOnClick + label={definition.label} + description={definition.description} + placeholder={definition.placeholder ?? t`Select...`} + required={definition.required} + disabled={definition.disabled} + error={definition.error ?? error?.message} + comboboxProps={{ withinPortal: true }} + maxDropdownHeight={300} + nothingFoundMessage={ + query.isFetching ? t`Loading...` : t`No results found` + } + renderNode={({ node, selected }) => { + const raw = nodeMap[node.value]; + return ( + + {raw?.icon && } + + {String(node.label)} + + + ); + }} + /> + ); +} diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 311b64b18f..6272f2b80d 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -228,11 +228,13 @@ export function partCategoryFields({ const fields: ApiFormFieldSet = { parent: { description: t`Parent part category`, - required: false + required: false, + field_type: 'category' }, name: {}, description: {}, default_location: { + field_type: 'location', filters: { structural: false }