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:
Guy Ben-Aharon
2024-10-05 20:58:17 +03:00
committed by GitHub
parent 588543f324
commit a28fb4afa1
12 changed files with 599 additions and 30 deletions

52
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

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

View File

@@ -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
View 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() };
};

View File

@@ -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)];
};

View File

@@ -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 };
};

View File

@@ -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(() => {

View File

@@ -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();

View File

@@ -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
View File

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

View File

@@ -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',