Add TreeField component
This commit is contained in:
parent
50577da65a
commit
0b9187c8f0
|
|
@ -100,7 +100,9 @@ export type ApiFormFieldType = {
|
|||
| 'nested object'
|
||||
| 'dependent field'
|
||||
| 'table'
|
||||
| 'tags';
|
||||
| 'tags'
|
||||
| 'location'
|
||||
| 'category';
|
||||
api_url?: string;
|
||||
pk_field?: string;
|
||||
model?: ModelType;
|
||||
|
|
|
|||
|
|
@ -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`}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue