mirror of
https://github.com/chartdb/chartdb.git
synced 2026-01-05 03:09:55 -06:00
Table overlap indication (#237)
* add find overlapping functionality * add indication for tables overlapping * fix reorder overlap * add loading diagram event * fix hidden nodes overlap bug
This commit is contained in:
52
package-lock.json
generated
52
package-lock.json
generated
@@ -34,6 +34,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@xyflow/react": "^12.0.4",
|
||||
"ahooks": "^3.8.1",
|
||||
"ai": "^3.3.14",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -3294,6 +3295,38 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ahooks": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/ahooks/-/ahooks-3.8.1.tgz",
|
||||
"integrity": "sha512-JoP9+/RWO7MnI/uSKdvQ8WB10Y3oo1PjLv+4Sv4Vpm19Z86VUMdXh+RhWvMGxZZs06sq2p0xVtFk8Oh5ZObsoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0",
|
||||
"dayjs": "^1.9.1",
|
||||
"intersection-observer": "^0.12.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"screenfull": "^5.0.0",
|
||||
"tslib": "^2.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ahooks/node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/ai": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ai/-/ai-3.4.0.tgz",
|
||||
@@ -4311,6 +4344,12 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
@@ -9182,6 +9221,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/intersection-observer": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/intersection-observer/-/intersection-observer-0.12.2.tgz",
|
||||
"integrity": "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -9260,7 +9305,6 @@
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
@@ -10117,6 +10161,12 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-fast-compare": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-hotkeys-hook": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.5.1.tgz",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@xyflow/react": "^12.0.4",
|
||||
"ahooks": "^3.8.1",
|
||||
"ai": "^3.3.14",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -8,6 +8,58 @@ import type { DBRelationship } from '@/lib/domain/db-relationship';
|
||||
import type { Diagram } from '@/lib/domain/diagram';
|
||||
import type { DatabaseEdition } from '@/lib/domain/database-edition';
|
||||
import type { DBSchema } from '@/lib/domain/db-schema';
|
||||
import { EventEmitter } from 'ahooks/lib/useEventEmitter';
|
||||
|
||||
export type ChartDBEventType =
|
||||
| 'add_tables'
|
||||
| 'update_table'
|
||||
| 'remove_tables'
|
||||
| 'add_field'
|
||||
| 'remove_field'
|
||||
| 'load_diagram';
|
||||
|
||||
export type ChartDBEventBase<T extends ChartDBEventType, D> = {
|
||||
action: T;
|
||||
data: D;
|
||||
};
|
||||
|
||||
export type CreateTableEvent = ChartDBEventBase<
|
||||
'add_tables',
|
||||
{ tables: DBTable[] }
|
||||
>;
|
||||
|
||||
export type UpdateTableEvent = ChartDBEventBase<
|
||||
'update_table',
|
||||
{ id: string; table: Partial<DBTable> }
|
||||
>;
|
||||
|
||||
export type RemoveTableEvent = ChartDBEventBase<
|
||||
'remove_tables',
|
||||
{ tableIds: string[] }
|
||||
>;
|
||||
|
||||
export type AddFieldEvent = ChartDBEventBase<
|
||||
'add_field',
|
||||
{ tableId: string; field: DBField; fields: DBField[] }
|
||||
>;
|
||||
|
||||
export type RemoveFieldEvent = ChartDBEventBase<
|
||||
'remove_field',
|
||||
{ tableId: string; fieldId: string; fields: DBField[] }
|
||||
>;
|
||||
|
||||
export type LoadDiagramEvent = ChartDBEventBase<
|
||||
'load_diagram',
|
||||
{ diagram: Diagram }
|
||||
>;
|
||||
|
||||
export type ChartDBEvent =
|
||||
| CreateTableEvent
|
||||
| UpdateTableEvent
|
||||
| RemoveTableEvent
|
||||
| AddFieldEvent
|
||||
| RemoveFieldEvent
|
||||
| LoadDiagramEvent;
|
||||
|
||||
export interface ChartDBContext {
|
||||
diagramId: string;
|
||||
@@ -17,6 +69,7 @@ export interface ChartDBContext {
|
||||
schemas: DBSchema[];
|
||||
relationships: DBRelationship[];
|
||||
currentDiagram: Diagram;
|
||||
events: EventEmitter<ChartDBEvent>;
|
||||
|
||||
filteredSchemas?: string[];
|
||||
filterSchemas: (schemaIds: string[]) => void;
|
||||
@@ -154,6 +207,7 @@ export const chartDBContext = createContext<ChartDBContext>({
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
events: new EventEmitter(),
|
||||
|
||||
// General operations
|
||||
updateDiagramId: emptyFn,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { DBTable } from '@/lib/domain/db-table';
|
||||
import { deepCopy, generateId } from '@/lib/utils';
|
||||
import { randomColor } from '@/lib/colors';
|
||||
import type { ChartDBContext } from './chartdb-context';
|
||||
import type { ChartDBContext, ChartDBEvent } from './chartdb-context';
|
||||
import { chartDBContext } from './chartdb-context';
|
||||
import { DatabaseType } from '@/lib/domain/database-type';
|
||||
import type { DBField } from '@/lib/domain/db-field';
|
||||
@@ -18,11 +18,13 @@ import type { DBSchema } from '@/lib/domain/db-schema';
|
||||
import { schemaNameToSchemaId } from '@/lib/domain/db-schema';
|
||||
import { useLocalConfig } from '@/hooks/use-local-config';
|
||||
import { defaultSchemas } from '@/lib/data/default-schemas';
|
||||
import { useEventEmitter } from 'ahooks';
|
||||
|
||||
export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
children,
|
||||
}) => {
|
||||
const db = useStorage();
|
||||
const events = useEventEmitter<ChartDBEvent>();
|
||||
const navigate = useNavigate();
|
||||
const { setSchemasFilter, schemasFilter } = useLocalConfig();
|
||||
const { addUndoAction, resetRedoStack, resetUndoStack } =
|
||||
@@ -272,6 +274,8 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
...tables.map((table) => db.addTable({ diagramId, table })),
|
||||
]);
|
||||
|
||||
events.emit({ action: 'add_tables', data: { tables } });
|
||||
|
||||
if (options.updateHistory) {
|
||||
addUndoAction({
|
||||
action: 'addTables',
|
||||
@@ -281,7 +285,7 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
resetRedoStack();
|
||||
}
|
||||
},
|
||||
[db, diagramId, setTables, addUndoAction, resetRedoStack]
|
||||
[db, diagramId, setTables, addUndoAction, resetRedoStack, events]
|
||||
);
|
||||
|
||||
const addTable: ChartDBContext['addTable'] = useCallback(
|
||||
@@ -352,6 +356,8 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
tables.filter((table) => !ids.includes(table.id))
|
||||
);
|
||||
|
||||
events.emit({ action: 'remove_tables', data: { tableIds: ids } });
|
||||
|
||||
const updatedAt = new Date();
|
||||
setDiagramUpdatedAt(updatedAt);
|
||||
await Promise.all([
|
||||
@@ -381,6 +387,7 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
resetRedoStack,
|
||||
getTable,
|
||||
relationships,
|
||||
events,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -401,6 +408,12 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
setTables((tables) =>
|
||||
tables.map((t) => (t.id === id ? { ...t, ...table } : t))
|
||||
);
|
||||
|
||||
events.emit({
|
||||
action: 'update_table',
|
||||
data: { id, table },
|
||||
});
|
||||
|
||||
const updatedAt = new Date();
|
||||
setDiagramUpdatedAt(updatedAt);
|
||||
await Promise.all([
|
||||
@@ -417,7 +430,15 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
resetRedoStack();
|
||||
}
|
||||
},
|
||||
[db, setTables, addUndoAction, resetRedoStack, getTable, diagramId]
|
||||
[
|
||||
db,
|
||||
setTables,
|
||||
addUndoAction,
|
||||
resetRedoStack,
|
||||
getTable,
|
||||
diagramId,
|
||||
events,
|
||||
]
|
||||
);
|
||||
|
||||
const updateTablesState: ChartDBContext['updateTablesState'] = useCallback(
|
||||
@@ -471,6 +492,11 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
|
||||
setTables(updateTables);
|
||||
|
||||
events.emit({
|
||||
action: 'remove_tables',
|
||||
data: { tableIds: tablesToDelete.map((t) => t.id) },
|
||||
});
|
||||
|
||||
const promises = [];
|
||||
for (const updatedTable of updatedTables) {
|
||||
promises.push(
|
||||
@@ -519,6 +545,7 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
addUndoAction,
|
||||
resetRedoStack,
|
||||
relationships,
|
||||
events,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -593,6 +620,7 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
fieldId: string,
|
||||
options = { updateHistory: true }
|
||||
) => {
|
||||
const fields = getTable(tableId)?.fields ?? [];
|
||||
const prevField = getField(tableId, fieldId);
|
||||
setTables((tables) =>
|
||||
tables.map((table) =>
|
||||
@@ -607,6 +635,15 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
)
|
||||
);
|
||||
|
||||
events.emit({
|
||||
action: 'remove_field',
|
||||
data: {
|
||||
tableId: tableId,
|
||||
fieldId,
|
||||
fields: fields.filter((f) => f.id !== fieldId),
|
||||
},
|
||||
});
|
||||
|
||||
const table = await db.getTable({ diagramId, id: tableId });
|
||||
if (!table) {
|
||||
return;
|
||||
@@ -634,7 +671,16 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
resetRedoStack();
|
||||
}
|
||||
},
|
||||
[db, diagramId, setTables, addUndoAction, resetRedoStack, getField]
|
||||
[
|
||||
db,
|
||||
diagramId,
|
||||
setTables,
|
||||
addUndoAction,
|
||||
resetRedoStack,
|
||||
getField,
|
||||
getTable,
|
||||
events,
|
||||
]
|
||||
);
|
||||
|
||||
const addField: ChartDBContext['addField'] = useCallback(
|
||||
@@ -643,6 +689,7 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
field: DBField,
|
||||
options = { updateHistory: true }
|
||||
) => {
|
||||
const fields = getTable(tableId)?.fields ?? [];
|
||||
setTables((tables) =>
|
||||
tables.map((table) =>
|
||||
table.id === tableId
|
||||
@@ -651,6 +698,15 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
)
|
||||
);
|
||||
|
||||
events.emit({
|
||||
action: 'add_field',
|
||||
data: {
|
||||
tableId: tableId,
|
||||
field,
|
||||
fields: [...fields, field],
|
||||
},
|
||||
});
|
||||
|
||||
const table = await db.getTable({ diagramId, id: tableId });
|
||||
|
||||
if (!table) {
|
||||
@@ -679,7 +735,15 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
resetRedoStack();
|
||||
}
|
||||
},
|
||||
[db, diagramId, setTables, addUndoAction, resetRedoStack]
|
||||
[
|
||||
db,
|
||||
diagramId,
|
||||
setTables,
|
||||
addUndoAction,
|
||||
resetRedoStack,
|
||||
events,
|
||||
getTable,
|
||||
]
|
||||
);
|
||||
|
||||
const createField: ChartDBContext['createField'] = useCallback(
|
||||
@@ -1138,6 +1202,8 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
setRelationships(diagram?.relationships ?? []);
|
||||
setDiagramCreatedAt(diagram.createdAt);
|
||||
setDiagramUpdatedAt(diagram.updatedAt);
|
||||
|
||||
events.emit({ action: 'load_diagram', data: { diagram } });
|
||||
}
|
||||
|
||||
return diagram;
|
||||
@@ -1152,6 +1218,7 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
setRelationships,
|
||||
setDiagramCreatedAt,
|
||||
setDiagramUpdatedAt,
|
||||
events,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1166,6 +1233,7 @@ export const ChartDBProvider: React.FC<React.PropsWithChildren> = ({
|
||||
currentDiagram,
|
||||
schemas,
|
||||
filteredSchemas,
|
||||
events,
|
||||
filterSchemas,
|
||||
updateDiagramId,
|
||||
updateDiagramName,
|
||||
|
||||
73
src/lib/graph.ts
Normal file
73
src/lib/graph.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface Graph<T> {
|
||||
graph: Map<T, T[]>;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
export const createGraph = <T>(): Graph<T> => ({
|
||||
graph: new Map(),
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
|
||||
export const addVertex = <T>(graph: Graph<T>, vertex: T): Graph<T> => {
|
||||
if (!graph.graph.has(vertex)) {
|
||||
graph.graph.set(vertex, []);
|
||||
}
|
||||
return { ...graph, lastUpdated: Date.now() };
|
||||
};
|
||||
|
||||
export const addEdge = <T>(
|
||||
graph: Graph<T>,
|
||||
source: T,
|
||||
destination: T
|
||||
): Graph<T> => {
|
||||
if (!graph.graph.has(source)) {
|
||||
addVertex(graph, source);
|
||||
}
|
||||
if (!graph.graph.has(destination)) {
|
||||
addVertex(graph, destination);
|
||||
}
|
||||
|
||||
if (!graph.graph.get(source)?.includes(destination)) {
|
||||
graph.graph.get(source)?.push(destination);
|
||||
}
|
||||
|
||||
if (!graph.graph.get(destination)?.includes(source)) {
|
||||
graph.graph.get(destination)?.push(source);
|
||||
}
|
||||
|
||||
return { ...graph, lastUpdated: Date.now() };
|
||||
};
|
||||
|
||||
export const getNeighbors = <T>(graph: Graph<T>, vertex: T): T[] | undefined =>
|
||||
graph.graph.get(vertex);
|
||||
|
||||
export const removeVertex = <T>(graph: Graph<T>, vertex: T): Graph<T> => {
|
||||
graph.graph.delete(vertex);
|
||||
graph.graph.forEach((neighbors) => {
|
||||
const index = neighbors.indexOf(vertex);
|
||||
if (index !== -1) {
|
||||
neighbors.splice(index, 1); // Remove the edge
|
||||
}
|
||||
});
|
||||
return { ...graph, lastUpdated: Date.now() };
|
||||
};
|
||||
|
||||
export const removeEdge = <T>(
|
||||
graph: Graph<T>,
|
||||
source: T,
|
||||
destination: T
|
||||
): Graph<T> => {
|
||||
if (graph.graph.has(source)) {
|
||||
const index = graph.graph.get(source)?.indexOf(destination) ?? -1;
|
||||
if (index !== -1) {
|
||||
graph.graph.get(source)?.splice(index, 1);
|
||||
}
|
||||
}
|
||||
if (graph.graph.has(destination)) {
|
||||
const index = graph.graph.get(destination)?.indexOf(source) ?? -1;
|
||||
if (index !== -1) {
|
||||
graph.graph.get(destination)?.splice(index, 1); // For undirected graph
|
||||
}
|
||||
}
|
||||
return { ...graph, lastUpdated: Date.now() };
|
||||
};
|
||||
@@ -55,3 +55,7 @@ export const debounce = <T extends (...args: Parameters<T>) => ReturnType<T>>(
|
||||
timeout = setTimeout(() => func(...args), waitFor);
|
||||
};
|
||||
};
|
||||
|
||||
export const removeDups = <T>(array: T[]): T[] => {
|
||||
return [...new Set(array)];
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { Cardinality } from '@/lib/domain/db-relationship';
|
||||
import { MIN_TABLE_SIZE, type TableNodeType } from './table-node/table-node';
|
||||
import { addEdge, createGraph, removeEdge, type Graph } from '@/lib/graph';
|
||||
import type { DBTable } from '@/lib/domain/db-table';
|
||||
|
||||
export const getCardinalityMarkerId = ({
|
||||
cardinality,
|
||||
@@ -10,3 +13,112 @@ export const getCardinalityMarkerId = ({
|
||||
side: 'left' | 'right';
|
||||
}) =>
|
||||
`cardinality_${selected ? 'selected' : 'not_selected'}_${cardinality}_${side}`;
|
||||
|
||||
const calcRect = ({
|
||||
node,
|
||||
table,
|
||||
}: ExactlyOne<{ table: DBTable; node: TableNodeType }>) => {
|
||||
const id = node?.id ?? table?.id ?? '';
|
||||
const x = node?.position.x ?? table?.x ?? 0;
|
||||
const y = node?.position.y ?? table?.y ?? 0;
|
||||
const width =
|
||||
node?.measured?.width ??
|
||||
node?.data.table.width ??
|
||||
table?.width ??
|
||||
MIN_TABLE_SIZE;
|
||||
const height = node
|
||||
? (node?.measured?.height ??
|
||||
calcTableHeight(node?.data.table.fields.length ?? 0))
|
||||
: calcTableHeight(table?.fields.length ?? 0);
|
||||
|
||||
return {
|
||||
id,
|
||||
left: x,
|
||||
right: x + width,
|
||||
top: y,
|
||||
bottom: y + height,
|
||||
};
|
||||
};
|
||||
|
||||
export const findTableOverlapping = (
|
||||
{
|
||||
node,
|
||||
table,
|
||||
}: ExactlyOne<{
|
||||
node: TableNodeType;
|
||||
table: DBTable;
|
||||
}>,
|
||||
{
|
||||
nodes,
|
||||
tables,
|
||||
}: ExactlyOne<{
|
||||
nodes: TableNodeType[];
|
||||
tables: DBTable[];
|
||||
}>,
|
||||
graph: Graph<string>
|
||||
): Graph<string> => {
|
||||
const id = node?.id ?? table?.id ?? '';
|
||||
const tableRect = calcRect(node ? { node } : { table });
|
||||
|
||||
for (const otherTable of nodes ?? tables ?? []) {
|
||||
if (id === otherTable.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isNode = !!nodes;
|
||||
|
||||
const otherTableRect = isNode
|
||||
? calcRect({ node: otherTable as TableNodeType })
|
||||
: calcRect({ table: otherTable as DBTable });
|
||||
|
||||
if (
|
||||
tableRect.left < otherTableRect.right &&
|
||||
tableRect.right > otherTableRect.left &&
|
||||
tableRect.top < otherTableRect.bottom &&
|
||||
tableRect.bottom > otherTableRect.top
|
||||
) {
|
||||
graph = addEdge(graph, id, otherTable.id);
|
||||
} else {
|
||||
graph = removeEdge(graph, id, otherTable.id);
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
};
|
||||
|
||||
export const findOverlappingTables = ({
|
||||
tables,
|
||||
nodes,
|
||||
}: ExactlyOne<{
|
||||
nodes: TableNodeType[];
|
||||
tables: DBTable[];
|
||||
}>): Graph<string> => {
|
||||
let graph = createGraph<string>();
|
||||
|
||||
if (tables) {
|
||||
for (const table of tables) {
|
||||
graph = findTableOverlapping({ table }, { tables }, graph);
|
||||
}
|
||||
} else {
|
||||
for (const node of nodes) {
|
||||
graph = findTableOverlapping({ node }, { nodes }, graph);
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
};
|
||||
|
||||
export const calcTableHeight = (fieldCount: number): number => {
|
||||
const fieldHeight = 32; // h-8 per field
|
||||
|
||||
return Math.min(fieldCount, 11) * fieldHeight + 48;
|
||||
};
|
||||
|
||||
export const getTableDimensions = (
|
||||
table: DBTable
|
||||
): { width: number; height: number } => {
|
||||
const fieldCount = table.fields.length;
|
||||
const height = calcTableHeight(fieldCount);
|
||||
const width = table.width || MIN_TABLE_SIZE;
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type {
|
||||
addEdge,
|
||||
NodePositionChange,
|
||||
@@ -52,6 +58,15 @@ import { useDialog } from '@/hooks/use-dialog';
|
||||
import { MarkerDefinitions } from './marker-definitions';
|
||||
import { CanvasContextMenu } from './canvas-context-menu';
|
||||
import { areFieldTypesCompatible } from '@/lib/data/data-types';
|
||||
import {
|
||||
calcTableHeight,
|
||||
findOverlappingTables,
|
||||
findTableOverlapping,
|
||||
} from './canvas-utils';
|
||||
import type { Graph } from '@/lib/graph';
|
||||
import { createGraph, removeVertex } from '@/lib/graph';
|
||||
import type { ChartDBEvent } from '@/context/chartdb-context/chartdb-context';
|
||||
import { debounce } from '@/lib/utils';
|
||||
|
||||
type AddEdgeParams = Parameters<typeof addEdge<TableEdgeType>>[0];
|
||||
|
||||
@@ -66,6 +81,7 @@ const tableToTableNode = (
|
||||
position: { x: table.x, y: table.y },
|
||||
data: {
|
||||
table,
|
||||
isOverlapping: false,
|
||||
},
|
||||
width: table.width ?? MIN_TABLE_SIZE,
|
||||
hidden: !shouldShowTablesBySchemaFilter(table, filteredSchemas),
|
||||
@@ -76,12 +92,12 @@ export interface CanvasProps {
|
||||
}
|
||||
|
||||
export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
const { getEdge, getInternalNode, fitView, getEdges } = useReactFlow();
|
||||
const { getEdge, getInternalNode, fitView, getEdges, getNode } =
|
||||
useReactFlow();
|
||||
const [selectedTableIds, setSelectedTableIds] = useState<string[]>([]);
|
||||
const [selectedRelationshipIds, setSelectedRelationshipIds] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const { filteredSchemas } = useChartDB();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -92,6 +108,8 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
removeRelationships,
|
||||
getField,
|
||||
databaseType,
|
||||
filteredSchemas,
|
||||
events,
|
||||
} = useChartDB();
|
||||
const { showSidePanel } = useLayout();
|
||||
const { effectiveTheme } = useTheme();
|
||||
@@ -101,6 +119,8 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
const nodeTypes = useMemo(() => ({ table: TableNode }), []);
|
||||
const edgeTypes = useMemo(() => ({ 'table-edge': TableEdge }), []);
|
||||
const [isInitialLoadingNodes, setIsInitialLoadingNodes] = useState(true);
|
||||
const [overlapGraph, setOverlapGraph] =
|
||||
useState<Graph<string>>(createGraph());
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<TableNodeType>(
|
||||
initialTables.map((table) => tableToTableNode(table, filteredSchemas))
|
||||
@@ -207,9 +227,47 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(
|
||||
tables.map((table) => tableToTableNode(table, filteredSchemas))
|
||||
tables.map((table) => {
|
||||
const isOverlapping =
|
||||
(overlapGraph.graph.get(table.id) ?? []).length > 0;
|
||||
const node = tableToTableNode(table, filteredSchemas);
|
||||
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
isOverlapping,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}, [tables, setNodes, filteredSchemas]);
|
||||
}, [
|
||||
tables,
|
||||
setNodes,
|
||||
filteredSchemas,
|
||||
overlapGraph.lastUpdated,
|
||||
overlapGraph.graph,
|
||||
]);
|
||||
|
||||
const prevFilteredSchemas = useRef<string[] | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!equal(filteredSchemas, prevFilteredSchemas.current)) {
|
||||
debounce(() => {
|
||||
const overlappingTablesInDiagram = findOverlappingTables({
|
||||
tables: tables.filter((table) =>
|
||||
shouldShowTablesBySchemaFilter(table, filteredSchemas)
|
||||
),
|
||||
});
|
||||
setOverlapGraph(overlappingTablesInDiagram);
|
||||
fitView({
|
||||
duration: 500,
|
||||
padding: 0.1,
|
||||
maxZoom: 0.8,
|
||||
});
|
||||
}, 500)();
|
||||
prevFilteredSchemas.current = filteredSchemas;
|
||||
}
|
||||
}, [filteredSchemas, fitView, tables]);
|
||||
|
||||
const onConnectHandler = useCallback(
|
||||
async (params: AddEdgeParams) => {
|
||||
@@ -279,11 +337,50 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
[getEdge, onEdgesChange, removeRelationships]
|
||||
);
|
||||
|
||||
const updateOverlappingGraphOnChanges = useCallback(
|
||||
({
|
||||
positionChanges,
|
||||
sizeChanges,
|
||||
}: {
|
||||
positionChanges: NodePositionChange[];
|
||||
sizeChanges: NodeDimensionChange[];
|
||||
}) => {
|
||||
if (positionChanges.length > 0 || sizeChanges.length > 0) {
|
||||
let newOverlappingGraph: Graph<string> = overlapGraph;
|
||||
|
||||
for (const change of positionChanges) {
|
||||
newOverlappingGraph = findTableOverlapping(
|
||||
{ node: getNode(change.id) as TableNodeType },
|
||||
{ nodes: nodes.filter((node) => !node.hidden) },
|
||||
newOverlappingGraph
|
||||
);
|
||||
}
|
||||
|
||||
for (const change of sizeChanges) {
|
||||
newOverlappingGraph = findTableOverlapping(
|
||||
{ node: getNode(change.id) as TableNodeType },
|
||||
{ nodes: nodes.filter((node) => !node.hidden) },
|
||||
newOverlappingGraph
|
||||
);
|
||||
}
|
||||
|
||||
setOverlapGraph(newOverlappingGraph);
|
||||
}
|
||||
},
|
||||
[nodes, overlapGraph, setOverlapGraph, getNode]
|
||||
);
|
||||
|
||||
const updateOverlappingGraphOnChangesDebounced = debounce(
|
||||
updateOverlappingGraphOnChanges,
|
||||
200
|
||||
);
|
||||
|
||||
const onNodesChangeHandler: OnNodesChange<TableNodeType> = useCallback(
|
||||
(changes) => {
|
||||
const positionChanges: NodePositionChange[] = changes.filter(
|
||||
(change) => change.type === 'position' && !change.dragging
|
||||
) as NodePositionChange[];
|
||||
|
||||
const removeChanges: NodeRemoveChange[] = changes.filter(
|
||||
(change) => change.type === 'remove'
|
||||
) as NodeRemoveChange[];
|
||||
@@ -336,11 +433,101 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
);
|
||||
}
|
||||
|
||||
updateOverlappingGraphOnChangesDebounced({
|
||||
positionChanges,
|
||||
sizeChanges,
|
||||
});
|
||||
|
||||
return onNodesChange(changes);
|
||||
},
|
||||
[onNodesChange, updateTablesState]
|
||||
[
|
||||
onNodesChange,
|
||||
updateTablesState,
|
||||
updateOverlappingGraphOnChangesDebounced,
|
||||
]
|
||||
);
|
||||
|
||||
const eventConsumer = useCallback(
|
||||
(event: ChartDBEvent) => {
|
||||
let newOverlappingGraph: Graph<string> = overlapGraph;
|
||||
if (event.action === 'add_tables') {
|
||||
for (const table of event.data.tables) {
|
||||
newOverlappingGraph = findTableOverlapping(
|
||||
{ node: getNode(table.id) as TableNodeType },
|
||||
{ nodes: nodes.filter((node) => !node.hidden) },
|
||||
overlapGraph
|
||||
);
|
||||
}
|
||||
|
||||
setOverlapGraph(newOverlappingGraph);
|
||||
} else if (event.action === 'remove_tables') {
|
||||
for (const tableId of event.data.tableIds) {
|
||||
newOverlappingGraph = removeVertex(
|
||||
newOverlappingGraph,
|
||||
tableId
|
||||
);
|
||||
}
|
||||
|
||||
setOverlapGraph(newOverlappingGraph);
|
||||
} else if (
|
||||
event.action === 'update_table' &&
|
||||
event.data.table.width
|
||||
) {
|
||||
const node = getNode(event.data.id) as TableNodeType;
|
||||
|
||||
const measured = {
|
||||
...node.measured,
|
||||
width: event.data.table.width,
|
||||
};
|
||||
|
||||
newOverlappingGraph = findTableOverlapping(
|
||||
{
|
||||
node: {
|
||||
...node,
|
||||
measured,
|
||||
},
|
||||
},
|
||||
{ nodes: nodes.filter((node) => !node.hidden) },
|
||||
overlapGraph
|
||||
);
|
||||
setOverlapGraph(newOverlappingGraph);
|
||||
} else if (
|
||||
event.action === 'add_field' ||
|
||||
event.action === 'remove_field'
|
||||
) {
|
||||
const node = getNode(event.data.tableId) as TableNodeType;
|
||||
|
||||
const measured = {
|
||||
...(node.measured ?? {}),
|
||||
height: calcTableHeight(event.data.fields.length),
|
||||
};
|
||||
|
||||
newOverlappingGraph = findTableOverlapping(
|
||||
{
|
||||
node: {
|
||||
...node,
|
||||
measured,
|
||||
},
|
||||
},
|
||||
{ nodes: nodes.filter((node) => !node.hidden) },
|
||||
overlapGraph
|
||||
);
|
||||
setOverlapGraph(newOverlappingGraph);
|
||||
} else if (event.action === 'load_diagram') {
|
||||
const diagramTables = event.data.diagram.tables ?? [];
|
||||
const overlappingTablesInDiagram = findOverlappingTables({
|
||||
tables: diagramTables.filter((table) =>
|
||||
shouldShowTablesBySchemaFilter(table, filteredSchemas)
|
||||
),
|
||||
});
|
||||
setOverlapGraph(overlappingTablesInDiagram);
|
||||
}
|
||||
},
|
||||
[overlapGraph, setOverlapGraph, getNode, nodes, filteredSchemas]
|
||||
);
|
||||
|
||||
events.useSubscription(eventConsumer);
|
||||
|
||||
const isLoadingDOM =
|
||||
tables.length > 0 ? !getInternalNode(tables[0].id) : false;
|
||||
|
||||
@@ -353,6 +540,10 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
mode: 'all', // Use 'all' mode for manual reordering
|
||||
});
|
||||
|
||||
const updatedOverlapGraph = findOverlappingTables({
|
||||
tables: newTables,
|
||||
});
|
||||
|
||||
updateTablesState((currentTables) =>
|
||||
currentTables.map((table) => {
|
||||
const newTable = newTables.find((t) => t.id === table.id);
|
||||
@@ -363,6 +554,8 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setOverlapGraph(updatedOverlapGraph);
|
||||
}, [filteredSchemas, relationships, tables, updateTablesState]);
|
||||
|
||||
const showReorderConfirmation = useCallback(() => {
|
||||
|
||||
@@ -19,10 +19,12 @@ import type { TableEdgeType } from '../table-edge';
|
||||
import type { DBField } from '@/lib/domain/db-field';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TableNodeContextMenu } from './table-node-context-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type TableNodeType = Node<
|
||||
{
|
||||
table: DBTable;
|
||||
isOverlapping: boolean;
|
||||
},
|
||||
'table'
|
||||
>;
|
||||
@@ -36,7 +38,7 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = ({
|
||||
selected,
|
||||
dragging,
|
||||
id,
|
||||
data: { table },
|
||||
data: { table, isOverlapping },
|
||||
}) => {
|
||||
const { updateTable, relationships } = useChartDB();
|
||||
const edges = useStore((store) => store.edges) as TableEdgeType[];
|
||||
@@ -121,7 +123,15 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = ({
|
||||
return (
|
||||
<TableNodeContextMenu table={table}>
|
||||
<div
|
||||
className={`flex w-full flex-col border-2 bg-slate-50 dark:bg-slate-950 ${selected ? 'border-pink-600' : 'border-slate-500 dark:border-slate-700'} rounded-lg shadow-sm`}
|
||||
className={cn(
|
||||
'flex w-full flex-col border-2 bg-slate-50 dark:bg-slate-950 rounded-lg shadow-sm transition-transform duration-300',
|
||||
selected
|
||||
? 'border-pink-600'
|
||||
: 'border-slate-500 dark:border-slate-700',
|
||||
isOverlapping
|
||||
? 'ring-2 ring-offset-slate-50 dark:ring-offset-slate-900 ring-blue-500 ring-offset-2 animate-pulse-border animate-scale'
|
||||
: ''
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (e.detail === 2) {
|
||||
openTableInEditor();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -15,15 +15,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import type { SelectBoxOption } from '@/components/select-box/select-box';
|
||||
import { SelectBox } from '@/components/select-box/select-box';
|
||||
import { useChartDB } from '@/hooks/use-chartdb';
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
import { debounce } from '@/lib/utils';
|
||||
|
||||
export interface SidePanelProps {}
|
||||
|
||||
export const SidePanel: React.FC<SidePanelProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const { schemas, filterSchemas, filteredSchemas } = useChartDB();
|
||||
const { fitView } = useReactFlow();
|
||||
const {
|
||||
selectSidebarSection,
|
||||
selectedSidebarSection,
|
||||
@@ -32,18 +29,6 @@ export const SidePanel: React.FC<SidePanelProps> = () => {
|
||||
closeSelectSchema,
|
||||
} = useLayout();
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSchemas !== undefined) {
|
||||
debounce(() => {
|
||||
fitView({
|
||||
duration: 500,
|
||||
padding: 0.1,
|
||||
maxZoom: 0.8,
|
||||
});
|
||||
}, 500)();
|
||||
}
|
||||
}, [filteredSchemas, fitView]);
|
||||
|
||||
const schemasOptions: SelectBoxOption[] = useMemo(
|
||||
() =>
|
||||
schemas.map(
|
||||
|
||||
10
src/types.d.ts
vendored
10
src/types.d.ts
vendored
@@ -3,3 +3,13 @@ type PartialExcept<
|
||||
ParameterField extends keyof ParameterType,
|
||||
> = Pick<ParameterType, ParameterField> &
|
||||
Partial<Omit<ParameterType, ParameterField>>;
|
||||
|
||||
type Explode<T> = keyof T extends infer K
|
||||
? K extends unknown
|
||||
? { [I in keyof T]: I extends K ? T[I] : never }
|
||||
: never
|
||||
: never;
|
||||
type AtMostOne<T> = Explode<Partial<T>>;
|
||||
type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> &
|
||||
U[keyof U];
|
||||
type ExactlyOne<T> = AtMostOne<T> & AtLeastOne<T>;
|
||||
|
||||
@@ -67,6 +67,15 @@ module.exports = {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
'pulse-border': {
|
||||
'0%, 100%': { borderColor: 'rgba(59, 130, 246, 0.5)' },
|
||||
'50%': { borderColor: 'rgba(59, 130, 246, 1)' },
|
||||
},
|
||||
scale: {
|
||||
'0%': { transform: 'scale(1)' },
|
||||
'50%': { transform: 'scale(1.05)' },
|
||||
'100%': { transform: 'scale(1)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
|
||||
Reference in New Issue
Block a user