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:
@@ -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