Древовидные справочники: выбор колонки для дерева (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 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);
const childMatch = children.length > 0;
if (label.includes(q) || childMatch) {
if (children.length > 0) {
visible.push({ ...node, children });
if (childMatch) expanded.add(node.id);
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;
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); }}
>
{hasChildren ? (
<span
className="shrink-0 p-0.5 rounded hover:bg-muted-foreground/10"
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
onToggleExpand(node.id);
}}
>
{expanded.has(node.id) ? (
<ChevronDown className="w-3.5 h-3.5" />
{isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
)}
</span>
) : (
<span className="w-5" />
)}
<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 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);
const childMatch = children.length > 0;
if (label.includes(q) || childMatch) {
if (children.length > 0) {
visible.push({ ...node, children });
if (childMatch) expanded.add(node.id);
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();
if (node.type === 'row') {
const rowId = node.id.toString();
const isSelected = value?.toString() === rowId;
return (
<div>
<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}

View File

@@ -61,6 +61,7 @@ import {
rowDroppableId,
rowGapId,
} from '@/components/directories/DirectoryRowTree';
import { DirectoryColumnTree } from '@/components/directories/DirectoryColumnTree';
interface TableColumn {
name: string;
@@ -85,6 +86,7 @@ export default function TableEditor() {
const [tableName, setTableName] = useState('');
const [tableDescription, setTableDescription] = useState('');
const [tableColumns, setTableColumns] = useState<TableColumn[]>([]);
const [treeColumnIndex, setTreeColumnIndex] = useState<number | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [importPreview, setImportPreview] = useState<{
added: { values: string[] }[];
@@ -103,6 +105,7 @@ export default function TableEditor() {
const [rowEditorOpen, setRowEditorOpen] = useState(false);
const [rowEditorInitialValues, setRowEditorInitialValues] = useState<string[]>([]);
const [rowEditorParentId, setRowEditorParentId] = useState<number | null>(null);
const [rowEditorGroupValue, setRowEditorGroupValue] = useState<string | null>(null);
const [editorValues, setEditorValues] = useState<string[]>([]);
const [treeSearchQuery, setTreeSearchQuery] = useState('');
@@ -171,6 +174,7 @@ export default function TableEditor() {
setTableName(data.table.name);
setTableDescription(data.table.description || '');
setTableColumns(data.table.columns as TableColumn[]);
setTreeColumnIndex(data.table.treeColumnIndex ?? null);
setNewRowValues(new Array(data.table.columns.length).fill(''));
}
}, [data]);
@@ -182,7 +186,7 @@ export default function TableEditor() {
}, [rowEditorOpen, rowEditorInitialValues]);
const updateTableMutation = useMutation({
mutationFn: async (updates: { name?: string; description?: string; columns?: TableColumn[] }) => {
mutationFn: async (updates: { name?: string; description?: string; columns?: TableColumn[]; treeColumnIndex?: number | null }) => {
return apiRequest('PATCH', `/api/directories/${tableId}`, updates);
},
onSuccess: () => {
@@ -357,8 +361,9 @@ export default function TableEditor() {
});
};
const openRowEditor = (parentId: number | null = null, initialValues?: string[], rowId?: number) => {
const openRowEditor = (parentId: number | null = null, initialValues?: string[], rowId?: number, groupValue: string | null = null) => {
setRowEditorParentId(parentId);
setRowEditorGroupValue(groupValue);
setRowEditorInitialValues(initialValues ?? new Array(tableColumns.length).fill(''));
if (rowId) {
setEditingRowId(rowId);
@@ -372,14 +377,19 @@ export default function TableEditor() {
setRowEditorOpen(false);
setRowEditorInitialValues([]);
setRowEditorParentId(null);
setRowEditorGroupValue(null);
setEditingRowId(null);
};
const handleSaveRowEditor = (values: string[]) => {
const finalValues = [...values];
if (treeColumnIndex !== null && rowEditorGroupValue !== null && editingRowId === null) {
finalValues[treeColumnIndex] = rowEditorGroupValue;
}
if (editingRowId !== null) {
updateRowMutation.mutate({ rowId: editingRowId, values });
updateRowMutation.mutate({ rowId: editingRowId, values: finalValues });
} else {
createRowMutation.mutate({ values, parentId: rowEditorParentId });
createRowMutation.mutate({ values: finalValues, parentId: rowEditorParentId });
}
};
@@ -393,6 +403,11 @@ export default function TableEditor() {
openRowEditor(parentId, new Array(tableColumns.length).fill(''));
};
const handleColumnAddChild = (groupValue: string | null) => {
setSelectedRowId(null);
openRowEditor(null, new Array(tableColumns.length).fill(''), undefined, groupValue);
};
const handleTreeDelete = (row: DataTableRow) => {
if (window.confirm(`Удалить строку "${row.values[0] || row.id}"?`)) {
deleteRowMutation.mutate(row.id);
@@ -682,13 +697,6 @@ export default function TableEditor() {
<Card>
<CardContent className="p-0">
{viewMode === 'tree' ? (
<DndContext
sensors={treeSensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="p-2 space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
@@ -711,7 +719,26 @@ export default function TableEditor() {
<div className="text-center py-8 text-muted-foreground text-sm">
{treeSearchQuery ? 'Ничего не найдено' : 'Таблица пуста'}
</div>
) : treeColumnIndex != null ? (
<DirectoryColumnTree
rows={displayRows}
treeColumnIndex={treeColumnIndex}
displayColumnIndex={0}
selectedRowId={selectedRowId}
onSelect={handleTreeSelect}
onEdit={handleTreeSelect}
onDelete={handleTreeDelete}
onAddChild={handleColumnAddChild}
searchQuery={treeSearchQuery}
/>
) : (
<DndContext
sensors={treeSensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<DirectoryRowTree
rows={displayRows}
selectedRowId={selectedRowId}
@@ -724,8 +751,6 @@ export default function TableEditor() {
overRowId={overRowId}
searchQuery={treeSearchQuery}
/>
)}
</div>
<DragOverlay dropAnimation={dropAnimation}>
{activeRow ? (
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm bg-accent text-accent-foreground shadow-sm border">
@@ -735,6 +760,8 @@ export default function TableEditor() {
) : null}
</DragOverlay>
</DndContext>
)}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
@@ -1146,13 +1173,35 @@ export default function TableEditor() {
</Button>
</div>
</div>
<div>
<Label htmlFor="edit-table-tree-column">Колонка для дерева</Label>
<Select
value={treeColumnIndex === null ? 'none' : String(treeColumnIndex)}
onValueChange={(val) => setTreeColumnIndex(val === 'none' ? null : Number(val))}
>
<SelectTrigger id="edit-table-tree-column" className="mt-2">
<SelectValue placeholder="Не используется" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не используется</SelectItem>
{tableColumns.map((col, idx) => (
<SelectItem key={idx} value={String(idx)}>
{col.name || `Колонка ${idx + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
Строки будут сгруппированы по уникальным значениям выбранной колонки.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSettingsOpen(false)}>
Отмена
</Button>
<Button
onClick={() => updateTableMutation.mutate({ name: tableName, description: tableDescription, columns: tableColumns })}
onClick={() => updateTableMutation.mutate({ name: tableName, description: tableDescription, columns: tableColumns, treeColumnIndex })}
disabled={updateTableMutation.isPending}
data-testid="button-save-settings"
>

View File

@@ -0,0 +1,2 @@
-- Add tree column index setting to data tables
ALTER TABLE data_tables ADD COLUMN IF NOT EXISTS tree_column_index INTEGER;

View File

@@ -323,11 +323,12 @@ export function registerDataTableRoutes(app: import("express").Express): void {
return res.status(403).json({ error: 'Нет прав для редактирования таблицы' });
}
const { name, description, columns } = req.body;
const { name, description, columns, treeColumnIndex } = req.body;
const updates: Record<string, unknown> = {};
if (name !== undefined) updates.name = name;
if (description !== undefined) updates.description = description;
if (columns !== undefined) updates.columns = columns;
if (treeColumnIndex !== undefined) updates.treeColumnIndex = treeColumnIndex === null ? null : Number(treeColumnIndex);
const updatedTable = await storage.updateDataTable(tableId, req.organizationId!, updates as Parameters<typeof storage.updateDataTable>[2]);
res.json({ table: updatedTable });
@@ -482,6 +483,110 @@ export function registerDataTableRoutes(app: import("express").Express): void {
}
);
// Get table rows as a tree (grouped by treeColumnIndex or parentId hierarchy)
app.get(['/api/tables/:tableId/rows/tree', '/api/directories/:tableId/rows/tree'],
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const tableId = parseInt(req.params.tableId);
const table = await storage.getDataTable(tableId, req.organizationId!);
if (!table) {
return res.status(404).json({ error: 'Таблица не найдена' });
}
const role = await storage.getUserTableRole(tableId, req.user!.id, table.createdBy, req.organizationId!);
if (!role) {
return res.status(403).json({ error: 'Нет доступа' });
}
let rows = await storage.getDataTableRows(tableId, req.organizationId!);
const treeColumnIndex = table.treeColumnIndex;
const search = String(req.query.search || '').toLowerCase();
type TreeNode =
| { type: 'group'; id: string; label: string; children: TreeNode[] }
| { type: 'row'; id: number; row: typeof rows[number] };
const buildParentTree = (parentRows: typeof rows, parentId: number | null): TreeNode[] => {
const items = parentRows
.filter((r) => (r.parentId ?? null) === parentId)
.sort((a, b) => a.position - b.position);
return items.map((row) => {
const children = buildParentTree(parentRows, row.id);
if (children.length > 0) {
return { type: 'group' as const, id: `row-group:${row.id}`, label: String(row.values[0] ?? ''), children: [...children, { type: 'row' as const, id: row.id, row }] };
}
return { type: 'row' as const, id: row.id, row };
});
};
const buildColumnTree = (allRows: typeof rows, columnIndex: number): TreeNode[] => {
const groups = new Map<string, typeof rows[number][]>();
const rootRows: typeof rows[number][] = [];
for (const row of allRows) {
const vals = Array.isArray(row.values) ? row.values : [];
const key = String(vals[columnIndex] ?? '').trim();
if (!key) {
rootRows.push(row);
continue;
}
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(row);
}
const nodes: TreeNode[] = [];
for (const [label, groupRows] of groups) {
const sorted = [...groupRows].sort((a, b) => a.position - b.position);
nodes.push({
type: 'group',
id: `group:${label}`,
label,
children: sorted.map((row) => ({ type: 'row', id: row.id, row })),
});
}
if (rootRows.length > 0) {
rootRows.sort((a, b) => a.position - b.position);
nodes.push(...rootRows.map((row) => ({ type: 'row' as const, id: row.id, row })));
}
return nodes.sort((a, b) => {
const al = a.type === 'group' ? a.label : String(a.row.values[columnIndex] ?? '');
const bl = b.type === 'group' ? b.label : String(b.row.values[columnIndex] ?? '');
return al.localeCompare(bl, 'ru');
});
};
const filterNodes = (nodes: TreeNode[], query: string): TreeNode[] => {
if (!query) return nodes;
const result: TreeNode[] = [];
for (const node of nodes) {
if (node.type === 'row') {
const label = String(node.row.values[0] ?? '').toLowerCase();
if (label.includes(query)) result.push(node);
} else {
const children = filterNodes(node.children, query);
if (children.length > 0) {
result.push({ ...node, children });
} else if (node.label.toLowerCase().includes(query)) {
result.push({ ...node, children: [] });
}
}
}
return result;
};
const rawNodes = treeColumnIndex != null
? buildColumnTree(rows, treeColumnIndex)
: buildParentTree(rows, null);
const nodes = search ? filterNodes(rawNodes, search) : rawNodes;
res.json({ columnIndex: treeColumnIndex, nodes });
} catch (error) {
console.error('Get table rows tree error:', error);
res.status(500).json({ error: 'Ошибка получения дерева строк' });
}
}
);
// Create table row
app.post(['/api/tables/:tableId/rows', '/api/directories/:tableId/rows'],
authenticateToken,

View File

@@ -1842,6 +1842,7 @@ export const dataTables = pgTable("data_tables", {
isDeleted: boolean("is_deleted").default(false),
visibility: varchar("visibility", { length: 20 }).default("restricted"),
folderId: integer("folder_id"),
treeColumnIndex: integer("tree_column_index"),
}, (table) => ({
folderIdx: index("data_tables_folder_idx").on(table.folderId),
}));