feat: настраиваемая колонка отображения в дереве справочника

- Добавлено поле data_tables.tree_display_column_index.
- Backend /api/directories/:id/rows/tree возвращает displayColumnIndex и
  формирует label для row-узлов на основе выбранной колонки.
- В редакторе справочника добавлен селектор «Колонка для отображения в дереве».
- DirectoryRowTree, DirectoryColumnTree, DirectoryTreeSelect,
  DirectoryTreeMultiSelect используют настроенную колонку.
- TableFieldSelect / TableFieldMultiSelect используют
  treeDisplayColumnIndex справочника как fallback, если в поле формы
  не задан displayColumn.
- Миграция 0057 + запись в _journal.json.
This commit is contained in:
2026-07-12 23:07:34 +03:00
parent 4e2a622265
commit 09861fc966
10 changed files with 76 additions and 57 deletions

View File

@@ -35,6 +35,7 @@ export default function TableFieldMultiSelect({
rows: DataTableRow[];
columns: Array<{ name: string }>;
treeColumnIndex: number | null;
treeDisplayColumnIndex: number | null;
};
}>({
queryKey: ['/api/directories', tableId],
@@ -69,7 +70,7 @@ export default function TableFieldMultiSelect({
let displayColumnIndex = table?.columns?.findIndex((c) => c.name === displayColumn);
if (displayColumnIndex === undefined || displayColumnIndex < 0) {
displayColumnIndex = 0;
displayColumnIndex = table?.treeDisplayColumnIndex ?? 0;
}
const selectedValues = useMemo(() => {
@@ -166,7 +167,6 @@ export default function TableFieldMultiSelect({
{hasTree ? (
<DirectoryTreeMultiSelect
nodes={nodes}
displayColumnIndex={displayColumnIndex!}
selected={selectedSet}
onToggle={toggle}
searchTerm={searchTerm}

View File

@@ -33,6 +33,7 @@ export default function TableFieldSelect({
rows: DataTableRow[];
columns: Array<{ name: string }>;
treeColumnIndex: number | null;
treeDisplayColumnIndex: number | null;
};
}>({
queryKey: ['/api/directories', tableId],
@@ -67,7 +68,7 @@ export default function TableFieldSelect({
let displayColumnIndex = table?.columns?.findIndex((c) => c.name === displayColumn);
if (displayColumnIndex === undefined || displayColumnIndex < 0) {
displayColumnIndex = 0;
displayColumnIndex = table?.treeDisplayColumnIndex ?? 0;
}
const sortedRows = useMemo(() => {
@@ -151,7 +152,6 @@ export default function TableFieldSelect({
{hasTree ? (
<DirectoryTreeSelect
nodes={nodes}
displayColumnIndex={displayColumnIndex!}
value={value}
onChange={handleSelect}
searchTerm={searchTerm}

View File

@@ -107,7 +107,8 @@ function getVisibleItems(tree: DirectoryRowTreeItem[], expanded: Set<number>): D
function filterTree(
tree: DirectoryRowTreeItem[],
query: string
query: string,
labelColumnIndex = 0
): { tree: DirectoryRowTreeItem[]; expanded: Set<number> } {
if (!query.trim()) return { tree, expanded: new Set<number>() };
const q = query.toLowerCase();
@@ -115,7 +116,7 @@ function filterTree(
function walk(nodes: DirectoryRowTreeItem[]): DirectoryRowTreeItem[] {
const visible: DirectoryRowTreeItem[] = [];
for (const node of nodes) {
const label = String(node.values[0] ?? '').toLowerCase();
const label = getRowLabel(node, labelColumnIndex).toLowerCase();
const children = walk(node.children);
const childMatch = children.length > 0;
if (label.includes(q) || childMatch) {
@@ -298,7 +299,7 @@ export function DirectoryRowTree({
searchQuery,
}: DirectoryRowTreeProps & { searchQuery?: string }) {
const tree = useMemo(() => buildTree(rows), [rows]);
const filtered = useMemo(() => filterTree(tree, searchQuery || ''), [tree, searchQuery]);
const filtered = useMemo(() => filterTree(tree, searchQuery || '', labelColumnIndex), [tree, searchQuery, labelColumnIndex]);
const effectiveExpanded = useMemo(
() => (searchQuery?.trim() ? filtered.expanded : expanded),
[searchQuery, filtered.expanded, expanded]

View File

@@ -6,7 +6,7 @@ import type { DataTableRow } from '@shared/schema';
export type DirectoryTreeNode =
| { type: 'group'; id: string; label: string; level: number; children: DirectoryTreeNode[] }
| { type: 'row'; id: number; level: number; row: DataTableRow };
| { type: 'row'; id: number; label: string; level: number; row: DataTableRow };
function assignLevels(nodes: DirectoryTreeNode[], level = 0): DirectoryTreeNode[] {
return nodes.map((node) => {
@@ -19,7 +19,6 @@ function assignLevels(nodes: DirectoryTreeNode[], level = 0): DirectoryTreeNode[
interface DirectoryTreeMultiSelectProps {
nodes: DirectoryTreeNode[];
displayColumnIndex: number;
selected: Set<string>;
onToggle: (rowId: string) => void;
searchTerm?: string;
@@ -27,8 +26,7 @@ interface DirectoryTreeMultiSelectProps {
function filterNodes(
nodes: DirectoryTreeNode[],
query: string,
displayColumnIndex: number
query: string
): { nodes: DirectoryTreeNode[]; expanded: Set<string> } {
if (!query.trim()) return { nodes, expanded: new Set<string>() };
const q = query.toLowerCase();
@@ -38,7 +36,7 @@ function filterNodes(
const visible: DirectoryTreeNode[] = [];
for (const node of items) {
if (node.type === 'row') {
const label = String(node.row.values[displayColumnIndex] ?? node.row.values[0] ?? '').toLowerCase();
const label = node.label.toLowerCase();
if (label.includes(q)) visible.push(node);
} else {
const children = walk(node.children);
@@ -56,21 +54,14 @@ function filterNodes(
return { nodes: walk(nodes), expanded };
}
function getRowLabel(row: DataTableRow, displayColumnIndex: number): string {
const values = Array.isArray(row.values) ? row.values : [];
return String(values[displayColumnIndex] ?? values[0] ?? '') || '(без названия)';
}
function TreeNode({
node,
displayColumnIndex,
selected,
onToggle,
expanded,
onToggleExpand,
}: {
node: DirectoryTreeNode;
displayColumnIndex: number;
selected: Set<string>;
onToggle: (rowId: string) => void;
expanded: Set<string>;
@@ -87,7 +78,7 @@ function TreeNode({
<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)}
{node.label}
</span>
</div>
);
@@ -117,7 +108,6 @@ function TreeNode({
<TreeNode
key={child.type === 'group' ? child.id : child.id}
node={child}
displayColumnIndex={displayColumnIndex}
selected={selected}
onToggle={onToggle}
expanded={expanded}
@@ -132,15 +122,14 @@ function TreeNode({
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]
() => filterNodes(nodesWithLevels, searchTerm),
[nodesWithLevels, searchTerm]
);
const [manualExpanded, setManualExpanded] = useState<Set<string>>(new Set());
@@ -168,7 +157,6 @@ export function DirectoryTreeMultiSelect({
<TreeNode
key={node.type === 'group' ? node.id : node.id}
node={node}
displayColumnIndex={displayColumnIndex}
selected={selected}
onToggle={onToggle}
expanded={effectiveExpanded}

View File

@@ -5,7 +5,7 @@ import type { DataTableRow } from '@shared/schema';
export type DirectoryTreeNode =
| { type: 'group'; id: string; label: string; level: number; children: DirectoryTreeNode[] }
| { type: 'row'; id: number; level: number; row: DataTableRow };
| { type: 'row'; id: number; label: string; level: number; row: DataTableRow };
function assignLevels(nodes: DirectoryTreeNode[], level = 0): DirectoryTreeNode[] {
return nodes.map((node) => {
@@ -18,7 +18,6 @@ function assignLevels(nodes: DirectoryTreeNode[], level = 0): DirectoryTreeNode[
interface DirectoryTreeSelectProps {
nodes: DirectoryTreeNode[];
displayColumnIndex: number;
value?: string | number | null;
onChange: (rowId: string) => void;
searchTerm?: string;
@@ -26,8 +25,7 @@ interface DirectoryTreeSelectProps {
function filterNodes(
nodes: DirectoryTreeNode[],
query: string,
displayColumnIndex: number
query: string
): { nodes: DirectoryTreeNode[]; expanded: Set<string> } {
if (!query.trim()) return { nodes, expanded: new Set<string>() };
const q = query.toLowerCase();
@@ -37,7 +35,7 @@ function filterNodes(
const visible: DirectoryTreeNode[] = [];
for (const node of items) {
if (node.type === 'row') {
const label = String(node.row.values[displayColumnIndex] ?? node.row.values[0] ?? '').toLowerCase();
const label = node.label.toLowerCase();
if (label.includes(q)) visible.push(node);
} else {
const children = walk(node.children);
@@ -55,21 +53,14 @@ function filterNodes(
return { nodes: walk(nodes), expanded };
}
function getRowLabel(row: DataTableRow, displayColumnIndex: number): string {
const values = Array.isArray(row.values) ? row.values : [];
return String(values[displayColumnIndex] ?? values[0] ?? '') || '(без названия)';
}
function TreeNode({
node,
displayColumnIndex,
value,
onChange,
expanded,
onToggle,
}: {
node: DirectoryTreeNode;
displayColumnIndex: number;
value?: string | number | null;
onChange: (rowId: string) => void;
expanded: Set<string>;
@@ -89,7 +80,7 @@ function TreeNode({
onClick={() => onChange(rowId)}
>
<FileText className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{getRowLabel(node.row, displayColumnIndex)}</span>
<span className="flex-1 truncate">{node.label}</span>
</button>
);
}
@@ -116,7 +107,6 @@ function TreeNode({
<TreeNode
key={child.type === 'group' ? child.id : child.id}
node={child}
displayColumnIndex={displayColumnIndex}
value={value}
onChange={onChange}
expanded={expanded}
@@ -131,15 +121,14 @@ function TreeNode({
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]
() => filterNodes(nodesWithLevels, searchTerm),
[nodesWithLevels, searchTerm]
);
const [manualExpanded, setManualExpanded] = useState<Set<string>>(new Set());
@@ -176,7 +165,6 @@ export function DirectoryTreeSelect({
<TreeNode
key={node.type === 'group' ? node.id : node.id}
node={node}
displayColumnIndex={displayColumnIndex}
value={value}
onChange={onChange}
expanded={effectiveExpanded}

View File

@@ -87,6 +87,7 @@ export default function TableEditor() {
const [tableDescription, setTableDescription] = useState('');
const [tableColumns, setTableColumns] = useState<TableColumn[]>([]);
const [treeColumnIndex, setTreeColumnIndex] = useState<number | null>(null);
const [treeDisplayColumnIndex, setTreeDisplayColumnIndex] = useState<number | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [importPreview, setImportPreview] = useState<{
added: { values: string[] }[];
@@ -175,6 +176,7 @@ export default function TableEditor() {
setTableDescription(data.table.description || '');
setTableColumns(data.table.columns as TableColumn[]);
setTreeColumnIndex(data.table.treeColumnIndex ?? null);
setTreeDisplayColumnIndex(data.table.treeDisplayColumnIndex ?? null);
setNewRowValues(new Array(data.table.columns.length).fill(''));
}
}, [data]);
@@ -186,7 +188,7 @@ export default function TableEditor() {
}, [rowEditorOpen, rowEditorInitialValues]);
const updateTableMutation = useMutation({
mutationFn: async (updates: { name?: string; description?: string; columns?: TableColumn[]; treeColumnIndex?: number | null }) => {
mutationFn: async (updates: { name?: string; description?: string; columns?: TableColumn[]; treeColumnIndex?: number | null; treeDisplayColumnIndex?: number | null }) => {
return apiRequest('PATCH', `/api/directories/${tableId}`, updates);
},
onSuccess: () => {
@@ -723,7 +725,7 @@ export default function TableEditor() {
<DirectoryColumnTree
rows={displayRows}
treeColumnIndex={treeColumnIndex}
displayColumnIndex={0}
displayColumnIndex={treeDisplayColumnIndex ?? 0}
selectedRowId={selectedRowId}
onSelect={handleTreeSelect}
onEdit={handleTreeSelect}
@@ -741,6 +743,7 @@ export default function TableEditor() {
>
<DirectoryRowTree
rows={displayRows}
labelColumnIndex={treeDisplayColumnIndex ?? 0}
selectedRowId={selectedRowId}
expanded={expanded}
onSelect={handleTreeSelect}
@@ -755,7 +758,7 @@ export default function TableEditor() {
{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>
<span>{activeRow.values[treeDisplayColumnIndex ?? 0] || activeRow.values[0] || '—'}</span>
</div>
) : null}
</DragOverlay>
@@ -1195,13 +1198,34 @@ export default function TableEditor() {
Строки будут сгруппированы по уникальным значениям выбранной колонки.
</p>
</div>
<div>
<Label htmlFor="edit-table-tree-display-column">Колонка для отображения в дереве</Label>
<Select
value={treeDisplayColumnIndex === null ? '0' : String(treeDisplayColumnIndex)}
onValueChange={(val) => setTreeDisplayColumnIndex(Number(val))}
>
<SelectTrigger id="edit-table-tree-display-column" className="mt-2">
<SelectValue placeholder="Первая колонка" />
</SelectTrigger>
<SelectContent>
{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, treeColumnIndex })}
onClick={() => updateTableMutation.mutate({ name: tableName, description: tableDescription, columns: tableColumns, treeColumnIndex, treeDisplayColumnIndex })}
disabled={updateTableMutation.isPending}
data-testid="button-save-settings"
>

View File

@@ -0,0 +1 @@
ALTER TABLE data_tables ADD COLUMN tree_display_column_index INTEGER;

View File

@@ -421,6 +421,13 @@
"when": 1783883784747,
"tag": "0056_add_tree_column_index_to_data_tables",
"breakpoints": true
},
{
"idx": 60,
"version": "7",
"when": 1752330000000,
"tag": "0057_add_tree_display_column_index",
"breakpoints": true
}
]
}

View File

@@ -323,12 +323,13 @@ export function registerDataTableRoutes(app: import("express").Express): void {
return res.status(403).json({ error: 'Нет прав для редактирования таблицы' });
}
const { name, description, columns, treeColumnIndex } = req.body;
const { name, description, columns, treeColumnIndex, treeDisplayColumnIndex } = 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);
if (treeDisplayColumnIndex !== undefined) updates.treeDisplayColumnIndex = treeDisplayColumnIndex === null ? null : Number(treeDisplayColumnIndex);
const updatedTable = await storage.updateDataTable(tableId, req.organizationId!, updates as Parameters<typeof storage.updateDataTable>[2]);
res.json({ table: updatedTable });
@@ -504,9 +505,16 @@ export function registerDataTableRoutes(app: import("express").Express): void {
const treeColumnIndex = table.treeColumnIndex;
const search = String(req.query.search || '').toLowerCase();
const displayColumnIndex = table.treeDisplayColumnIndex ?? 0;
const getRowLabel = (row: typeof rows[number]): string => {
const values = Array.isArray(row.values) ? row.values : [];
return String(values[displayColumnIndex] ?? values[0] ?? '') || '(без названия)';
};
type TreeNode =
| { type: 'group'; id: string; label: string; children: TreeNode[] }
| { type: 'row'; id: number; row: typeof rows[number] };
| { type: 'row'; id: number; label: string; row: typeof rows[number] };
const buildParentTree = (parentRows: typeof rows, parentId: number | null): TreeNode[] => {
const items = parentRows
@@ -514,10 +522,11 @@ export function registerDataTableRoutes(app: import("express").Express): void {
.sort((a, b) => a.position - b.position);
return items.map((row) => {
const children = buildParentTree(parentRows, row.id);
const label = getRowLabel(row);
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: 'group' as const, id: `row-group:${row.id}`, label, children: [...children, { type: 'row' as const, id: row.id, label, row }] };
}
return { type: 'row' as const, id: row.id, row };
return { type: 'row' as const, id: row.id, label, row };
});
};
@@ -541,16 +550,16 @@ export function registerDataTableRoutes(app: import("express").Express): void {
type: 'group',
id: `group:${label}`,
label,
children: sorted.map((row) => ({ type: 'row', id: row.id, row })),
children: sorted.map((row) => ({ type: 'row', id: row.id, label: getRowLabel(row), 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 })));
nodes.push(...rootRows.map((row) => ({ type: 'row' as const, id: row.id, label: getRowLabel(row), 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] ?? '');
const al = a.type === 'group' ? a.label : getRowLabel(a.row);
const bl = b.type === 'group' ? b.label : getRowLabel(b.row);
return al.localeCompare(bl, 'ru');
});
};
@@ -560,7 +569,7 @@ export function registerDataTableRoutes(app: import("express").Express): void {
const result: TreeNode[] = [];
for (const node of nodes) {
if (node.type === 'row') {
const label = String(node.row.values[0] ?? '').toLowerCase();
const label = node.label.toLowerCase();
if (label.includes(query)) result.push(node);
} else {
const children = filterNodes(node.children, query);
@@ -579,7 +588,7 @@ export function registerDataTableRoutes(app: import("express").Express): void {
: buildParentTree(rows, null);
const nodes = search ? filterNodes(rawNodes, search) : rawNodes;
res.json({ columnIndex: treeColumnIndex, nodes });
res.json({ columnIndex: treeColumnIndex, displayColumnIndex, nodes });
} catch (error) {
console.error('Get table rows tree error:', error);
res.status(500).json({ error: 'Ошибка получения дерева строк' });

View File

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