Add TreeField component

This commit is contained in:
Oliver Walters 2026-06-21 03:26:42 +00:00
parent 50577da65a
commit 0b9187c8f0
4 changed files with 177 additions and 2 deletions

View File

@ -100,7 +100,9 @@ export type ApiFormFieldType = {
| 'nested object'
| 'dependent field'
| 'table'
| 'tags';
| 'tags'
| 'location'
| 'category';
api_url?: string;
pk_field?: string;
model?: ModelType;

View File

@ -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 (
<TagsField controller={controller} definition={fieldDefinition} />
);
case 'location':
return (
<TreeField
controller={controller}
definition={fieldDefinition}
fieldName={fieldName}
endpoint={ApiEndpoints.stock_location_tree}
/>
);
case 'category':
return (
<TreeField
controller={controller}
definition={fieldDefinition}
fieldName={fieldName}
endpoint={ApiEndpoints.category_tree}
/>
);
default:
return (
<Alert color='red' title={t`Error`}>

View File

@ -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<FieldValues, any>;
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<string[]>([]);
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<string, any> = {};
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<number, TreeNodeData & { children: TreeNodeData[] }> = {};
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 (
<TreeSelect
data={treeData}
value={selectValue}
onChange={onChange}
searchValue={searchValue}
onSearchChange={setSearchValue}
searchable
filter={() => 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 (
<Group gap='xs' wrap='nowrap'>
{raw?.icon && <ApiIcon name={raw.icon} />}
<Text size='sm' fw={selected ? 600 : undefined}>
{String(node.label)}
</Text>
</Group>
);
}}
/>
);
}

View File

@ -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
}