Древовидные справочники: выбор колонки для дерева (treeColumnIndex) и API /rows/tree
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
222
client/src/components/directories/DirectoryColumnTree.tsx
Normal file
222
client/src/components/directories/DirectoryColumnTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,36 +697,48 @@ 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">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Поиск в дереве..."
|
||||
className="pl-7 h-8 text-xs"
|
||||
value={treeSearchQuery}
|
||||
onChange={(e) => setTreeSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button onClick={() => handleTreeAddChild(null)} className="h-8 px-2.5 text-xs" data-testid="button-add-tree-row">
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
Добавить
|
||||
</Button>
|
||||
)}
|
||||
<div className="p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Поиск в дереве..."
|
||||
className="pl-7 h-8 text-xs"
|
||||
value={treeSearchQuery}
|
||||
onChange={(e) => setTreeSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{displayRows.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
{treeSearchQuery ? 'Ничего не найдено' : 'Таблица пуста'}
|
||||
</div>
|
||||
) : (
|
||||
{canEdit && (
|
||||
<Button onClick={() => handleTreeAddChild(null)} className="h-8 px-2.5 text-xs" data-testid="button-add-tree-row">
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
Добавить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{displayRows.length === 0 ? (
|
||||
<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,17 +751,17 @@ 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">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>{activeRow.values[0] || '—'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<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">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>{activeRow.values[0] || '—'}</span>
|
||||
</div>
|
||||
) : 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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user