mirror of
https://github.com/chartdb/chartdb.git
synced 2025-10-23 16:13:40 +00:00
feat: implement area grouping with parent-child relationships (#762)
* feat: implement area grouping with parent-child relationships * fix: improve area node visual appearance and text visibility * update area header color * fix build * fix * fix * fix * fix * fix * fix --------- Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
This commit is contained in:
@@ -47,6 +47,7 @@ export interface DBTable {
|
|||||||
comments?: string | null;
|
comments?: string | null;
|
||||||
order?: number | null;
|
order?: number | null;
|
||||||
expanded?: boolean | null;
|
expanded?: boolean | null;
|
||||||
|
parentAreaId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dbTableSchema: z.ZodType<DBTable> = z.object({
|
export const dbTableSchema: z.ZodType<DBTable> = z.object({
|
||||||
@@ -65,6 +66,7 @@ export const dbTableSchema: z.ZodType<DBTable> = z.object({
|
|||||||
comments: z.string().or(z.null()).optional(),
|
comments: z.string().or(z.null()).optional(),
|
||||||
order: z.number().or(z.null()).optional(),
|
order: z.number().or(z.null()).optional(),
|
||||||
expanded: z.boolean().or(z.null()).optional(),
|
expanded: z.boolean().or(z.null()).optional(),
|
||||||
|
parentAreaId: z.string().or(z.null()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const shouldShowTablesBySchemaFilter = (
|
export const shouldShowTablesBySchemaFilter = (
|
||||||
|
@@ -80,11 +80,13 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
|
|||||||
<NodeResizer
|
<NodeResizer
|
||||||
isVisible={focused}
|
isVisible={focused}
|
||||||
lineClassName="!border-4 !border-transparent"
|
lineClassName="!border-4 !border-transparent"
|
||||||
handleClassName="!h-3 !w-3 !rounded-full !bg-pink-600"
|
handleClassName="!h-[18px] !w-[18px] !rounded-full !bg-pink-600"
|
||||||
|
minHeight={100}
|
||||||
|
minWidth={100}
|
||||||
/>
|
/>
|
||||||
<div className="group flex h-8 items-center justify-between rounded-t-md px-2">
|
<div className="group flex h-8 items-center justify-between rounded-t-md px-2">
|
||||||
<div className="flex w-full items-center gap-1">
|
<div className="flex w-full items-center gap-1">
|
||||||
<GripVertical className="size-4 text-slate-700 opacity-60 dark:text-slate-300" />
|
<GripVertical className="size-4 shrink-0 text-slate-700 opacity-60 dark:text-slate-300" />
|
||||||
|
|
||||||
{editMode && !readonly ? (
|
{editMode && !readonly ? (
|
||||||
<div className="flex w-full items-center">
|
<div className="flex w-full items-center">
|
||||||
|
82
src/pages/editor-page/canvas/area-utils.ts
Normal file
82
src/pages/editor-page/canvas/area-utils.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { DBTable } from '@/lib/domain/db-table';
|
||||||
|
import type { Area } from '@/lib/domain/area';
|
||||||
|
import { calcTableHeight } from '@/lib/domain/db-table';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a table is inside an area based on their positions and dimensions
|
||||||
|
*/
|
||||||
|
const isTableInsideArea = (table: DBTable, area: Area): boolean => {
|
||||||
|
// Get table dimensions (assuming default width if not specified)
|
||||||
|
const tableWidth = table.width ?? 224; // MIN_TABLE_SIZE from db-table.ts
|
||||||
|
const tableHeight = calcTableHeight(table);
|
||||||
|
|
||||||
|
// Check if table's top-left corner is inside the area
|
||||||
|
const tableLeft = table.x;
|
||||||
|
const tableRight = table.x + tableWidth;
|
||||||
|
const tableTop = table.y;
|
||||||
|
const tableBottom = table.y + tableHeight;
|
||||||
|
|
||||||
|
const areaLeft = area.x;
|
||||||
|
const areaRight = area.x + area.width;
|
||||||
|
const areaTop = area.y;
|
||||||
|
const areaBottom = area.y + area.height;
|
||||||
|
|
||||||
|
// Check if table is completely inside the area
|
||||||
|
return (
|
||||||
|
tableLeft >= areaLeft &&
|
||||||
|
tableRight <= areaRight &&
|
||||||
|
tableTop >= areaTop &&
|
||||||
|
tableBottom <= areaBottom
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find which area contains a table
|
||||||
|
*/
|
||||||
|
const findContainingArea = (table: DBTable, areas: Area[]): Area | null => {
|
||||||
|
// Sort areas by order (if available) to prioritize top-most areas
|
||||||
|
const sortedAreas = [...areas].sort(
|
||||||
|
(a, b) => (b.order ?? 0) - (a.order ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const area of sortedAreas) {
|
||||||
|
if (isTableInsideArea(table, area)) {
|
||||||
|
return area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update tables with their parent area IDs based on containment
|
||||||
|
*/
|
||||||
|
export const updateTablesParentAreas = (
|
||||||
|
tables: DBTable[],
|
||||||
|
areas: Area[]
|
||||||
|
): DBTable[] => {
|
||||||
|
return tables.map((table) => {
|
||||||
|
const containingArea = findContainingArea(table, areas);
|
||||||
|
const newParentAreaId = containingArea?.id || null;
|
||||||
|
|
||||||
|
// Only update if parentAreaId has changed
|
||||||
|
if (table.parentAreaId !== newParentAreaId) {
|
||||||
|
return {
|
||||||
|
...table,
|
||||||
|
parentAreaId: newParentAreaId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all tables that are inside a specific area
|
||||||
|
*/
|
||||||
|
export const getTablesInArea = (
|
||||||
|
areaId: string,
|
||||||
|
tables: DBTable[]
|
||||||
|
): DBTable[] => {
|
||||||
|
return tables.filter((table) => table.parentAreaId === areaId);
|
||||||
|
};
|
@@ -83,6 +83,7 @@ import { useCanvas } from '@/hooks/use-canvas';
|
|||||||
import type { AreaNodeType } from './area-node/area-node';
|
import type { AreaNodeType } from './area-node/area-node';
|
||||||
import { AreaNode } from './area-node/area-node';
|
import { AreaNode } from './area-node/area-node';
|
||||||
import type { Area } from '@/lib/domain/area';
|
import type { Area } from '@/lib/domain/area';
|
||||||
|
import { updateTablesParentAreas, getTablesInArea } from './area-utils';
|
||||||
|
|
||||||
const HIGHLIGHTED_EDGE_Z_INDEX = 1;
|
const HIGHLIGHTED_EDGE_Z_INDEX = 1;
|
||||||
const DEFAULT_EDGE_Z_INDEX = 0;
|
const DEFAULT_EDGE_Z_INDEX = 0;
|
||||||
@@ -108,17 +109,22 @@ const initialEdges: EdgeType[] = [];
|
|||||||
const tableToTableNode = (
|
const tableToTableNode = (
|
||||||
table: DBTable,
|
table: DBTable,
|
||||||
filteredSchemas?: string[]
|
filteredSchemas?: string[]
|
||||||
): TableNodeType => ({
|
): TableNodeType => {
|
||||||
id: table.id,
|
// Always use absolute position for now
|
||||||
type: 'table',
|
const position = { x: table.x, y: table.y };
|
||||||
position: { x: table.x, y: table.y },
|
|
||||||
data: {
|
return {
|
||||||
table,
|
id: table.id,
|
||||||
isOverlapping: false,
|
type: 'table',
|
||||||
},
|
position,
|
||||||
width: table.width ?? MIN_TABLE_SIZE,
|
data: {
|
||||||
hidden: !shouldShowTablesBySchemaFilter(table, filteredSchemas),
|
table,
|
||||||
});
|
isOverlapping: false,
|
||||||
|
},
|
||||||
|
width: table.width ?? MIN_TABLE_SIZE,
|
||||||
|
hidden: !shouldShowTablesBySchemaFilter(table, filteredSchemas),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const areaToAreaNode = (area: Area): AreaNodeType => ({
|
const areaToAreaNode = (area: Area): AreaNodeType => ({
|
||||||
id: area.id,
|
id: area.id,
|
||||||
@@ -406,6 +412,54 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
}
|
}
|
||||||
}, [filteredSchemas, fitView, tables, setOverlapGraph]);
|
}, [filteredSchemas, fitView, tables, setOverlapGraph]);
|
||||||
|
|
||||||
|
// Handle parent area updates when tables move
|
||||||
|
const tablePositions = useMemo(
|
||||||
|
() => tables.map((t) => ({ id: t.id, x: t.x, y: t.y })),
|
||||||
|
[tables]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkParentAreas = debounce(() => {
|
||||||
|
const updatedTables = updateTablesParentAreas(tables, areas);
|
||||||
|
const needsUpdate: Array<{
|
||||||
|
id: string;
|
||||||
|
parentAreaId: string | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
updatedTables.forEach((newTable, index) => {
|
||||||
|
const oldTable = tables[index];
|
||||||
|
if (
|
||||||
|
oldTable &&
|
||||||
|
newTable.parentAreaId !== oldTable.parentAreaId
|
||||||
|
) {
|
||||||
|
needsUpdate.push({
|
||||||
|
id: newTable.id,
|
||||||
|
parentAreaId: newTable.parentAreaId || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needsUpdate.length > 0) {
|
||||||
|
updateTablesState((currentTables) =>
|
||||||
|
currentTables.map((table) => {
|
||||||
|
const update = needsUpdate.find(
|
||||||
|
(u) => u.id === table.id
|
||||||
|
);
|
||||||
|
if (update) {
|
||||||
|
return {
|
||||||
|
id: table.id,
|
||||||
|
parentAreaId: update.parentAreaId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
checkParentAreas();
|
||||||
|
}, [tablePositions, areas, updateTablesState, tables]);
|
||||||
|
|
||||||
const onConnectHandler = useCallback(
|
const onConnectHandler = useCallback(
|
||||||
async (params: AddEdgeParams) => {
|
async (params: AddEdgeParams) => {
|
||||||
if (
|
if (
|
||||||
@@ -581,8 +635,13 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
(changes: NodeChange<NodeType>[], type: NodeType['type']) => {
|
(changes: NodeChange<NodeType>[], type: NodeType['type']) => {
|
||||||
const relevantChanges = changes.filter((change) => {
|
const relevantChanges = changes.filter((change) => {
|
||||||
if (
|
if (
|
||||||
change.type === 'position' ||
|
(change.type === 'position' &&
|
||||||
change.type === 'dimensions' ||
|
!change.dragging &&
|
||||||
|
change.position?.x !== undefined &&
|
||||||
|
change.position?.y !== undefined &&
|
||||||
|
!isNaN(change.position.x) &&
|
||||||
|
!isNaN(change.position.y)) ||
|
||||||
|
(change.type === 'dimensions' && change.resizing) ||
|
||||||
change.type === 'remove'
|
change.type === 'remove'
|
||||||
) {
|
) {
|
||||||
const node = getNode(change.id);
|
const node = getNode(change.id);
|
||||||
@@ -602,7 +661,13 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
|
|
||||||
const positionChanges: NodePositionChange[] =
|
const positionChanges: NodePositionChange[] =
|
||||||
relevantChanges.filter(
|
relevantChanges.filter(
|
||||||
(change) => change.type === 'position' && !change.dragging
|
(change) =>
|
||||||
|
change.type === 'position' &&
|
||||||
|
!change.dragging &&
|
||||||
|
change.position?.x !== undefined &&
|
||||||
|
change.position?.y !== undefined &&
|
||||||
|
!isNaN(change.position.x) &&
|
||||||
|
!isNaN(change.position.y)
|
||||||
) as NodePositionChange[];
|
) as NodePositionChange[];
|
||||||
|
|
||||||
const removeChanges: NodeRemoveChange[] = relevantChanges.filter(
|
const removeChanges: NodeRemoveChange[] = relevantChanges.filter(
|
||||||
@@ -632,7 +697,51 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle table changes
|
// Handle area drag changes - add child table movements for visual feedback only
|
||||||
|
const areaDragChanges = changesToApply.filter((change) => {
|
||||||
|
if (change.type === 'position') {
|
||||||
|
const node = getNode(change.id);
|
||||||
|
return node?.type === 'area' && change.dragging;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}) as NodePositionChange[];
|
||||||
|
|
||||||
|
// Add visual position changes for child tables during area dragging
|
||||||
|
if (areaDragChanges.length > 0) {
|
||||||
|
const additionalChanges: NodePositionChange[] = [];
|
||||||
|
|
||||||
|
areaDragChanges.forEach((areaChange) => {
|
||||||
|
const currentArea = areas.find(
|
||||||
|
(a) => a.id === areaChange.id
|
||||||
|
);
|
||||||
|
if (currentArea && areaChange.position) {
|
||||||
|
const deltaX = areaChange.position.x - currentArea.x;
|
||||||
|
const deltaY = areaChange.position.y - currentArea.y;
|
||||||
|
|
||||||
|
// Find child tables and create visual position changes
|
||||||
|
const childTables = tables.filter(
|
||||||
|
(table) => table.parentAreaId === areaChange.id
|
||||||
|
);
|
||||||
|
|
||||||
|
childTables.forEach((table) => {
|
||||||
|
additionalChanges.push({
|
||||||
|
id: table.id,
|
||||||
|
type: 'position',
|
||||||
|
position: {
|
||||||
|
x: table.x + deltaX,
|
||||||
|
y: table.y + deltaY,
|
||||||
|
},
|
||||||
|
dragging: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add visual changes to React Flow
|
||||||
|
changesToApply = [...changesToApply, ...additionalChanges];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle table changes - only update storage when NOT dragging
|
||||||
const { positionChanges, removeChanges, sizeChanges } =
|
const { positionChanges, removeChanges, sizeChanges } =
|
||||||
findRelevantNodesChanges(changesToApply, 'table');
|
findRelevantNodesChanges(changesToApply, 'table');
|
||||||
|
|
||||||
@@ -641,8 +750,9 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
removeChanges.length > 0 ||
|
removeChanges.length > 0 ||
|
||||||
sizeChanges.length > 0
|
sizeChanges.length > 0
|
||||||
) {
|
) {
|
||||||
updateTablesState((currentTables) =>
|
updateTablesState((currentTables) => {
|
||||||
currentTables
|
// First update positions
|
||||||
|
const updatedTables = currentTables
|
||||||
.map((currentTable) => {
|
.map((currentTable) => {
|
||||||
const positionChange = positionChanges.find(
|
const positionChange = positionChanges.find(
|
||||||
(change) => change.id === currentTable.id
|
(change) => change.id === currentTable.id
|
||||||
@@ -651,12 +761,19 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
(change) => change.id === currentTable.id
|
(change) => change.id === currentTable.id
|
||||||
);
|
);
|
||||||
if (positionChange || sizeChange) {
|
if (positionChange || sizeChange) {
|
||||||
|
const x = positionChange?.position?.x;
|
||||||
|
const y = positionChange?.position?.y;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: currentTable.id,
|
...currentTable,
|
||||||
...(positionChange
|
...(positionChange &&
|
||||||
|
x !== undefined &&
|
||||||
|
y !== undefined &&
|
||||||
|
!isNaN(x) &&
|
||||||
|
!isNaN(y)
|
||||||
? {
|
? {
|
||||||
x: positionChange.position?.x,
|
x,
|
||||||
y: positionChange.position?.y,
|
y,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(sizeChange
|
...(sizeChange
|
||||||
@@ -676,8 +793,10 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
!removeChanges.some(
|
!removeChanges.some(
|
||||||
(change) => change.id === table.id
|
(change) => change.id === table.id
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
);
|
|
||||||
|
return updatedTables;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOverlappingGraphOnChangesDebounced({
|
updateOverlappingGraphOnChangesDebounced({
|
||||||
@@ -697,29 +816,85 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
areaRemoveChanges.length > 0 ||
|
areaRemoveChanges.length > 0 ||
|
||||||
areaSizeChanges.length > 0
|
areaSizeChanges.length > 0
|
||||||
) {
|
) {
|
||||||
[...areaPositionChanges, ...areaSizeChanges].forEach(
|
const areasUpdates: Record<string, Partial<Area>> = {};
|
||||||
(change) => {
|
// Handle area position changes and move child tables (only when drag ends)
|
||||||
const updateData: Partial<Area> = {};
|
areaPositionChanges.forEach((change) => {
|
||||||
|
if (change.type === 'position' && change.position) {
|
||||||
|
areasUpdates[change.id] = {
|
||||||
|
...areasUpdates[change.id],
|
||||||
|
x: change.position.x,
|
||||||
|
y: change.position.y,
|
||||||
|
};
|
||||||
|
|
||||||
if (change.type === 'position') {
|
if (areaSizeChanges.length !== 0) {
|
||||||
updateData.x = change.position?.x;
|
// If there are size changes, we don't need to move child tables
|
||||||
updateData.y = change.position?.y;
|
return;
|
||||||
}
|
}
|
||||||
|
const currentArea = areas.find(
|
||||||
|
(a) => a.id === change.id
|
||||||
|
);
|
||||||
|
if (currentArea) {
|
||||||
|
const deltaX = change.position.x - currentArea.x;
|
||||||
|
const deltaY = change.position.y - currentArea.y;
|
||||||
|
|
||||||
if (change.type === 'dimensions') {
|
const childTables = getTablesInArea(
|
||||||
updateData.width = change.dimensions?.width;
|
change.id,
|
||||||
updateData.height = change.dimensions?.height;
|
tables
|
||||||
}
|
);
|
||||||
|
|
||||||
if (Object.keys(updateData).length > 0) {
|
// Update child table positions in storage
|
||||||
updateArea(change.id, updateData);
|
if (childTables.length > 0) {
|
||||||
|
updateTablesState((currentTables) =>
|
||||||
|
currentTables.map((table) => {
|
||||||
|
if (table.parentAreaId === change.id) {
|
||||||
|
return {
|
||||||
|
id: table.id,
|
||||||
|
x: table.x + deltaX,
|
||||||
|
y: table.y + deltaY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
|
||||||
|
// Handle area size changes
|
||||||
|
areaSizeChanges.forEach((change) => {
|
||||||
|
if (change.type === 'dimensions' && change.dimensions) {
|
||||||
|
areasUpdates[change.id] = {
|
||||||
|
...areasUpdates[change.id],
|
||||||
|
width: change.dimensions.width,
|
||||||
|
height: change.dimensions.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
areaRemoveChanges.forEach((change) => {
|
areaRemoveChanges.forEach((change) => {
|
||||||
|
updateTablesState((currentTables) =>
|
||||||
|
currentTables.map((table) => {
|
||||||
|
if (table.parentAreaId === change.id) {
|
||||||
|
return {
|
||||||
|
...table,
|
||||||
|
parentAreaId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
})
|
||||||
|
);
|
||||||
removeArea(change.id);
|
removeArea(change.id);
|
||||||
|
|
||||||
|
delete areasUpdates[change.id];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Apply area updates to storage
|
||||||
|
if (Object.keys(areasUpdates).length > 0) {
|
||||||
|
for (const [id, updates] of Object.entries(areasUpdates)) {
|
||||||
|
updateArea(id, updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return onNodesChange(changesToApply);
|
return onNodesChange(changesToApply);
|
||||||
@@ -732,6 +907,9 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
|||||||
updateArea,
|
updateArea,
|
||||||
removeArea,
|
removeArea,
|
||||||
readonly,
|
readonly,
|
||||||
|
tables,
|
||||||
|
areas,
|
||||||
|
getNode,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user