feat(directories): import/export hierarchical data tables with template button

- server/storage/data-tables-core.storage.ts: support parentId in sync operations
- server/routes/data-tables-sync.routes.ts: validate parent references and cycles for hierarchical rows
- client/TableEditor.tsx: export/import _id/_parent_id/_position, add Download Template button
This commit is contained in:
2026-07-12 23:29:35 +03:00
parent 09861fc966
commit d91dd2bd92
3 changed files with 253 additions and 40 deletions

View File

@@ -89,8 +89,9 @@ export default function TableEditor() {
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[] }[];
const [importPreview, setImportPreview] = useState<{
added: { values: string[]; parentId?: number | null }[];
updated: { id: number; values: string[]; oldPosition: number; newPosition: number; parentId: number | null; oldParentId: number | null }[];
deleted: { id: number; values: string[] }[];
reordered: { values: string[]; oldPosition: number; newPosition: number }[];
} | null>(null);
@@ -478,8 +479,13 @@ export default function TableEditor() {
const handleExport = async () => {
if (!data?.table) return;
const cols = data.table.columns as TableColumn[];
const headers = cols.map(c => c.name);
const rows = (rowsData?.rows || data.table.rows).map(r => r.values);
const headers = ['_id', '_parent_id', '_position', ...cols.map(c => c.name)];
const rows = (rowsData?.rows || data.table.rows).map(r => [
r.id,
r.parentId ?? '',
r.position,
...r.values,
]);
const wsData = [headers, ...rows];
// Notify server about the export for audit logging (fire-and-forget)
@@ -497,6 +503,21 @@ export default function TableEditor() {
toast({ title: 'Файл экспортирован' });
};
const handleDownloadTemplate = async () => {
if (!data?.table) return;
const cols = data.table.columns as TableColumn[];
const headers = ['_id', '_parent_id', '_position', ...cols.map(c => c.name)];
const exampleRow = [1, '', 0, ...cols.map(() => '')];
const noteRow = ['id строки (авто)', 'id родителя (можно пусто)', 'порядок', ...cols.map(c => `значение: ${c.name}`)];
const wsData = [headers, noteRow, exampleRow];
const ws = XLSX.utils.aoa_to_sheet(wsData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Шаблон');
XLSX.writeFile(wb, `${data.table.name}_шаблон.xlsx`);
toast({ title: 'Шаблон скачан' });
};
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !data?.table) return;
@@ -506,18 +527,44 @@ export default function TableEditor() {
const buffer = await file.arrayBuffer();
const wb = XLSX.read(buffer, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const importedData: string[][] = XLSX.utils.sheet_to_json(ws, { header: 1 });
const importedData: (string | number | null)[][] = XLSX.utils.sheet_to_json(ws, { header: 1 });
if (importedData.length === 0) {
toast({ title: 'Файл пуст', variant: 'destructive' });
return;
}
const hasHeaders = importedData[0].some(cell =>
tableColumns.some(col => col.name.toLowerCase() === String(cell || '').toLowerCase())
);
const dataRows = hasHeaders ? importedData.slice(1) : importedData;
const rows = dataRows.map(row => ({ values: row.map(cell => String(cell || '')) }));
const headers = importedData[0].map(h => String(h || '').trim().toLowerCase());
const idColIndex = headers.indexOf('_id');
const parentIdColIndex = headers.indexOf('_parent_id');
const positionColIndex = headers.indexOf('_position');
const isExtendedFormat = idColIndex !== -1 || parentIdColIndex !== -1 || positionColIndex !== -1;
const firstDataRowIndex = importedData.slice(1).findIndex(row => row.some(cell => cell != null && String(cell).trim() !== ''));
const dataRows = firstDataRowIndex !== -1 ? importedData.slice(1 + firstDataRowIndex) : [];
let rows: any[];
if (isExtendedFormat) {
rows = dataRows.map(row => {
const id = idColIndex !== -1 && row[idColIndex] != null && String(row[idColIndex]).trim() !== ''
? Number(row[idColIndex])
: undefined;
const parentId = parentIdColIndex !== -1 && row[parentIdColIndex] != null && String(row[parentIdColIndex]).trim() !== ''
? Number(row[parentIdColIndex])
: null;
const values = row
.map((cell, idx) => ({ cell, idx }))
.filter(({ idx }) => idx !== idColIndex && idx !== parentIdColIndex && idx !== positionColIndex)
.map(({ cell }) => String(cell ?? ''));
return { id, parentId, values };
});
} else {
const hasHeaders = importedData[0].some(cell =>
tableColumns.some(col => col.name.toLowerCase() === String(cell || '').toLowerCase())
);
const legacyDataRows = hasHeaders ? importedData.slice(1) : importedData;
rows = legacyDataRows.map(row => ({ values: row.map(cell => String(cell ?? '')) }));
}
setImportRows(rows);
const response = await apiRequest('POST', `/api/directories/${tableId}/sync`, { apply: false, rows });
@@ -527,6 +574,7 @@ export default function TableEditor() {
added: result.addedRows || [],
deleted: result.deletedRows || [],
reordered: result.reorderedRows || [],
updated: result.updatedRows || [],
});
setImportOpen(true);
} catch (error) {
@@ -651,6 +699,10 @@ export default function TableEditor() {
</Button>
{canEdit && (
<>
<Button variant="outline" onClick={handleDownloadTemplate} data-testid="button-template">
<FileSpreadsheet className="w-4 h-4 mr-2" />
Шаблон
</Button>
<input
ref={fileInputRef}
type="file"
@@ -1458,6 +1510,10 @@ export default function TableEditor() {
<Badge variant="default" className="bg-green-600">+{importPreview.added.length}</Badge>
<span className="text-sm">Добавлено</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="default" className="bg-yellow-600">{importPreview.updated.length}</Badge>
<span className="text-sm">Изменено</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="default" className="bg-blue-600">{importPreview.reordered.length}</Badge>
<span className="text-sm">Переупорядочено</span>
@@ -1484,6 +1540,28 @@ export default function TableEditor() {
</div>
)}
{importPreview.updated.length > 0 && (
<div>
<h4 className="font-medium mb-2 text-yellow-600">Будут изменены:</h4>
<div className="max-h-32 overflow-y-auto border rounded p-2 space-y-1">
{importPreview.updated.slice(0, 10).map((row, idx) => (
<div key={idx} className="text-sm bg-yellow-50 dark:bg-yellow-950 p-1 rounded">
{Array.isArray(row.values) ? row.values.join(' | ') : '—'}
{row.parentId !== row.oldParentId && (
<span className="text-muted-foreground ml-2">(родитель: {row.oldParentId || 'корень'} → {row.parentId || 'корень'})</span>
)}
{row.oldPosition !== row.newPosition && (
<span className="text-muted-foreground ml-2">(позиция: {row.oldPosition + 1} → {row.newPosition + 1})</span>
)}
</div>
))}
{importPreview.updated.length > 10 && (
<div className="text-sm text-muted-foreground">...и ещё {importPreview.updated.length - 10}</div>
)}
</div>
</div>
)}
{importPreview.reordered.length > 0 && (
<div>
<h4 className="font-medium mb-2 text-blue-600">Изменён порядок:</h4>
@@ -1517,7 +1595,7 @@ export default function TableEditor() {
</div>
)}
{importPreview.added.length === 0 && importPreview.reordered.length === 0 && importPreview.deleted.length === 0 && (
{importPreview.added.length === 0 && importPreview.updated.length === 0 && importPreview.reordered.length === 0 && importPreview.deleted.length === 0 && (
<div className="text-center py-4 text-muted-foreground">
Изменений не обнаружено. Данные уже синхронизированы.
</div>
@@ -1528,9 +1606,9 @@ export default function TableEditor() {
<Button variant="outline" onClick={() => { setImportOpen(false); setImportPreview(null); setImportRows([]); }}>
Отмена
</Button>
<Button
onClick={handleApplyImport}
disabled={importLoading || !!(importPreview && importPreview.added.length === 0 && importPreview.reordered.length === 0 && importPreview.deleted.length === 0)}
<Button
onClick={handleApplyImport}
disabled={importLoading || !!(importPreview && importPreview.added.length === 0 && importPreview.updated.length === 0 && importPreview.reordered.length === 0 && importPreview.deleted.length === 0)}
data-testid="button-apply-import"
>
{importLoading ? 'Применение...' : 'Применить изменения'}

View File

@@ -34,7 +34,9 @@ export function registerDataTableSyncRoutes(app: import("express").Express): voi
}
const currentRows = await storage.getDataTableRows(tableId, req.organizationId!);
const validCurrentRows = currentRows.filter(r => Array.isArray(r.values));
const currentRowsById = new Map(validCurrentRows.map(r => [r.id, r]));
const normalizeValues = (values: (string | null)[] | null, columnCount: number): string[] => {
const arr = values || [];
const result: string[] = [];
@@ -44,39 +46,168 @@ export function registerDataTableSyncRoutes(app: import("express").Express): voi
}
return result;
};
const columnCount = table.columns?.length || 6;
const makeKey = (values: (string | null)[] | null) => JSON.stringify(normalizeValues(values, columnCount));
const validCurrentRows = currentRows.filter(r => Array.isArray(r.values));
const currentRowsByKey = new Map(validCurrentRows.map(r => [makeKey(r.values), r]));
const currentKeysSet = new Set(validCurrentRows.map(r => makeKey(r.values)));
const newKeysSet = new Set(rows.map((r: { values: string[] }) => makeKey(r.values)));
const added: { values: string[], position: number }[] = [];
const columnCount = table.columns?.length || 6;
const makeValuesKey = (values: (string | null)[] | null) => JSON.stringify(normalizeValues(values, columnCount));
// Detect extended format: rows contain id or parentId
const isExtendedFormat = rows.some((r: any) => r && (r.id != null || r.parentId != null));
if (isExtendedFormat) {
// Parse incoming rows
const incomingRows: {
rawId?: number | null;
values: string[];
parentId?: number | null;
position: number;
}[] = rows.map((r: any, index: number) => ({
rawId: r.id != null ? Number(r.id) : undefined,
values: normalizeValues(r.values, columnCount),
parentId: r.parentId != null ? Number(r.parentId) : null,
position: index,
}));
// Resolve existing row id for each incoming row
const incomingWithResolvedId = incomingRows.map((row) => {
const existing = row.rawId != null ? currentRowsById.get(row.rawId) : undefined;
return {
...row,
existingId: existing ? existing.id : undefined,
};
});
// Determine deleted rows: existing ids not present in incoming rows
const incomingExistingIds = new Set(
incomingWithResolvedId
.filter(r => r.existingId != null)
.map(r => r.existingId!)
);
const deleted = validCurrentRows
.filter(r => !incomingExistingIds.has(r.id))
.map(r => ({ id: r.id, values: normalizeValues(r.values, columnCount) }));
const deletedIds = new Set(deleted.map(d => d.id));
// Validate parent references
const validParentIds = new Set(validCurrentRows.map(r => r.id).filter(id => !deletedIds.has(id)));
for (const row of incomingWithResolvedId) {
if (row.parentId != null) {
if (!validParentIds.has(row.parentId)) {
return res.status(400).json({ error: `Некорректный parentId ${row.parentId}: строка не найдена или будет удалена` });
}
if (row.existingId != null && row.parentId === row.existingId) {
return res.status(400).json({ error: `Строка не может быть родителем самой себя (id ${row.existingId})` });
}
}
}
// Detect cycles using union-find / ancestor check
const parentMap = new Map<number | string, number | null>();
for (const row of incomingWithResolvedId) {
const key = row.existingId != null ? row.existingId : `new:${row.position}`;
parentMap.set(key, row.parentId ?? null);
}
const getAncestors = (start: number | string): Set<number | string> => {
const ancestors = new Set<number | string>();
let current: number | string | null | undefined = start;
while (current != null) {
if (ancestors.has(current)) break; // cycle detected
ancestors.add(current);
current = parentMap.get(current);
}
return ancestors;
};
for (const row of incomingWithResolvedId) {
if (row.parentId == null) continue;
const key = row.existingId != null ? row.existingId : `new:${row.position}`;
const ancestors = getAncestors(row.parentId);
if (ancestors.has(key)) {
return res.status(400).json({ error: `Обнаружен цикл в иерархии для строки ${row.values[0] || row.position}` });
}
}
// Build preview lists
const added: { values: string[]; position: number; parentId: number | null }[] = [];
const updated: { id: number; values: string[]; oldPosition: number; newPosition: number; parentId: number | null; oldParentId: number | null }[] = [];
for (const row of incomingWithResolvedId) {
if (row.existingId == null) {
added.push({ values: row.values, position: row.position, parentId: row.parentId ?? null });
} else {
const existing = currentRowsById.get(row.existingId)!;
const parentChanged = (row.parentId ?? null) !== (existing.parentId ?? null);
const positionChanged = row.position !== existing.position;
const valuesChanged = makeValuesKey(row.values) !== makeValuesKey(existing.values);
if (parentChanged || positionChanged || valuesChanged) {
updated.push({
id: existing.id,
values: row.values,
oldPosition: existing.position,
newPosition: row.position,
parentId: row.parentId ?? null,
oldParentId: existing.parentId ?? null,
});
}
}
}
if (apply) {
const positionUpdates = updated
.filter(u => u.oldPosition === u.newPosition && u.oldParentId === u.parentId && makeValuesKey(u.values) === makeValuesKey(currentRowsById.get(u.id)!.values))
.map(u => ({ id: u.id, position: u.newPosition, parentId: u.parentId }));
const reordered = updated
.filter(u => u.oldPosition !== u.newPosition || u.oldParentId !== u.parentId || makeValuesKey(u.values) !== makeValuesKey(currentRowsById.get(u.id)!.values))
.map(u => ({ id: u.id, newPosition: u.newPosition, parentId: u.parentId }));
await storage.applyDataTableSync(
tableId,
deleted,
added,
reordered,
positionUpdates
);
}
return res.json({
apply,
added: added.length,
updated: updated.length,
deleted: deleted.length,
addedRows: added.map(r => ({ values: r.values, parentId: r.parentId })),
updatedRows: updated.map(r => ({ id: r.id, values: r.values, oldPosition: r.oldPosition, newPosition: r.newPosition, parentId: r.parentId, oldParentId: r.oldParentId })),
deletedRows: deleted.map(r => ({ id: r.id, values: r.values })),
});
}
// Legacy format: sync by full values key
const currentRowsByKey = new Map(validCurrentRows.map(r => [makeValuesKey(r.values), r]));
const currentKeysSet = new Set(validCurrentRows.map(r => makeValuesKey(r.values)));
const newKeysSet = new Set(rows.map((r: { values: string[] }) => makeValuesKey(r.values)));
const added: { values: string[], position: number; parentId?: number | null }[] = [];
const deleted: { id: number; values: string[] }[] = [];
const reordered: { id: number; values: string[]; oldPosition: number; newPosition: number }[] = [];
const reordered: { id: number; values: string[]; oldPosition: number; newPosition: number; parentId?: number | null }[] = [];
rows.forEach((newRow: { values: string[] }, index: number) => {
const key = makeKey(newRow.values);
const key = makeValuesKey(newRow.values);
if (!currentKeysSet.has(key)) {
added.push({ values: newRow.values, position: index });
} else {
const existingRow = currentRowsByKey.get(key);
if (existingRow && existingRow.position !== index) {
reordered.push({
id: existingRow.id,
values: normalizeValues(existingRow.values, columnCount),
oldPosition: existingRow.position,
newPosition: index
reordered.push({
id: existingRow.id,
values: normalizeValues(existingRow.values, columnCount),
oldPosition: existingRow.position,
newPosition: index,
});
}
}
});
for (const currentRow of validCurrentRows) {
const key = makeKey(currentRow.values);
const key = makeValuesKey(currentRow.values);
if (!newKeysSet.has(key)) {
deleted.push({ id: currentRow.id, values: normalizeValues(currentRow.values, columnCount) });
}
@@ -85,7 +216,7 @@ export function registerDataTableSyncRoutes(app: import("express").Express): voi
if (apply) {
const positionUpdates: { id: number; position: number }[] = [];
for (let i = 0; i < rows.length; i++) {
const key = makeKey(rows[i].values);
const key = makeValuesKey(rows[i].values);
const existingRow = currentRowsByKey.get(key);
if (existingRow && !deleted.some(d => d.id === existingRow.id)) {
positionUpdates.push({ id: existingRow.id, position: i });

View File

@@ -315,9 +315,9 @@ export class DataTablesCoreStorage extends BotsStorage {
async applyDataTableSync(
tableId: number,
deleted: { id: number }[],
added: { values: string[]; position: number }[],
reordered: { id: number; newPosition: number }[],
positionUpdates: { id: number; position: number }[]
added: { values: string[]; position: number; parentId?: number | null }[],
reordered: { id: number; newPosition: number; parentId?: number | null }[],
positionUpdates: { id: number; position: number; parentId?: number | null }[]
): Promise<void> {
await db.transaction(async (tx) => {
for (const row of deleted) {
@@ -329,18 +329,22 @@ export class DataTablesCoreStorage extends BotsStorage {
for (const row of added) {
await tx
.insert(dataTableRows)
.values({ tableId, values: row.values, position: row.position });
.values({ tableId, values: row.values, position: row.position, parentId: row.parentId ?? null });
}
for (const row of reordered) {
const updates: Partial<typeof dataTableRows.$inferInsert> = { position: row.newPosition, updatedAt: new Date() };
if (row.parentId !== undefined) updates.parentId = row.parentId;
await tx
.update(dataTableRows)
.set({ position: row.newPosition, updatedAt: new Date() })
.set(updates)
.where(and(eq(dataTableRows.id, row.id), eq(dataTableRows.tableId, tableId)));
}
for (const row of positionUpdates) {
const updates: Partial<typeof dataTableRows.$inferInsert> = { position: row.position, updatedAt: new Date() };
if (row.parentId !== undefined) updates.parentId = row.parentId;
await tx
.update(dataTableRows)
.set({ position: row.position, updatedAt: new Date() })
.set(updates)
.where(and(eq(dataTableRows.id, row.id), eq(dataTableRows.tableId, tableId)));
}
});