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:
Jonathan Fishner
2025-07-16 15:11:16 +03:00
committed by GitHub
parent bf32c08d37
commit b35e17526b
4 changed files with 302 additions and 38 deletions

View File

@@ -47,6 +47,7 @@ export interface DBTable {
comments?: string | null;
order?: number | null;
expanded?: boolean | null;
parentAreaId?: string | null;
}
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(),
order: z.number().or(z.null()).optional(),
expanded: z.boolean().or(z.null()).optional(),
parentAreaId: z.string().or(z.null()).optional(),
});
export const shouldShowTablesBySchemaFilter = (

View File

@@ -80,11 +80,13 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo(
<NodeResizer
isVisible={focused}
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="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 ? (
<div className="flex w-full items-center">

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

View File

@@ -83,6 +83,7 @@ import { useCanvas } from '@/hooks/use-canvas';
import type { AreaNodeType } from './area-node/area-node';
import { AreaNode } from './area-node/area-node';
import type { Area } from '@/lib/domain/area';
import { updateTablesParentAreas, getTablesInArea } from './area-utils';
const HIGHLIGHTED_EDGE_Z_INDEX = 1;
const DEFAULT_EDGE_Z_INDEX = 0;
@@ -108,17 +109,22 @@ const initialEdges: EdgeType[] = [];
const tableToTableNode = (
table: DBTable,
filteredSchemas?: string[]
): TableNodeType => ({
): TableNodeType => {
// Always use absolute position for now
const position = { x: table.x, y: table.y };
return {
id: table.id,
type: 'table',
position: { x: table.x, y: table.y },
position,
data: {
table,
isOverlapping: false,
},
width: table.width ?? MIN_TABLE_SIZE,
hidden: !shouldShowTablesBySchemaFilter(table, filteredSchemas),
});
};
};
const areaToAreaNode = (area: Area): AreaNodeType => ({
id: area.id,
@@ -406,6 +412,54 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
}
}, [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(
async (params: AddEdgeParams) => {
if (
@@ -581,8 +635,13 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
(changes: NodeChange<NodeType>[], type: NodeType['type']) => {
const relevantChanges = changes.filter((change) => {
if (
change.type === 'position' ||
change.type === 'dimensions' ||
(change.type === 'position' &&
!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'
) {
const node = getNode(change.id);
@@ -602,7 +661,13 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
const positionChanges: NodePositionChange[] =
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[];
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 } =
findRelevantNodesChanges(changesToApply, 'table');
@@ -641,8 +750,9 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
removeChanges.length > 0 ||
sizeChanges.length > 0
) {
updateTablesState((currentTables) =>
currentTables
updateTablesState((currentTables) => {
// First update positions
const updatedTables = currentTables
.map((currentTable) => {
const positionChange = positionChanges.find(
(change) => change.id === currentTable.id
@@ -651,12 +761,19 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
(change) => change.id === currentTable.id
);
if (positionChange || sizeChange) {
const x = positionChange?.position?.x;
const y = positionChange?.position?.y;
return {
id: currentTable.id,
...(positionChange
...currentTable,
...(positionChange &&
x !== undefined &&
y !== undefined &&
!isNaN(x) &&
!isNaN(y)
? {
x: positionChange.position?.x,
y: positionChange.position?.y,
x,
y,
}
: {}),
...(sizeChange
@@ -676,8 +793,10 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
!removeChanges.some(
(change) => change.id === table.id
)
)
);
return updatedTables;
});
}
updateOverlappingGraphOnChangesDebounced({
@@ -697,29 +816,85 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
areaRemoveChanges.length > 0 ||
areaSizeChanges.length > 0
) {
[...areaPositionChanges, ...areaSizeChanges].forEach(
(change) => {
const updateData: Partial<Area> = {};
const areasUpdates: Record<string, Partial<Area>> = {};
// Handle area position changes and move child tables (only when drag ends)
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') {
updateData.x = change.position?.x;
updateData.y = change.position?.y;
if (areaSizeChanges.length !== 0) {
// If there are size changes, we don't need to move child tables
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') {
updateData.width = change.dimensions?.width;
updateData.height = change.dimensions?.height;
}
if (Object.keys(updateData).length > 0) {
updateArea(change.id, updateData);
}
}
const childTables = getTablesInArea(
change.id,
tables
);
areaRemoveChanges.forEach((change) => {
removeArea(change.id);
// Update child table positions in storage
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) => {
updateTablesState((currentTables) =>
currentTables.map((table) => {
if (table.parentAreaId === change.id) {
return {
...table,
parentAreaId: null,
};
}
return table;
})
);
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);
@@ -732,6 +907,9 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
updateArea,
removeArea,
readonly,
tables,
areas,
getNode,
]
);