Древовидные справочники: выбор колонки для дерева (treeColumnIndex) и API /rows/tree

This commit is contained in:
2026-07-12 21:59:38 +03:00
parent ec08f3222d
commit 782731674b
9 changed files with 638 additions and 224 deletions

View File

@@ -5,7 +5,8 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { Table, X } from 'lucide-react';
import { DirectoryTreeMultiSelect } from '@/components/directories/DirectoryTreeMultiSelect';
import { DirectoryTreeMultiSelect, type DirectoryTreeNode } from '@/components/directories/DirectoryTreeMultiSelect';
import { apiRequest } from '@/lib/queryClient';
import type { DataTableRow } from '@shared/schema';
interface TableFieldMultiSelectProps {
@@ -33,6 +34,7 @@ export default function TableFieldMultiSelect({
table: {
rows: DataTableRow[];
columns: Array<{ name: string }>;
treeColumnIndex: number | null;
};
}>({
queryKey: ['/api/directories', tableId],
@@ -42,12 +44,28 @@ export default function TableFieldMultiSelect({
retry: false,
});
const { data: treeData } = useQuery<{
columnIndex: number | null;
nodes: DirectoryTreeNode[];
}>({
queryKey: ['/api/directories', tableId, 'rows', 'tree'],
queryFn: async () => {
const res = await apiRequest('GET', `/api/directories/${tableId}/rows/tree`);
return res.json();
},
enabled: !!tableId && !!tableData,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: false,
});
const [searchTerm, setSearchTerm] = useState('');
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const table = tableData?.table;
const rows = table?.rows || [];
const nodes = treeData?.nodes || [];
let displayColumnIndex = table?.columns?.findIndex((c) => c.name === displayColumn);
if (displayColumnIndex === undefined || displayColumnIndex < 0) {
@@ -88,7 +106,7 @@ export default function TableFieldMultiSelect({
.filter(Boolean);
}, [selectedValues, rows, displayColumnIndex]);
const hasHierarchy = rows.some((r) => r.parentId !== null && r.parentId !== undefined);
const hasTree = nodes.some((n) => n.type === 'group');
const toggle = (rowId: string) => {
const next = new Set(selectedSet);
@@ -145,9 +163,9 @@ export default function TableFieldMultiSelect({
}}
>
<div className="max-h-60 overflow-auto">
{hasHierarchy ? (
{hasTree ? (
<DirectoryTreeMultiSelect
rows={rows}
nodes={nodes}
displayColumnIndex={displayColumnIndex!}
selected={selectedSet}
onToggle={toggle}

View File

@@ -3,7 +3,8 @@ import { useQuery } from '@tanstack/react-query';
import { Input } from '@/components/ui/input';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { Table } from 'lucide-react';
import { DirectoryTreeSelect } from '@/components/directories/DirectoryTreeSelect';
import { DirectoryTreeSelect, type DirectoryTreeNode } from '@/components/directories/DirectoryTreeSelect';
import { apiRequest } from '@/lib/queryClient';
import type { DataTableRow } from '@shared/schema';
interface TableFieldSelectProps {
@@ -31,6 +32,7 @@ export default function TableFieldSelect({
table: {
rows: DataTableRow[];
columns: Array<{ name: string }>;
treeColumnIndex: number | null;
};
}>({
queryKey: ['/api/directories', tableId],
@@ -40,12 +42,28 @@ export default function TableFieldSelect({
retry: false,
});
const { data: treeData } = useQuery<{
columnIndex: number | null;
nodes: DirectoryTreeNode[];
}>({
queryKey: ['/api/directories', tableId, 'rows', 'tree'],
queryFn: async () => {
const res = await apiRequest('GET', `/api/directories/${tableId}/rows/tree`);
return res.json();
},
enabled: !!tableId && !!tableData,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: false,
});
const [searchTerm, setSearchTerm] = useState('');
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const table = tableData?.table;
const rows = table?.rows || [];
const nodes = treeData?.nodes || [];
let displayColumnIndex = table?.columns?.findIndex((c) => c.name === displayColumn);
if (displayColumnIndex === undefined || displayColumnIndex < 0) {
@@ -69,6 +87,8 @@ export default function TableFieldSelect({
});
}, [sortedRows, searchTerm, displayColumnIndex]);
const hasTree = nodes.some((n) => n.type === 'group');
if (!tableId) {
return <div className="text-sm text-muted-foreground">Таблица не настроена</div>;
}
@@ -96,8 +116,6 @@ export default function TableFieldSelect({
setIsOpen(false);
};
const hasHierarchy = rows.some((r) => r.parentId !== null && r.parentId !== undefined);
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverAnchor asChild>
@@ -130,9 +148,9 @@ export default function TableFieldSelect({
}}
>
<div className="max-h-60 overflow-auto">
{hasHierarchy ? (
{hasTree ? (
<DirectoryTreeSelect
rows={rows}
nodes={nodes}
displayColumnIndex={displayColumnIndex!}
value={value}
onChange={handleSelect}

View File

@@ -0,0 +1,222 @@
import { useMemo, useState } from 'react';
import { ChevronRight, ChevronDown, FileText, Pencil, Trash2, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { DataTableRow } from '@shared/schema';
interface DirectoryColumnTreeProps {
rows: DataTableRow[];
treeColumnIndex: number;
displayColumnIndex?: number;
selectedRowId?: number | null;
onSelect: (row: DataTableRow) => void;
onEdit: (row: DataTableRow) => void;
onDelete: (row: DataTableRow) => void;
onAddChild: (parentValue: string | null) => void;
searchQuery?: string;
}
interface ColumnTreeGroup {
label: string;
rows: DataTableRow[];
}
function groupRows(rows: DataTableRow[], columnIndex: number): { groups: ColumnTreeGroup[]; rootRows: DataTableRow[] } {
const groups = new Map<string, DataTableRow[]>();
const rootRows: DataTableRow[] = [];
for (const row of rows) {
const values = Array.isArray(row.values) ? row.values : [];
const key = String(values[columnIndex] ?? '').trim();
if (!key) {
rootRows.push(row);
continue;
}
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(row);
}
const sortedGroups = Array.from(groups.entries())
.map(([label, groupRows]) => ({
label,
rows: [...groupRows].sort((a, b) => a.position - b.position),
}))
.sort((a, b) => a.label.localeCompare(b.label, 'ru'));
rootRows.sort((a, b) => a.position - b.position);
return { groups: sortedGroups, rootRows };
}
function getRowLabel(row: DataTableRow, displayColumnIndex = 0): string {
const values = Array.isArray(row.values) ? row.values : [];
return String(values[displayColumnIndex] ?? values[0] ?? '') || '(без названия)';
}
function filterGroups(
groups: ColumnTreeGroup[],
rootRows: DataTableRow[],
query: string,
displayColumnIndex: number
): { groups: ColumnTreeGroup[]; rootRows: DataTableRow[]; expanded: Set<string> } {
if (!query.trim()) return { groups, rootRows, expanded: new Set<string>() };
const q = query.toLowerCase();
const expanded = new Set<string>();
const filteredGroups = groups
.map((group) => ({
...group,
rows: group.rows.filter((row) => {
const label = getRowLabel(row, displayColumnIndex).toLowerCase();
return label.includes(q);
}),
}))
.filter((group) => {
if (group.rows.length > 0) {
expanded.add(group.label);
return true;
}
if (group.label.toLowerCase().includes(q)) {
return true;
}
return false;
});
const filteredRootRows = rootRows.filter((row) => {
const label = getRowLabel(row, displayColumnIndex).toLowerCase();
return label.includes(q);
});
return { groups: filteredGroups, rootRows: filteredRootRows, expanded };
}
export function DirectoryColumnTree({
rows,
treeColumnIndex,
displayColumnIndex = 0,
selectedRowId,
onSelect,
onEdit,
onDelete,
onAddChild,
searchQuery,
}: DirectoryColumnTreeProps) {
const { groups, rootRows } = useMemo(
() => groupRows(rows, treeColumnIndex),
[rows, treeColumnIndex]
);
const filtered = useMemo(
() => filterGroups(groups, rootRows, searchQuery || '', displayColumnIndex),
[groups, rootRows, searchQuery, displayColumnIndex]
);
const [manualExpanded, setManualExpanded] = useState<Set<string>>(new Set());
const effectiveExpanded = useMemo(
() => (searchQuery?.trim() ? filtered.expanded : manualExpanded),
[searchQuery, filtered.expanded, manualExpanded]
);
const toggle = (label: string) => {
setManualExpanded((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
};
const renderRow = (row: DataTableRow, level: number) => {
const isSelected = selectedRowId === row.id;
return (
<div
key={row.id}
className={cn(
'group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm cursor-pointer select-none transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'
)}
style={{ paddingLeft: `${level * 16 + 28}px` }}
onClick={() => onSelect(row)}
>
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="flex-1 break-words whitespace-normal leading-tight">{getRowLabel(row, displayColumnIndex)}</span>
<div
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Редактировать"
onClick={(e) => {
e.stopPropagation();
onEdit(row);
}}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
title="Удалить"
onClick={(e) => {
e.stopPropagation();
onDelete(row);
}}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
);
};
return (
<div className="space-y-0">
{filtered.groups.map((group) => {
const isExpanded = effectiveExpanded.has(group.label);
return (
<div key={group.label}>
<div
className="flex items-center gap-1 rounded-md px-2 py-1.5 text-sm cursor-pointer select-none hover:bg-muted font-medium"
style={{ paddingLeft: '8px' }}
onClick={() => toggle(group.label)}
>
{isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
)}
<span className="flex-1 truncate">{group.label}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
title="Добавить в группу"
onClick={(e) => {
e.stopPropagation();
onAddChild(group.label);
}}
>
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
{isExpanded && (
<div>
{group.rows.map((row) => renderRow(row, 1))}
</div>
)}
</div>
);
})}
{filtered.rootRows.length > 0 && (
<div className="pt-1">
{filtered.rootRows.map((row) => renderRow(row, 0))}
</div>
)}
</div>
);
}

View File

@@ -4,68 +4,56 @@ import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import type { DataTableRow } from '@shared/schema';
interface TreeItem extends DataTableRow {
level: number;
children: TreeItem[];
export type DirectoryTreeNode =
| { type: 'group'; id: string; label: string; level: number; children: DirectoryTreeNode[] }
| { type: 'row'; id: number; level: number; row: DataTableRow };
function assignLevels(nodes: DirectoryTreeNode[], level = 0): DirectoryTreeNode[] {
return nodes.map((node) => {
if (node.type === 'row') {
return { ...node, level };
}
return { ...node, level, children: assignLevels(node.children, level + 1) };
});
}
interface DirectoryTreeMultiSelectProps {
rows: DataTableRow[];
nodes: DirectoryTreeNode[];
displayColumnIndex: number;
selected: Set<string>;
onToggle: (rowId: string) => void;
searchTerm?: string;
}
function buildTree(rows: DataTableRow[]): TreeItem[] {
const map = new Map<number, TreeItem>();
const roots: TreeItem[] = [];
const sorted = [...rows].sort((a, b) => {
if ((a.parentId ?? -1) !== (b.parentId ?? -1)) return 0;
return a.position - b.position;
});
for (const row of sorted) {
map.set(row.id, { ...row, level: 0, children: [] });
}
for (const row of sorted) {
const node = map.get(row.id)!;
if (row.parentId === null || row.parentId === undefined) {
roots.push(node);
} else {
const parent = map.get(row.parentId);
if (parent) {
node.level = parent.level + 1;
parent.children.push(node);
} else {
roots.push(node);
}
}
}
return roots.sort((a, b) => a.position - b.position);
}
function filterTree(tree: TreeItem[], query: string, displayColumnIndex: number): { tree: TreeItem[]; expanded: Set<number> } {
if (!query.trim()) return { tree, expanded: new Set<number>() };
function filterNodes(
nodes: DirectoryTreeNode[],
query: string,
displayColumnIndex: number
): { nodes: DirectoryTreeNode[]; expanded: Set<string> } {
if (!query.trim()) return { nodes, expanded: new Set<string>() };
const q = query.toLowerCase();
const expanded = new Set<number>();
function walk(nodes: TreeItem[]): TreeItem[] {
const visible: TreeItem[] = [];
for (const node of nodes) {
const label = String(node.values[displayColumnIndex] ?? node.values[0] ?? '').toLowerCase();
const children = walk(node.children);
const childMatch = children.length > 0;
if (label.includes(q) || childMatch) {
visible.push({ ...node, children });
if (childMatch) expanded.add(node.id);
const expanded = new Set<string>();
function walk(items: DirectoryTreeNode[]): DirectoryTreeNode[] {
const visible: DirectoryTreeNode[] = [];
for (const node of items) {
if (node.type === 'row') {
const label = String(node.row.values[displayColumnIndex] ?? node.row.values[0] ?? '').toLowerCase();
if (label.includes(q)) visible.push(node);
} else {
const children = walk(node.children);
if (children.length > 0) {
visible.push({ ...node, children });
expanded.add(node.id);
} else if (node.label.toLowerCase().includes(q)) {
visible.push({ ...node, children: [] });
}
}
}
return visible;
}
return { tree: walk(tree), expanded };
return { nodes: walk(nodes), expanded };
}
function getRowLabel(row: DataTableRow, displayColumnIndex: number): string {
@@ -81,49 +69,53 @@ function TreeNode({
expanded,
onToggleExpand,
}: {
node: TreeItem;
node: DirectoryTreeNode;
displayColumnIndex: number;
selected: Set<string>;
onToggle: (rowId: string) => void;
expanded: Set<number>;
onToggleExpand: (id: number) => void;
expanded: Set<string>;
onToggleExpand: (id: string) => void;
}) {
const hasChildren = node.children.length > 0;
const rowId = node.id.toString();
if (node.type === 'row') {
const rowId = node.id.toString();
return (
<div
className="flex items-center gap-1.5 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer"
style={{ paddingLeft: `${node.level * 16 + 28}px` }}
onMouseDown={(e) => { e.preventDefault(); onToggle(rowId); }}
>
<Checkbox checked={selected.has(rowId)} />
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className={cn('flex-1 truncate', selected.has(rowId) && 'font-medium')}>
{getRowLabel(node.row, displayColumnIndex)}
</span>
</div>
);
}
const isExpanded = expanded.has(node.id);
return (
<div>
<div
className="flex items-center gap-1.5 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer"
className="flex items-center gap-1.5 px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer font-medium"
style={{ paddingLeft: `${node.level * 16 + 8}px` }}
onMouseDown={(e) => { e.preventDefault(); onToggle(rowId); }}
onMouseDown={(e) => {
e.preventDefault();
onToggleExpand(node.id);
}}
>
{hasChildren ? (
<span
className="shrink-0 p-0.5 rounded hover:bg-muted-foreground/10"
onMouseDown={(e) => {
e.stopPropagation();
onToggleExpand(node.id);
}}
>
{expanded.has(node.id) ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
</span>
{isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
) : (
<span className="w-5" />
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
)}
<Checkbox checked={selected.has(rowId)} />
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className={cn('flex-1 truncate', selected.has(rowId) && 'font-medium')}>{getRowLabel(node, displayColumnIndex)}</span>
<span className="flex-1 truncate">{node.label}</span>
</div>
{hasChildren && expanded.has(node.id) && (
{isExpanded && (
<div>
{node.children.map((child) => (
<TreeNode
key={child.id}
key={child.type === 'group' ? child.id : child.id}
node={child}
displayColumnIndex={displayColumnIndex}
selected={selected}
@@ -138,21 +130,30 @@ function TreeNode({
);
}
export function DirectoryTreeMultiSelect({ rows, displayColumnIndex, selected, onToggle, searchTerm = '' }: DirectoryTreeMultiSelectProps) {
const tree = useMemo(() => buildTree(rows), [rows]);
const filtered = useMemo(() => filterTree(tree, searchTerm, displayColumnIndex), [tree, searchTerm, displayColumnIndex]);
const [manualExpanded, setManualExpanded] = useState<Set<number>>(new Set());
export function DirectoryTreeMultiSelect({
nodes,
displayColumnIndex,
selected,
onToggle,
searchTerm = '',
}: DirectoryTreeMultiSelectProps) {
const nodesWithLevels = useMemo(() => assignLevels(nodes), [nodes]);
const filtered = useMemo(
() => filterNodes(nodesWithLevels, searchTerm, displayColumnIndex),
[nodesWithLevels, searchTerm, displayColumnIndex]
);
const [manualExpanded, setManualExpanded] = useState<Set<string>>(new Set());
const effectiveExpanded = useMemo(
() => (searchTerm.trim() ? filtered.expanded : manualExpanded),
[searchTerm, filtered.expanded, manualExpanded]
);
const displayTree = useMemo(
() => (searchTerm.trim() ? filtered.tree : tree),
[searchTerm, filtered.tree, tree]
() => (searchTerm.trim() ? filtered.nodes : nodes),
[searchTerm, filtered.nodes, nodes]
);
const toggleExpand = (id: number) => {
const toggleExpand = (id: string) => {
setManualExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
@@ -165,7 +166,7 @@ export function DirectoryTreeMultiSelect({ rows, displayColumnIndex, selected, o
<div className="py-1">
{displayTree.map((node) => (
<TreeNode
key={node.id}
key={node.type === 'group' ? node.id : node.id}
node={node}
displayColumnIndex={displayColumnIndex}
selected={selected}

View File

@@ -3,68 +3,56 @@ import { ChevronRight, ChevronDown, FileText } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { DataTableRow } from '@shared/schema';
interface TreeItem extends DataTableRow {
level: number;
children: TreeItem[];
export type DirectoryTreeNode =
| { type: 'group'; id: string; label: string; level: number; children: DirectoryTreeNode[] }
| { type: 'row'; id: number; level: number; row: DataTableRow };
function assignLevels(nodes: DirectoryTreeNode[], level = 0): DirectoryTreeNode[] {
return nodes.map((node) => {
if (node.type === 'row') {
return { ...node, level };
}
return { ...node, level, children: assignLevels(node.children, level + 1) };
});
}
interface DirectoryTreeSelectProps {
rows: DataTableRow[];
nodes: DirectoryTreeNode[];
displayColumnIndex: number;
value?: string | number | null;
onChange: (rowId: string) => void;
searchTerm?: string;
}
function buildTree(rows: DataTableRow[]): TreeItem[] {
const map = new Map<number, TreeItem>();
const roots: TreeItem[] = [];
const sorted = [...rows].sort((a, b) => {
if ((a.parentId ?? -1) !== (b.parentId ?? -1)) return 0;
return a.position - b.position;
});
for (const row of sorted) {
map.set(row.id, { ...row, level: 0, children: [] });
}
for (const row of sorted) {
const node = map.get(row.id)!;
if (row.parentId === null || row.parentId === undefined) {
roots.push(node);
} else {
const parent = map.get(row.parentId);
if (parent) {
node.level = parent.level + 1;
parent.children.push(node);
} else {
roots.push(node);
}
}
}
return roots.sort((a, b) => a.position - b.position);
}
function filterTree(tree: TreeItem[], query: string, displayColumnIndex: number): { tree: TreeItem[]; expanded: Set<number> } {
if (!query.trim()) return { tree, expanded: new Set<number>() };
function filterNodes(
nodes: DirectoryTreeNode[],
query: string,
displayColumnIndex: number
): { nodes: DirectoryTreeNode[]; expanded: Set<string> } {
if (!query.trim()) return { nodes, expanded: new Set<string>() };
const q = query.toLowerCase();
const expanded = new Set<number>();
function walk(nodes: TreeItem[]): TreeItem[] {
const visible: TreeItem[] = [];
for (const node of nodes) {
const label = String(node.values[displayColumnIndex] ?? node.values[0] ?? '').toLowerCase();
const children = walk(node.children);
const childMatch = children.length > 0;
if (label.includes(q) || childMatch) {
visible.push({ ...node, children });
if (childMatch) expanded.add(node.id);
const expanded = new Set<string>();
function walk(items: DirectoryTreeNode[]): DirectoryTreeNode[] {
const visible: DirectoryTreeNode[] = [];
for (const node of items) {
if (node.type === 'row') {
const label = String(node.row.values[displayColumnIndex] ?? node.row.values[0] ?? '').toLowerCase();
if (label.includes(q)) visible.push(node);
} else {
const children = walk(node.children);
if (children.length > 0) {
visible.push({ ...node, children });
expanded.add(node.id);
} else if (node.label.toLowerCase().includes(q)) {
visible.push({ ...node, children: [] });
}
}
}
return visible;
}
return { tree: walk(tree), expanded };
return { nodes: walk(nodes), expanded };
}
function getRowLabel(row: DataTableRow, displayColumnIndex: number): string {
@@ -80,52 +68,53 @@ function TreeNode({
expanded,
onToggle,
}: {
node: TreeItem;
node: DirectoryTreeNode;
displayColumnIndex: number;
value?: string | number | null;
onChange: (rowId: string) => void;
expanded: Set<number>;
onToggle: (id: number) => void;
expanded: Set<string>;
onToggle: (id: string) => void;
}) {
const hasChildren = node.children.length > 0;
const isSelected = value?.toString() === node.id.toString();
return (
<div>
if (node.type === 'row') {
const rowId = node.id.toString();
const isSelected = value?.toString() === rowId;
return (
<button
type="button"
className={cn(
'w-full flex items-center gap-1.5 px-2 py-1.5 text-sm rounded hover:bg-accent text-left',
isSelected && 'bg-accent text-accent-foreground'
)}
style={{ paddingLeft: `${node.level * 16 + 8}px` }}
onClick={() => onChange(node.id.toString())}
style={{ paddingLeft: `${node.level * 16 + 28}px` }}
onClick={() => onChange(rowId)}
>
{hasChildren ? (
<span
className="shrink-0 p-0.5 rounded hover:bg-muted-foreground/10"
onClick={(e) => {
e.stopPropagation();
onToggle(node.id);
}}
>
{expanded.has(node.id) ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
</span>
) : (
<span className="w-5" />
)}
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{getRowLabel(node, displayColumnIndex)}</span>
<span className="flex-1 truncate">{getRowLabel(node.row, displayColumnIndex)}</span>
</button>
{hasChildren && expanded.has(node.id) && (
);
}
const isExpanded = expanded.has(node.id);
return (
<div>
<button
type="button"
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-sm rounded hover:bg-accent text-left font-medium"
style={{ paddingLeft: `${node.level * 16 + 8}px` }}
onClick={() => onToggle(node.id)}
>
{isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
)}
<span className="flex-1 truncate">{node.label}</span>
</button>
{isExpanded && (
<div>
{node.children.map((child) => (
<TreeNode
key={child.id}
key={child.type === 'group' ? child.id : child.id}
node={child}
displayColumnIndex={displayColumnIndex}
value={value}
@@ -140,21 +129,30 @@ function TreeNode({
);
}
export function DirectoryTreeSelect({ rows, displayColumnIndex, value, onChange, searchTerm = '' }: DirectoryTreeSelectProps) {
const tree = useMemo(() => buildTree(rows), [rows]);
const filtered = useMemo(() => filterTree(tree, searchTerm, displayColumnIndex), [tree, searchTerm, displayColumnIndex]);
const [manualExpanded, setManualExpanded] = useState<Set<number>>(new Set());
export function DirectoryTreeSelect({
nodes,
displayColumnIndex,
value,
onChange,
searchTerm = '',
}: DirectoryTreeSelectProps) {
const nodesWithLevels = useMemo(() => assignLevels(nodes), [nodes]);
const filtered = useMemo(
() => filterNodes(nodesWithLevels, searchTerm, displayColumnIndex),
[nodesWithLevels, searchTerm, displayColumnIndex]
);
const [manualExpanded, setManualExpanded] = useState<Set<string>>(new Set());
const effectiveExpanded = useMemo(
() => (searchTerm.trim() ? filtered.expanded : manualExpanded),
[searchTerm, filtered.expanded, manualExpanded]
);
const displayTree = useMemo(
() => (searchTerm.trim() ? filtered.tree : tree),
[searchTerm, filtered.tree, tree]
() => (searchTerm.trim() ? filtered.nodes : nodes),
[searchTerm, filtered.nodes, nodes]
);
const toggle = (id: number) => {
const toggle = (id: string) => {
setManualExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
@@ -176,7 +174,7 @@ export function DirectoryTreeSelect({ rows, displayColumnIndex, value, onChange,
</button>
{displayTree.map((node) => (
<TreeNode
key={node.id}
key={node.type === 'group' ? node.id : node.id}
node={node}
displayColumnIndex={displayColumnIndex}
value={value}