Compare commits

...

3 Commits

Author SHA1 Message Date
johnnyfish
e90887e9ee fix: prevent hidden elements from interacting during drag operations 2025-08-07 16:32:32 +03:00
johnnyfish
ad1e59bdd2 fix build 2025-08-07 16:16:23 +03:00
johnnyfish
6282a555bb feat: auto-hide/show areas based on table visibility in canvas filter 2025-08-07 16:05:57 +03:00
3 changed files with 520 additions and 95 deletions

View File

@@ -1,6 +1,10 @@
import type { DBTable } from '@/lib/domain/db-table';
import type { Area } from '@/lib/domain/area';
import { calcTableHeight } from '@/lib/domain/db-table';
import type { DiagramFilter } from '@/lib/domain/diagram-filter/diagram-filter';
import { filterTable } from '@/lib/domain/diagram-filter/filter';
import { defaultSchemas } from '@/lib/data/default-schemas';
import { DatabaseType } from '@/lib/domain/database-type';
/**
* Check if a table is inside an area based on their positions and dimensions
@@ -30,16 +34,54 @@ const isTableInsideArea = (table: DBTable, area: Area): boolean => {
);
};
/**
* Check if an area is visible based on its tables
*/
const isAreaVisible = (
area: Area,
tables: DBTable[],
filter?: DiagramFilter,
databaseType?: DatabaseType
): boolean => {
const tablesInArea = tables.filter((t) => t.parentAreaId === area.id);
// If area has no tables, consider it visible
if (tablesInArea.length === 0) return true;
// Area is visible if at least one table in it is visible
return tablesInArea.some((table) =>
filterTable({
table: { id: table.id, schema: table.schema },
filter,
options: {
defaultSchema:
defaultSchemas[databaseType || DatabaseType.GENERIC],
},
})
);
};
/**
* Find which area contains a table
*/
const findContainingArea = (table: DBTable, areas: Area[]): Area | null => {
const findContainingArea = (
table: DBTable,
areas: Area[],
tables: DBTable[],
filter?: DiagramFilter,
databaseType?: DatabaseType
): 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) {
// Skip hidden areas - they shouldn't capture tables
if (!isAreaVisible(area, tables, filter, databaseType)) {
continue;
}
if (isTableInsideArea(table, area)) {
return area;
}
@@ -53,10 +95,33 @@ const findContainingArea = (table: DBTable, areas: Area[]): Area | null => {
*/
export const updateTablesParentAreas = (
tables: DBTable[],
areas: Area[]
areas: Area[],
filter?: DiagramFilter,
databaseType?: DatabaseType
): DBTable[] => {
return tables.map((table) => {
const containingArea = findContainingArea(table, areas);
// Skip hidden tables - they shouldn't be assigned to areas
const isTableVisible = filterTable({
table: { id: table.id, schema: table.schema },
filter,
options: {
defaultSchema:
defaultSchemas[databaseType || DatabaseType.GENERIC],
},
});
if (!isTableVisible) {
// Hidden tables keep their current parent area (don't change)
return table;
}
const containingArea = findContainingArea(
table,
areas,
tables,
filter,
databaseType
);
const newParentAreaId = containingArea?.id || null;
// Only update if parentAreaId has changed
@@ -80,3 +145,26 @@ export const getTablesInArea = (
): DBTable[] => {
return tables.filter((table) => table.parentAreaId === areaId);
};
/**
* Get visible tables that are inside a specific area
*/
export const getVisibleTablesInArea = (
areaId: string,
tables: DBTable[],
filter?: DiagramFilter,
databaseType?: DatabaseType
): DBTable[] => {
return tables.filter((table) => {
if (table.parentAreaId !== areaId) return false;
return filterTable({
table: { id: table.id, schema: table.schema },
filter,
options: {
defaultSchema:
defaultSchemas[databaseType || DatabaseType.GENERIC],
},
});
});
};

View File

@@ -5,8 +5,19 @@ import React, {
useEffect,
useRef,
} from 'react';
import { X, Search, Eye, EyeOff, Database, Table, Funnel } from 'lucide-react';
import {
X,
Search,
Eye,
EyeOff,
Database,
Table,
Funnel,
Layers,
Box,
} from 'lucide-react';
import { useChartDB } from '@/hooks/use-chartdb';
import type { DBTable } from '@/lib/domain/db-table';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/button/button';
import { Input } from '@/components/input/input';
@@ -18,14 +29,17 @@ import type { TreeNode } from '@/components/tree-view/tree';
import { ScrollArea } from '@/components/scroll-area/scroll-area';
import { filterSchema, filterTable } from '@/lib/domain/diagram-filter/filter';
import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter';
import { ToggleGroup, ToggleGroupItem } from '@/components/toggle/toggle-group';
export interface CanvasFilterProps {
onClose: () => void;
}
type NodeType = 'schema' | 'table';
type NodeType = 'schema' | 'area' | 'table';
type GroupingMode = 'schema' | 'area';
type SchemaContext = { name: string; visible: boolean };
type AreaContext = { id: string; name: string; visible: boolean };
type TableContext = {
tableSchema?: string | null;
visible: boolean;
@@ -33,6 +47,7 @@ type TableContext = {
type NodeContext = {
schema: SchemaContext;
area: AreaContext;
table: TableContext;
};
@@ -44,7 +59,7 @@ type RelevantTableData = {
export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
const { t } = useTranslation();
const { tables, databaseType } = useChartDB();
const { tables, databaseType, areas } = useChartDB();
const {
filter,
toggleSchemaFilter,
@@ -56,6 +71,7 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
const [searchQuery, setSearchQuery] = useState('');
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [isFilterVisible, setIsFilterVisible] = useState(false);
const [groupingMode, setGroupingMode] = useState<GroupingMode>('schema');
const searchInputRef = useRef<HTMLInputElement>(null);
// Extract only the properties needed for tree data
@@ -76,40 +92,99 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
// Convert tables to tree nodes
const treeData = useMemo(() => {
// Group tables by schema
const tablesBySchema = new Map<string, RelevantTableData[]>();
relevantTableData.forEach((table) => {
const schema = !databaseWithSchemas
? 'All Tables'
: (table.schema ?? defaultSchemas[databaseType] ?? 'default');
if (!tablesBySchema.has(schema)) {
tablesBySchema.set(schema, []);
}
tablesBySchema.get(schema)!.push(table);
});
// Sort tables within each schema
tablesBySchema.forEach((tables) => {
tables.sort((a, b) => a.name.localeCompare(b.name));
});
// Convert to tree nodes
const nodes: TreeNode<NodeType, NodeContext>[] = [];
tablesBySchema.forEach((schemaTables, schemaName) => {
let schemaVisible;
if (groupingMode === 'area') {
// Group tables by area
const tablesByArea = new Map<string | null, DBTable[]>();
const tablesWithoutArea: DBTable[] = [];
if (databaseWithSchemas) {
const schemaId = schemaNameToSchemaId(schemaName);
schemaVisible = filterSchema({
schemaId,
schemaIdsFilter: filter?.schemaIds,
});
} else {
// if at least one table is visible, the schema is considered visible
schemaVisible = schemaTables.some((table) =>
tables.forEach((table) => {
if (table.parentAreaId) {
if (!tablesByArea.has(table.parentAreaId)) {
tablesByArea.set(table.parentAreaId, []);
}
tablesByArea.get(table.parentAreaId)!.push(table);
} else {
tablesWithoutArea.push(table);
}
});
// Sort tables within each area
tablesByArea.forEach((areaTables) => {
areaTables.sort((a, b) => a.name.localeCompare(b.name));
});
tablesWithoutArea.sort((a, b) => a.name.localeCompare(b.name));
// Create nodes for areas
areas.forEach((area) => {
const areaTables = tablesByArea.get(area.id) || [];
// Check if at least one table in the area is visible
const areaVisible =
areaTables.length === 0 ||
areaTables.some((table) =>
filterTable({
table: {
id: table.id,
schema: table.schema,
},
filter,
options: {
defaultSchema: defaultSchemas[databaseType],
},
})
);
const areaNode: TreeNode<NodeType, NodeContext> = {
id: `area-${area.id}`,
name: `${area.name} (${areaTables.length})`,
type: 'area',
isFolder: true,
icon: Box,
context: {
id: area.id,
name: area.name,
visible: areaVisible,
} as AreaContext,
className: !areaVisible ? 'opacity-50' : '',
children: areaTables.map(
(table): TreeNode<NodeType, NodeContext> => {
const tableVisible = filterTable({
table: {
id: table.id,
schema: table.schema,
},
filter,
options: {
defaultSchema: defaultSchemas[databaseType],
},
});
return {
id: table.id,
name: table.name,
type: 'table',
isFolder: false,
icon: Table,
context: {
tableSchema: table.schema,
visible: tableVisible,
} as TableContext,
className: !tableVisible ? 'opacity-50' : '',
};
}
),
};
if (areaTables.length > 0) {
nodes.push(areaNode);
}
});
// Add ungrouped tables
if (tablesWithoutArea.length > 0) {
const ungroupedVisible = tablesWithoutArea.some((table) =>
filterTable({
table: {
id: table.id,
@@ -121,19 +196,84 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
},
})
);
}
const schemaNode: TreeNode<NodeType, NodeContext> = {
id: `schema-${schemaName}`,
name: `${schemaName} (${schemaTables.length})`,
type: 'schema',
isFolder: true,
icon: Database,
context: { name: schemaName, visible: schemaVisible },
className: !schemaVisible ? 'opacity-50' : '',
children: schemaTables.map(
(table): TreeNode<NodeType, NodeContext> => {
const tableVisible = filterTable({
const ungroupedNode: TreeNode<NodeType, NodeContext> = {
id: 'ungrouped',
name: `Ungrouped (${tablesWithoutArea.length})`,
type: 'area',
isFolder: true,
icon: Layers,
context: {
id: 'ungrouped',
name: 'Ungrouped',
visible: ungroupedVisible,
} as AreaContext,
className: !ungroupedVisible ? 'opacity-50' : '',
children: tablesWithoutArea.map(
(table): TreeNode<NodeType, NodeContext> => {
const tableVisible = filterTable({
table: {
id: table.id,
schema: table.schema,
},
filter,
options: {
defaultSchema: defaultSchemas[databaseType],
},
});
return {
id: table.id,
name: table.name,
type: 'table',
isFolder: false,
icon: Table,
context: {
tableSchema: table.schema,
visible: tableVisible,
} as TableContext,
className: !tableVisible ? 'opacity-50' : '',
};
}
),
};
nodes.push(ungroupedNode);
}
} else {
// Group tables by schema (existing logic)
const tablesBySchema = new Map<string, RelevantTableData[]>();
relevantTableData.forEach((table) => {
const schema = !databaseWithSchemas
? 'All Tables'
: (table.schema ??
defaultSchemas[databaseType] ??
'default');
if (!tablesBySchema.has(schema)) {
tablesBySchema.set(schema, []);
}
tablesBySchema.get(schema)!.push(table);
});
// Sort tables within each schema
tablesBySchema.forEach((tables) => {
tables.sort((a, b) => a.name.localeCompare(b.name));
});
tablesBySchema.forEach((schemaTables, schemaName) => {
let schemaVisible;
if (databaseWithSchemas) {
const schemaId = schemaNameToSchemaId(schemaName);
schemaVisible = filterSchema({
schemaId,
schemaIdsFilter: filter?.schemaIds,
});
} else {
// if at least one table is visible, the schema is considered visible
schemaVisible = schemaTables.some((table) =>
filterTable({
table: {
id: table.id,
schema: table.schema,
@@ -142,30 +282,65 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
options: {
defaultSchema: defaultSchemas[databaseType],
},
});
})
);
}
const hidden = !tableVisible;
const schemaNode: TreeNode<NodeType, NodeContext> = {
id: `schema-${schemaName}`,
name: `${schemaName} (${schemaTables.length})`,
type: 'schema',
isFolder: true,
icon: Database,
context: {
name: schemaName,
visible: schemaVisible,
} as SchemaContext,
className: !schemaVisible ? 'opacity-50' : '',
children: schemaTables.map(
(table): TreeNode<NodeType, NodeContext> => {
const tableVisible = filterTable({
table: {
id: table.id,
schema: table.schema,
},
filter,
options: {
defaultSchema: defaultSchemas[databaseType],
},
});
return {
id: table.id,
name: table.name,
type: 'table',
isFolder: false,
icon: Table,
context: {
tableSchema: table.schema,
visible: tableVisible,
},
className: hidden ? 'opacity-50' : '',
};
}
),
};
nodes.push(schemaNode);
});
const hidden = !tableVisible;
return {
id: table.id,
name: table.name,
type: 'table',
isFolder: false,
icon: Table,
context: {
tableSchema: table.schema,
visible: tableVisible,
} as TableContext,
className: hidden ? 'opacity-50' : '',
};
}
),
};
nodes.push(schemaNode);
});
}
return nodes;
}, [relevantTableData, databaseType, filter, databaseWithSchemas]);
}, [
relevantTableData,
tables,
databaseType,
filter,
databaseWithSchemas,
groupingMode,
areas,
]);
// Initialize expanded state with all schemas expanded
useMemo(() => {
@@ -240,9 +415,12 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
const renderActions = useCallback(
(node: TreeNode<NodeType, NodeContext>) => {
if (node.type === 'schema') {
const schemaContext = node.context as SchemaContext;
const schemaId = schemaNameToSchemaId(schemaContext.name);
const schemaVisible = node.context.visible;
const context = node.context as SchemaContext;
const schemaVisible = context.visible;
const schemaName = context.name;
if (!schemaName) return null;
const schemaId = schemaNameToSchemaId(schemaName);
return (
<Button
@@ -256,7 +434,7 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
toggleSchemaFilter(schemaId);
} else {
// Toggle visibility of all tables in this schema
if (node.context.visible) {
if (schemaVisible) {
setTableIdsFilterEmpty();
} else {
clearTableIdsFilter();
@@ -273,10 +451,83 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
);
}
if (node.type === 'area') {
const context = node.context as AreaContext;
const areaVisible = context.visible;
const areaId = context.id;
if (!areaId) return null;
// Get all tables in this area
const areaTables =
areaId === 'ungrouped'
? tables.filter((t) => !t.parentAreaId)
: tables.filter((t) => t.parentAreaId === areaId);
const tableIds = areaTables.map((t) => t.id);
return (
<Button
variant="ghost"
size="sm"
className="size-7 h-fit p-0"
onClick={(e) => {
e.stopPropagation();
// Toggle all tables in this area
if (areaVisible) {
// Hide all tables in this area
tableIds.forEach((id) => {
const isVisible = filterTable({
table: {
id,
schema: tables.find(
(t) => t.id === id
)?.schema,
},
filter,
options: {
defaultSchema:
defaultSchemas[databaseType],
},
});
if (isVisible) {
toggleTableFilter(id);
}
});
} else {
// Show all tables in this area
tableIds.forEach((id) => {
const isVisible = filterTable({
table: {
id,
schema: tables.find(
(t) => t.id === id
)?.schema,
},
filter,
options: {
defaultSchema:
defaultSchemas[databaseType],
},
});
if (!isVisible) {
toggleTableFilter(id);
}
});
}
}}
>
{!areaVisible ? (
<EyeOff className="size-3.5 text-muted-foreground" />
) : (
<Eye className="size-3.5" />
)}
</Button>
);
}
if (node.type === 'table') {
const tableId = node.id;
const tableContext = node.context as TableContext;
const tableVisible = tableContext.visible;
const context = node.context as TableContext;
const tableVisible = context.visible;
return (
<Button
@@ -305,6 +556,9 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
clearTableIdsFilter,
setTableIdsFilterEmpty,
databaseWithSchemas,
tables,
filter,
databaseType,
]
);
@@ -312,10 +566,10 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
const handleNodeClick = useCallback(
(node: TreeNode<NodeType, NodeContext>) => {
if (node.type === 'table') {
const tableContext = node.context as TableContext;
const isTableVisible = tableContext.visible;
const context = node.context as TableContext;
const isTableVisible = context.visible;
// Only focus if neither table is hidden nor filtered by schema
// Only focus if table is visible
if (isTableVisible) {
focusOnTable(node.id);
}
@@ -376,13 +630,34 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
</div>
</div>
{/* Grouping Toggle */}
<div className="border-b p-2">
<ToggleGroup
type="single"
value={groupingMode}
onValueChange={(value) => {
if (value) setGroupingMode(value as GroupingMode);
}}
className="w-full"
>
<ToggleGroupItem value="schema" className="flex-1 text-xs">
<Database className="mr-1.5 size-3.5" />
{t('canvas_filter.group_by_schema', 'Group by Schema')}
</ToggleGroupItem>
<ToggleGroupItem value="area" className="flex-1 text-xs">
<Box className="mr-1.5 size-3.5" />
{t('canvas_filter.group_by_area', 'Group by Area')}
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Table Tree */}
<ScrollArea className="flex-1 rounded-b-lg" type="auto">
<TreeView
data={filteredTreeData}
onNodeClick={handleNodeClick}
renderActionsComponent={renderActions}
defaultFolderIcon={Database}
defaultFolderIcon={groupingMode === 'area' ? Box : Database}
defaultIcon={Table}
expanded={expanded}
setExpanded={setExpanded}

View File

@@ -86,7 +86,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';
import { updateTablesParentAreas, getVisibleTablesInArea } from './area-utils';
import { CanvasFilter } from './canvas-filter/canvas-filter';
import { useHotkeys } from 'react-hotkeys-hook';
import { ShowAllButton } from './show-all-button';
@@ -142,15 +142,40 @@ const tableToTableNode = (
};
};
const areaToAreaNode = (area: Area): AreaNodeType => ({
id: area.id,
type: 'area',
position: { x: area.x, y: area.y },
data: { area },
width: area.width,
height: area.height,
zIndex: -10,
});
const areaToAreaNode = (
area: Area,
tables: DBTable[],
filter?: DiagramFilter,
databaseType?: DatabaseType
): AreaNodeType => {
// Get all tables in this area
const tablesInArea = tables.filter((t) => t.parentAreaId === area.id);
// Check if at least one table in the area is visible
const hasVisibleTable =
tablesInArea.length === 0 ||
tablesInArea.some((table) =>
filterTable({
table: { id: table.id, schema: table.schema },
filter,
options: {
defaultSchema:
defaultSchemas[databaseType || DatabaseType.GENERIC],
},
})
);
return {
id: area.id,
type: 'area',
position: { x: area.x, y: area.y },
data: { area },
width: area.width,
height: area.height,
zIndex: -10,
hidden: !hasVisibleTable,
};
};
export interface CanvasProps {
initialTables: DBTable[];
@@ -409,7 +434,9 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
},
};
}),
...areas.map(areaToAreaNode),
...areas.map((area) =>
areaToAreaNode(area, tables, filter, databaseType)
),
];
// Check if nodes actually changed
@@ -468,7 +495,12 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
useEffect(() => {
const checkParentAreas = debounce(() => {
const updatedTables = updateTablesParentAreas(tables, areas);
const updatedTables = updateTablesParentAreas(
tables,
areas,
filter,
databaseType
);
const needsUpdate: Array<{
id: string;
parentAreaId: string | null;
@@ -509,7 +541,14 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
}, 300);
checkParentAreas();
}, [tablePositions, areas, updateTablesState, tables]);
}, [
tablePositions,
areas,
updateTablesState,
tables,
filter,
databaseType,
]);
const onConnectHandler = useCallback(
async (params: AddEdgeParams) => {
@@ -888,16 +927,37 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
const deltaX = change.position.x - currentArea.x;
const deltaY = change.position.y - currentArea.y;
const childTables = getTablesInArea(
// Only move visible child tables
const childTables = getVisibleTablesInArea(
change.id,
tables
tables,
filter,
databaseType
);
// Update child table positions in storage
if (childTables.length > 0) {
updateTablesState((currentTables) =>
currentTables.map((table) => {
if (table.parentAreaId === change.id) {
// Only move visible tables that are in this area
const isVisible = filterTable({
table: {
id: table.id,
schema: table.schema,
},
filter,
options: {
defaultSchema:
defaultSchemas[
databaseType
],
},
});
if (
table.parentAreaId === change.id &&
isVisible
) {
return {
id: table.id,
x: table.x + deltaX,
@@ -961,6 +1021,8 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
tables,
areas,
getNode,
databaseType,
filter,
]
);