Files
iistwin/server/utils/data-table-tree.ts
Ильяс Султанов 002ce152be MCP: инструменты справочников (data tables) — этап 1
- 15 инструментов: list/get_directory, list_directory_rows (flat/tree), get_directory_row, get_directory_column_values (read); create/bulk_create_directory_row (write); create/update/delete_directory, update/delete/move_directory_row, link/unlink_directories (full)
- Объектные проверки isTableAllowed по scopes.tableIds
- Логика дерева строк вынесена в server/utils/data-table-tree.ts (переиспользуется REST-эндпоинтом и MCP)
- bulkCreateDataTableRows: поддержка parentId и position
2026-07-21 17:02:04 +03:00

182 lines
6.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { DataTable, DataTableRow } from "@shared/schema";
// Узел дерева строк справочника:
// group — виртуальная группа (по значению колонки или строка с детьми),
// row — конкретная строка справочника.
export type DataTableTreeNode =
| { type: 'group'; id: string; label: string; level: number; children: DataTableTreeNode[] }
| { type: 'row'; id: number; label: string; level: number; row: DataTableRow };
export interface DataTableTreeResult {
columnIndex: number | null;
treeColumnIndex: number | null;
treeColumns: number[];
treeDisplayColumns: number[];
displayColumnIndex: number;
nodes: DataTableTreeNode[];
}
export interface BuildDataTableTreeOptions {
// Фильтры: индекс колонки (строкой) → значение; сравнение точное, без учёта регистра.
filters?: Record<string, string>;
// Подстрока для фильтрации узлов дерева по label (без учёта регистра).
search?: string;
}
// Строит дерево строк справочника.
// Два режима (как в GET /api/tables/:tableId/rows/tree):
// - treeColumns заданы → группировка по значениям этих колонок (мультиуровневая);
// - иначе → иерархия по parentId.
// Логика вынесена из server/routes/data-tables.routes.ts — поведение не менять.
export function buildDataTableTree(
table: DataTable,
rows: DataTableRow[],
options?: BuildDataTableTreeOptions
): DataTableTreeResult {
let filteredRows = rows;
const filters = options?.filters;
if (filters) {
Object.entries(filters).forEach(([col, val]) => {
const ci = parseInt(col);
if (!isNaN(ci) && val) {
filteredRows = filteredRows.filter((r) => {
const v = Array.isArray(r.values) ? r.values : [];
return String(v[ci] || '').toLowerCase() === String(val).toLowerCase();
});
}
});
}
const search = (options?.search ?? '').toLowerCase();
const treeColumnIndex = table.treeColumnIndex;
const treeDisplayColumnIndex = table.treeDisplayColumnIndex;
const treeColumns = table.treeColumns ?? (treeColumnIndex != null ? [treeColumnIndex] : []);
const treeDisplayColumns = table.treeDisplayColumns ?? (treeDisplayColumnIndex != null ? [treeDisplayColumnIndex] : [0]);
const displayColumnIndex = treeDisplayColumnIndex ?? treeDisplayColumns[0] ?? 0;
const getRowLabel = (row: DataTableRow): string => {
const values = Array.isArray(row.values) ? row.values : [];
// Последнее заполненное значение из treeDisplayColumns
for (let i = treeDisplayColumns.length - 1; i >= 0; i--) {
const val = String(values[treeDisplayColumns[i]] ?? '').trim();
if (val) return val;
}
// Fallback: первая заполненная колонка
for (const val of values) {
const str = String(val ?? '').trim();
if (str) return str;
}
return '(без названия)';
};
const buildParentTree = (parentRows: DataTableRow[], parentId: number | null, level = 0): DataTableTreeNode[] => {
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, level + 1);
const label = getRowLabel(row);
if (children.length > 0) {
return { type: 'group' as const, id: `row-group:${row.id}`, label, level, children: [...children, { type: 'row' as const, id: row.id, label, level: level + 1, row }] };
}
return { type: 'row' as const, id: row.id, label, level, row };
});
};
const buildMultiColumnTree = (allRows: DataTableRow[], columns: number[], level = 0): DataTableTreeNode[] => {
if (level >= columns.length) {
return allRows
.sort((a, b) => a.position - b.position)
.map((row) => ({ type: 'row' as const, id: row.id, label: getRowLabel(row), level, row }));
}
const columnIndex = columns[level];
const groups = new Map<string, DataTableRow[]>();
const rootRows: DataTableRow[] = [];
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: DataTableTreeNode[] = [];
for (const [label, groupRows] of groups) {
// Если у группы все строки имеют пустое значение в treeDisplayColumns,
// группа становится конечной строкой (можно выбрать в задачах)
const allEmpty = groupRows.every((row) => {
const vals = Array.isArray(row.values) ? row.values : [];
return treeDisplayColumns.every((ci) => !String(vals[ci] ?? '').trim());
});
if (allEmpty && groupRows.length === 1) {
nodes.push({
type: 'row',
id: groupRows[0].id,
label,
level,
row: groupRows[0],
});
} else {
nodes.push({
type: 'group',
id: `group:${level}:${label}`,
label,
level,
children: buildMultiColumnTree(groupRows, columns, level + 1),
});
}
}
if (rootRows.length > 0) {
nodes.push(...buildMultiColumnTree(rootRows, columns, level + 1));
}
return nodes.sort((a, b) => {
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');
});
};
const filterNodes = (nodes: DataTableTreeNode[], query: string): DataTableTreeNode[] => {
if (!query) return nodes;
const result: DataTableTreeNode[] = [];
for (const node of nodes) {
if (node.type === 'row') {
const label = node.label.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 = treeColumns.length > 0
? buildMultiColumnTree(filteredRows, treeColumns)
: buildParentTree(filteredRows, null);
const nodes = search ? filterNodes(rawNodes, search) : rawNodes;
return {
columnIndex: treeColumnIndex,
treeColumnIndex,
treeColumns,
treeDisplayColumns,
displayColumnIndex,
nodes,
};
}