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
}