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:
@@ -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 ? 'Применение...' : 'Применить изменения'}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user