mirror of
				https://github.com/chartdb/chartdb.git
				synced 2025-11-04 05:53:15 +00:00 
			
		
		
		
	Compare commits
	
		
			4 Commits
		
	
	
		
			jf/fix_err
			...
			feat/table
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					8874cb552d | ||
| 
						 | 
					32b2c2fa7a | ||
| 
						 | 
					63e8c82b24 | ||
| 
						 | 
					06cb0b5161 | 
@@ -127,6 +127,10 @@ export const en = {
 | 
			
		||||
                no_results: 'No tables found matching your filter.',
 | 
			
		||||
                show_list: 'Show Table List',
 | 
			
		||||
                show_dbml: 'Show DBML Editor',
 | 
			
		||||
                default_grouping: 'Default View',
 | 
			
		||||
                group_by_schema: 'Group by Schema',
 | 
			
		||||
                group_by_area: 'Group by Area',
 | 
			
		||||
                no_area: 'No Area',
 | 
			
		||||
 | 
			
		||||
                table: {
 | 
			
		||||
                    fields: 'Fields',
 | 
			
		||||
@@ -262,6 +266,15 @@ export const en = {
 | 
			
		||||
            },
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        canvas_filter: {
 | 
			
		||||
            title: 'Filter Tables',
 | 
			
		||||
            search_placeholder: 'Search tables...',
 | 
			
		||||
            default_grouping: 'Default View',
 | 
			
		||||
            group_by_schema: 'Group by Schema',
 | 
			
		||||
            group_by_area: 'Group by Area',
 | 
			
		||||
            no_area: 'No Area',
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        toolbar: {
 | 
			
		||||
            zoom_in: 'Zoom In',
 | 
			
		||||
            zoom_out: 'Zoom Out',
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,9 @@
 | 
			
		||||
import type { DBTable } from '@/lib/domain/db-table';
 | 
			
		||||
import type { Area } from '@/lib/domain/area';
 | 
			
		||||
import { calcTableHeight } from '@/lib/domain/db-table';
 | 
			
		||||
import {
 | 
			
		||||
    calcTableHeight,
 | 
			
		||||
    shouldShowTablesBySchemaFilter,
 | 
			
		||||
} from '@/lib/domain/db-table';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if a table is inside an area based on their positions and dimensions
 | 
			
		||||
@@ -53,9 +56,31 @@ const findContainingArea = (table: DBTable, areas: Area[]): Area | null => {
 | 
			
		||||
 */
 | 
			
		||||
export const updateTablesParentAreas = (
 | 
			
		||||
    tables: DBTable[],
 | 
			
		||||
    areas: Area[]
 | 
			
		||||
    areas: Area[],
 | 
			
		||||
    hiddenTableIds?: string[],
 | 
			
		||||
    filteredSchemas?: string[]
 | 
			
		||||
): DBTable[] => {
 | 
			
		||||
    return tables.map((table) => {
 | 
			
		||||
        // Check if table is hidden by direct hiding or schema filter
 | 
			
		||||
        const isHiddenDirectly = hiddenTableIds?.includes(table.id) ?? false;
 | 
			
		||||
        const isHiddenBySchema = !shouldShowTablesBySchemaFilter(
 | 
			
		||||
            table,
 | 
			
		||||
            filteredSchemas
 | 
			
		||||
        );
 | 
			
		||||
        const isHidden = isHiddenDirectly || isHiddenBySchema;
 | 
			
		||||
 | 
			
		||||
        // If table is hidden, remove it from any area
 | 
			
		||||
        if (isHidden) {
 | 
			
		||||
            if (table.parentAreaId !== null) {
 | 
			
		||||
                return {
 | 
			
		||||
                    ...table,
 | 
			
		||||
                    parentAreaId: null,
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
            return table;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // For visible tables, find containing area as before
 | 
			
		||||
        const containingArea = findContainingArea(table, areas);
 | 
			
		||||
        const newParentAreaId = containingArea?.id || null;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,26 +5,40 @@ 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,
 | 
			
		||||
} from 'lucide-react';
 | 
			
		||||
import { useChartDB } from '@/hooks/use-chartdb';
 | 
			
		||||
import { useTranslation } from 'react-i18next';
 | 
			
		||||
import { Button } from '@/components/button/button';
 | 
			
		||||
import { Input } from '@/components/input/input';
 | 
			
		||||
import { shouldShowTableSchemaBySchemaFilter } from '@/lib/domain/db-table';
 | 
			
		||||
import { schemaNameToSchemaId } from '@/lib/domain/db-schema';
 | 
			
		||||
import {
 | 
			
		||||
    schemaNameToSchemaId,
 | 
			
		||||
    databasesWithSchemas,
 | 
			
		||||
} from '@/lib/domain/db-schema';
 | 
			
		||||
import { defaultSchemas } from '@/lib/data/default-schemas';
 | 
			
		||||
import { useReactFlow } from '@xyflow/react';
 | 
			
		||||
import { TreeView } from '@/components/tree-view/tree-view';
 | 
			
		||||
import type { TreeNode } from '@/components/tree-view/tree';
 | 
			
		||||
import { ScrollArea } from '@/components/scroll-area/scroll-area';
 | 
			
		||||
import { ToggleGroup, ToggleGroupItem } from '@/components/toggle/toggle-group';
 | 
			
		||||
 | 
			
		||||
export interface CanvasFilterProps {
 | 
			
		||||
    onClose: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type NodeType = 'schema' | 'table';
 | 
			
		||||
type NodeType = 'schema' | 'table' | 'area';
 | 
			
		||||
 | 
			
		||||
type SchemaContext = { name: string };
 | 
			
		||||
type AreaContext = { id: string; name: string };
 | 
			
		||||
type TableContext = {
 | 
			
		||||
    tableSchema?: string | null;
 | 
			
		||||
    hidden: boolean;
 | 
			
		||||
@@ -32,6 +46,7 @@ type TableContext = {
 | 
			
		||||
 | 
			
		||||
type NodeContext = {
 | 
			
		||||
    schema: SchemaContext;
 | 
			
		||||
    area: AreaContext;
 | 
			
		||||
    table: TableContext;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@@ -51,12 +66,19 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
 | 
			
		||||
        removeHiddenTableId,
 | 
			
		||||
        filteredSchemas,
 | 
			
		||||
        filterSchemas,
 | 
			
		||||
        areas,
 | 
			
		||||
    } = useChartDB();
 | 
			
		||||
    const { fitView, setNodes } = useReactFlow();
 | 
			
		||||
    const [searchQuery, setSearchQuery] = useState('');
 | 
			
		||||
    const [expanded, setExpanded] = useState<Record<string, boolean>>({});
 | 
			
		||||
    const [isFilterVisible, setIsFilterVisible] = useState(false);
 | 
			
		||||
    const [groupBy, setGroupBy] = useState<'schema' | 'area'>('schema');
 | 
			
		||||
    const searchInputRef = useRef<HTMLInputElement>(null);
 | 
			
		||||
    const supportsSchemas = useMemo(
 | 
			
		||||
        () => databasesWithSchemas.includes(databaseType),
 | 
			
		||||
        [databaseType]
 | 
			
		||||
    );
 | 
			
		||||
    const hasAreas = useMemo(() => areas.length > 0, [areas]);
 | 
			
		||||
 | 
			
		||||
    // Extract only the properties needed for tree data
 | 
			
		||||
    const relevantTableData = useMemo<RelevantTableData[]>(
 | 
			
		||||
@@ -71,6 +93,137 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
 | 
			
		||||
 | 
			
		||||
    // Convert tables to tree nodes
 | 
			
		||||
    const treeData = useMemo(() => {
 | 
			
		||||
        if (groupBy === 'area' && hasAreas) {
 | 
			
		||||
            // Group tables by area
 | 
			
		||||
            const tablesByArea = new Map<string, RelevantTableData[]>();
 | 
			
		||||
            const tablesWithoutArea: RelevantTableData[] = [];
 | 
			
		||||
 | 
			
		||||
            // Create a map of area id to area
 | 
			
		||||
            const areaMap = areas.reduce(
 | 
			
		||||
                (acc, area) => {
 | 
			
		||||
                    acc[area.id] = area;
 | 
			
		||||
                    return acc;
 | 
			
		||||
                },
 | 
			
		||||
                {} as Record<string, (typeof areas)[0]>
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            relevantTableData.forEach((table) => {
 | 
			
		||||
                const tableData = tables.find((t) => t.id === table.id);
 | 
			
		||||
                if (
 | 
			
		||||
                    tableData?.parentAreaId &&
 | 
			
		||||
                    areaMap[tableData.parentAreaId]
 | 
			
		||||
                ) {
 | 
			
		||||
                    const areaId = tableData.parentAreaId;
 | 
			
		||||
                    if (!tablesByArea.has(areaId)) {
 | 
			
		||||
                        tablesByArea.set(areaId, []);
 | 
			
		||||
                    }
 | 
			
		||||
                    tablesByArea.get(areaId)!.push(table);
 | 
			
		||||
                } else {
 | 
			
		||||
                    tablesWithoutArea.push(table);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // Sort tables within each area
 | 
			
		||||
            tablesByArea.forEach((tables) => {
 | 
			
		||||
                tables.sort((a, b) => a.name.localeCompare(b.name));
 | 
			
		||||
            });
 | 
			
		||||
            tablesWithoutArea.sort((a, b) => a.name.localeCompare(b.name));
 | 
			
		||||
 | 
			
		||||
            // Convert to tree nodes
 | 
			
		||||
            const nodes: TreeNode<NodeType, NodeContext>[] = [];
 | 
			
		||||
 | 
			
		||||
            // Sort all areas by order or name (including empty ones)
 | 
			
		||||
            const sortedAreas = areas.sort((a, b) => {
 | 
			
		||||
                if (a.order !== undefined && b.order !== undefined) {
 | 
			
		||||
                    return a.order - b.order;
 | 
			
		||||
                }
 | 
			
		||||
                return a.name.localeCompare(b.name);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            sortedAreas.forEach((area) => {
 | 
			
		||||
                const areaTables = tablesByArea.get(area.id) || [];
 | 
			
		||||
                const areaNode: TreeNode<NodeType, NodeContext> = {
 | 
			
		||||
                    id: `area-${area.id}`,
 | 
			
		||||
                    name: `${area.name} (${areaTables.length})`,
 | 
			
		||||
                    type: 'area',
 | 
			
		||||
                    isFolder: true,
 | 
			
		||||
                    icon: Layers,
 | 
			
		||||
                    context: { id: area.id, name: area.name },
 | 
			
		||||
                    children: areaTables.map(
 | 
			
		||||
                        (table): TreeNode<NodeType, NodeContext> => {
 | 
			
		||||
                            const tableHidden =
 | 
			
		||||
                                hiddenTableIds?.includes(table.id) ?? false;
 | 
			
		||||
                            const visibleBySchema =
 | 
			
		||||
                                shouldShowTableSchemaBySchemaFilter({
 | 
			
		||||
                                    tableSchema: table.schema,
 | 
			
		||||
                                    filteredSchemas,
 | 
			
		||||
                                });
 | 
			
		||||
                            const hidden = tableHidden || !visibleBySchema;
 | 
			
		||||
 | 
			
		||||
                            return {
 | 
			
		||||
                                id: table.id,
 | 
			
		||||
                                name: table.name,
 | 
			
		||||
                                type: 'table',
 | 
			
		||||
                                isFolder: false,
 | 
			
		||||
                                icon: Table,
 | 
			
		||||
                                context: {
 | 
			
		||||
                                    tableSchema: table.schema,
 | 
			
		||||
                                    hidden: tableHidden,
 | 
			
		||||
                                },
 | 
			
		||||
                                className: hidden ? 'opacity-50' : '',
 | 
			
		||||
                            };
 | 
			
		||||
                        }
 | 
			
		||||
                    ),
 | 
			
		||||
                };
 | 
			
		||||
                nodes.push(areaNode);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // Add "No Area" group if there are tables without areas
 | 
			
		||||
            if (tablesWithoutArea.length > 0) {
 | 
			
		||||
                const noAreaNode: TreeNode<NodeType, NodeContext> = {
 | 
			
		||||
                    id: 'area-no-area',
 | 
			
		||||
                    name: `${t('canvas_filter.no_area')} (${tablesWithoutArea.length})`,
 | 
			
		||||
                    type: 'area',
 | 
			
		||||
                    isFolder: true,
 | 
			
		||||
                    icon: Layers,
 | 
			
		||||
                    context: {
 | 
			
		||||
                        id: 'no-area',
 | 
			
		||||
                        name: t('canvas_filter.no_area'),
 | 
			
		||||
                    },
 | 
			
		||||
                    className: 'opacity-75',
 | 
			
		||||
                    children: tablesWithoutArea.map(
 | 
			
		||||
                        (table): TreeNode<NodeType, NodeContext> => {
 | 
			
		||||
                            const tableHidden =
 | 
			
		||||
                                hiddenTableIds?.includes(table.id) ?? false;
 | 
			
		||||
                            const visibleBySchema =
 | 
			
		||||
                                shouldShowTableSchemaBySchemaFilter({
 | 
			
		||||
                                    tableSchema: table.schema,
 | 
			
		||||
                                    filteredSchemas,
 | 
			
		||||
                                });
 | 
			
		||||
                            const hidden = tableHidden || !visibleBySchema;
 | 
			
		||||
 | 
			
		||||
                            return {
 | 
			
		||||
                                id: table.id,
 | 
			
		||||
                                name: table.name,
 | 
			
		||||
                                type: 'table',
 | 
			
		||||
                                isFolder: false,
 | 
			
		||||
                                icon: Table,
 | 
			
		||||
                                context: {
 | 
			
		||||
                                    tableSchema: table.schema,
 | 
			
		||||
                                    hidden: tableHidden,
 | 
			
		||||
                                },
 | 
			
		||||
                                className: hidden ? 'opacity-50' : '',
 | 
			
		||||
                            };
 | 
			
		||||
                        }
 | 
			
		||||
                    ),
 | 
			
		||||
                };
 | 
			
		||||
                nodes.push(noAreaNode);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return nodes;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Default schema grouping
 | 
			
		||||
        // Group tables by schema
 | 
			
		||||
        const tablesBySchema = new Map<string, RelevantTableData[]>();
 | 
			
		||||
 | 
			
		||||
@@ -134,16 +287,36 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return nodes;
 | 
			
		||||
    }, [relevantTableData, databaseType, hiddenTableIds, filteredSchemas]);
 | 
			
		||||
    }, [
 | 
			
		||||
        relevantTableData,
 | 
			
		||||
        databaseType,
 | 
			
		||||
        hiddenTableIds,
 | 
			
		||||
        filteredSchemas,
 | 
			
		||||
        groupBy,
 | 
			
		||||
        hasAreas,
 | 
			
		||||
        areas,
 | 
			
		||||
        tables,
 | 
			
		||||
        t,
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    // Initialize expanded state with all schemas expanded
 | 
			
		||||
    useMemo(() => {
 | 
			
		||||
        const initialExpanded: Record<string, boolean> = {};
 | 
			
		||||
        treeData.forEach((node) => {
 | 
			
		||||
            initialExpanded[node.id] = true;
 | 
			
		||||
    // Initialize expanded state with all schemas expanded only when grouping changes
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        setExpanded((prevExpanded) => {
 | 
			
		||||
            const newExpanded: Record<string, boolean> = {};
 | 
			
		||||
 | 
			
		||||
            // Preserve existing expanded states for nodes that still exist
 | 
			
		||||
            treeData.forEach((node) => {
 | 
			
		||||
                if (node.id in prevExpanded) {
 | 
			
		||||
                    newExpanded[node.id] = prevExpanded[node.id];
 | 
			
		||||
                } else {
 | 
			
		||||
                    // Default new nodes to expanded
 | 
			
		||||
                    newExpanded[node.id] = true;
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            return newExpanded;
 | 
			
		||||
        });
 | 
			
		||||
        setExpanded(initialExpanded);
 | 
			
		||||
    }, [treeData]);
 | 
			
		||||
    }, [groupBy, treeData]);
 | 
			
		||||
 | 
			
		||||
    // Filter tree data based on search query
 | 
			
		||||
    const filteredTreeData: TreeNode<NodeType, NodeContext>[] = useMemo(() => {
 | 
			
		||||
@@ -219,6 +392,45 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
 | 
			
		||||
    // Render component that's always visible (eye indicator)
 | 
			
		||||
    const renderActions = useCallback(
 | 
			
		||||
        (node: TreeNode<NodeType, NodeContext>) => {
 | 
			
		||||
            if (node.type === 'area') {
 | 
			
		||||
                return (
 | 
			
		||||
                    <Button
 | 
			
		||||
                        variant="ghost"
 | 
			
		||||
                        size="sm"
 | 
			
		||||
                        className="size-7 h-fit p-0"
 | 
			
		||||
                        disabled={!node.children || node.children.length === 0}
 | 
			
		||||
                        onClick={(e) => {
 | 
			
		||||
                            e.stopPropagation();
 | 
			
		||||
                            // Toggle all tables in this area
 | 
			
		||||
                            const allHidden =
 | 
			
		||||
                                (node.children?.length > 0 &&
 | 
			
		||||
                                    node.children?.every((child) =>
 | 
			
		||||
                                        hiddenTableIds?.includes(child.id)
 | 
			
		||||
                                    )) ||
 | 
			
		||||
                                false;
 | 
			
		||||
 | 
			
		||||
                            node.children?.forEach((child) => {
 | 
			
		||||
                                if (child.type === 'table') {
 | 
			
		||||
                                    if (allHidden) {
 | 
			
		||||
                                        removeHiddenTableId(child.id);
 | 
			
		||||
                                    } else {
 | 
			
		||||
                                        addHiddenTableId(child.id);
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                            });
 | 
			
		||||
                        }}
 | 
			
		||||
                    >
 | 
			
		||||
                        {node.children?.every((child) =>
 | 
			
		||||
                            hiddenTableIds?.includes(child.id)
 | 
			
		||||
                        ) ? (
 | 
			
		||||
                            <EyeOff className="size-3.5 text-muted-foreground" />
 | 
			
		||||
                        ) : (
 | 
			
		||||
                            <Eye className="size-3.5" />
 | 
			
		||||
                        )}
 | 
			
		||||
                    </Button>
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (node.type === 'schema') {
 | 
			
		||||
                const schemaContext = node.context as SchemaContext;
 | 
			
		||||
                const schemaId = schemaNameToSchemaId(schemaContext.name);
 | 
			
		||||
@@ -283,35 +495,12 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
 | 
			
		||||
                        className="size-7 h-fit p-0"
 | 
			
		||||
                        onClick={(e) => {
 | 
			
		||||
                            e.stopPropagation();
 | 
			
		||||
                            if (!visibleBySchema && tableSchema) {
 | 
			
		||||
                                // Unhide schema and hide all other tables
 | 
			
		||||
                                const schemaId =
 | 
			
		||||
                                    schemaNameToSchemaId(tableSchema);
 | 
			
		||||
                                filterSchemas([
 | 
			
		||||
                                    ...(filteredSchemas ?? []),
 | 
			
		||||
                                    schemaId,
 | 
			
		||||
                                ]);
 | 
			
		||||
                                const schemaNode = treeData.find(
 | 
			
		||||
                                    (s) =>
 | 
			
		||||
                                        (s.context as SchemaContext).name ===
 | 
			
		||||
                                        tableSchema
 | 
			
		||||
                                );
 | 
			
		||||
                                if (schemaNode) {
 | 
			
		||||
                                    schemaNode.children?.forEach((child) => {
 | 
			
		||||
                                        if (
 | 
			
		||||
                                            child.id !== tableId &&
 | 
			
		||||
                                            !hiddenTableIds?.includes(child.id)
 | 
			
		||||
                                        ) {
 | 
			
		||||
                                            addHiddenTableId(child.id);
 | 
			
		||||
                                        }
 | 
			
		||||
                                    });
 | 
			
		||||
                                }
 | 
			
		||||
                            } else {
 | 
			
		||||
                                toggleTableVisibility(tableId, !hidden);
 | 
			
		||||
                            }
 | 
			
		||||
                            // Simply toggle the table visibility
 | 
			
		||||
                            toggleTableVisibility(tableId, !hidden);
 | 
			
		||||
                        }}
 | 
			
		||||
                        disabled={!visibleBySchema}
 | 
			
		||||
                    >
 | 
			
		||||
                        {hidden || !visibleBySchema ? (
 | 
			
		||||
                        {hidden ? (
 | 
			
		||||
                            <EyeOff className="size-3.5 text-muted-foreground" />
 | 
			
		||||
                        ) : (
 | 
			
		||||
                            <Eye className="size-3.5" />
 | 
			
		||||
@@ -326,7 +515,6 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
 | 
			
		||||
            toggleTableVisibility,
 | 
			
		||||
            filteredSchemas,
 | 
			
		||||
            filterSchemas,
 | 
			
		||||
            treeData,
 | 
			
		||||
            hiddenTableIds,
 | 
			
		||||
            addHiddenTableId,
 | 
			
		||||
            removeHiddenTableId,
 | 
			
		||||
@@ -403,6 +591,42 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
 | 
			
		||||
                        className="h-full pl-9"
 | 
			
		||||
                    />
 | 
			
		||||
                </div>
 | 
			
		||||
                {hasAreas && (
 | 
			
		||||
                    <div className="mt-2">
 | 
			
		||||
                        <ToggleGroup
 | 
			
		||||
                            type="single"
 | 
			
		||||
                            value={groupBy}
 | 
			
		||||
                            onValueChange={(value) => {
 | 
			
		||||
                                if (value)
 | 
			
		||||
                                    setGroupBy(value as 'schema' | 'area');
 | 
			
		||||
                            }}
 | 
			
		||||
                            className="w-full justify-start"
 | 
			
		||||
                        >
 | 
			
		||||
                            <ToggleGroupItem
 | 
			
		||||
                                value="schema"
 | 
			
		||||
                                aria-label={
 | 
			
		||||
                                    supportsSchemas
 | 
			
		||||
                                        ? 'Group by schema'
 | 
			
		||||
                                        : 'Default'
 | 
			
		||||
                                }
 | 
			
		||||
                                className="h-8 flex-1 gap-1.5 text-xs"
 | 
			
		||||
                            >
 | 
			
		||||
                                <Database className="size-3.5" />
 | 
			
		||||
                                {supportsSchemas
 | 
			
		||||
                                    ? t('canvas_filter.group_by_schema')
 | 
			
		||||
                                    : t('canvas_filter.default_grouping')}
 | 
			
		||||
                            </ToggleGroupItem>
 | 
			
		||||
                            <ToggleGroupItem
 | 
			
		||||
                                value="area"
 | 
			
		||||
                                aria-label="Group by area"
 | 
			
		||||
                                className="h-8 flex-1 gap-1.5 text-xs"
 | 
			
		||||
                            >
 | 
			
		||||
                                <Layers className="size-3.5" />
 | 
			
		||||
                                {t('canvas_filter.group_by_area')}
 | 
			
		||||
                            </ToggleGroupItem>
 | 
			
		||||
                        </ToggleGroup>
 | 
			
		||||
                    </div>
 | 
			
		||||
                )}
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            {/* Table Tree */}
 | 
			
		||||
 
 | 
			
		||||
@@ -144,15 +144,48 @@ 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[],
 | 
			
		||||
    hiddenTableIds?: string[],
 | 
			
		||||
    filteredSchemas?: string[]
 | 
			
		||||
): AreaNodeType => {
 | 
			
		||||
    // Check if all tables in this area are hidden
 | 
			
		||||
    const tablesInArea = tables.filter(
 | 
			
		||||
        (table) => table.parentAreaId === area.id
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Don't hide area if it has no tables (empty area)
 | 
			
		||||
    if (tablesInArea.length === 0) {
 | 
			
		||||
        return {
 | 
			
		||||
            id: area.id,
 | 
			
		||||
            type: 'area',
 | 
			
		||||
            position: { x: area.x, y: area.y },
 | 
			
		||||
            data: { area },
 | 
			
		||||
            width: area.width,
 | 
			
		||||
            height: area.height,
 | 
			
		||||
            zIndex: -10,
 | 
			
		||||
            hidden: false,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const allTablesHidden = tablesInArea.every(
 | 
			
		||||
        (table) =>
 | 
			
		||||
            hiddenTableIds?.includes(table.id) ||
 | 
			
		||||
            !shouldShowTablesBySchemaFilter(table, filteredSchemas)
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        id: area.id,
 | 
			
		||||
        type: 'area',
 | 
			
		||||
        position: { x: area.x, y: area.y },
 | 
			
		||||
        data: { area },
 | 
			
		||||
        width: area.width,
 | 
			
		||||
        height: area.height,
 | 
			
		||||
        zIndex: -10,
 | 
			
		||||
        hidden: allTablesHidden,
 | 
			
		||||
    };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface CanvasProps {
 | 
			
		||||
    initialTables: DBTable[];
 | 
			
		||||
@@ -415,7 +448,14 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
 | 
			
		||||
                        },
 | 
			
		||||
                    };
 | 
			
		||||
                }),
 | 
			
		||||
                ...areas.map(areaToAreaNode),
 | 
			
		||||
                ...areas.map((area) =>
 | 
			
		||||
                    areaToAreaNode(
 | 
			
		||||
                        area,
 | 
			
		||||
                        tables,
 | 
			
		||||
                        hiddenTableIds,
 | 
			
		||||
                        filteredSchemas
 | 
			
		||||
                    )
 | 
			
		||||
                ),
 | 
			
		||||
            ];
 | 
			
		||||
 | 
			
		||||
            // Check if nodes actually changed
 | 
			
		||||
@@ -465,7 +505,12 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        const checkParentAreas = debounce(() => {
 | 
			
		||||
            const updatedTables = updateTablesParentAreas(tables, areas);
 | 
			
		||||
            const updatedTables = updateTablesParentAreas(
 | 
			
		||||
                tables,
 | 
			
		||||
                areas,
 | 
			
		||||
                hiddenTableIds,
 | 
			
		||||
                filteredSchemas
 | 
			
		||||
            );
 | 
			
		||||
            const needsUpdate: Array<{
 | 
			
		||||
                id: string;
 | 
			
		||||
                parentAreaId: string | null;
 | 
			
		||||
@@ -505,7 +550,14 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
 | 
			
		||||
        }, 300);
 | 
			
		||||
 | 
			
		||||
        checkParentAreas();
 | 
			
		||||
    }, [tablePositions, areas, updateTablesState, tables]);
 | 
			
		||||
    }, [
 | 
			
		||||
        tablePositions,
 | 
			
		||||
        areas,
 | 
			
		||||
        updateTablesState,
 | 
			
		||||
        tables,
 | 
			
		||||
        hiddenTableIds,
 | 
			
		||||
        filteredSchemas,
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    const onConnectHandler = useCallback(
 | 
			
		||||
        async (params: AddEdgeParams) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -17,13 +17,22 @@ import {
 | 
			
		||||
    verticalListSortingStrategy,
 | 
			
		||||
} from '@dnd-kit/sortable';
 | 
			
		||||
import { useChartDB } from '@/hooks/use-chartdb.ts';
 | 
			
		||||
import type { Area } from '@/lib/domain/area';
 | 
			
		||||
import { useTranslation } from 'react-i18next';
 | 
			
		||||
 | 
			
		||||
export interface TableListProps {
 | 
			
		||||
    tables: DBTable[];
 | 
			
		||||
    groupBy?: 'schema' | 'area';
 | 
			
		||||
    areas?: Area[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const TableList: React.FC<TableListProps> = ({ tables }) => {
 | 
			
		||||
export const TableList: React.FC<TableListProps> = ({
 | 
			
		||||
    tables,
 | 
			
		||||
    groupBy = 'schema',
 | 
			
		||||
    areas = [],
 | 
			
		||||
}) => {
 | 
			
		||||
    const { updateTablesState } = useChartDB();
 | 
			
		||||
    const { t } = useTranslation();
 | 
			
		||||
 | 
			
		||||
    const { openTableFromSidebar, openedTableInSidebar } = useLayout();
 | 
			
		||||
    const lastOpenedTable = React.useRef<string | null>(null);
 | 
			
		||||
@@ -87,62 +96,134 @@ export const TableList: React.FC<TableListProps> = ({ tables }) => {
 | 
			
		||||
        }
 | 
			
		||||
    }, [scrollToTable, openedTableInSidebar]);
 | 
			
		||||
 | 
			
		||||
    const sortTables = useCallback((tablesToSort: DBTable[]) => {
 | 
			
		||||
        return tablesToSort.sort((table1: DBTable, table2: DBTable) => {
 | 
			
		||||
            // if one table has order and the other doesn't, the one with order should come first
 | 
			
		||||
            if (table1.order && table2.order === undefined) {
 | 
			
		||||
                return -1;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (table1.order === undefined && table2.order) {
 | 
			
		||||
                return 1;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // if both tables have order, sort by order
 | 
			
		||||
            if (table1.order !== undefined && table2.order !== undefined) {
 | 
			
		||||
                return (table1.order ?? 0) - (table2.order ?? 0);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // if both tables don't have order, sort by name
 | 
			
		||||
            if (table1.isView === table2.isView) {
 | 
			
		||||
                // Both are either tables or views, so sort alphabetically by name
 | 
			
		||||
                return table1.name.localeCompare(table2.name);
 | 
			
		||||
            }
 | 
			
		||||
            // If one is a view and the other is not, put tables first
 | 
			
		||||
            return table1.isView ? 1 : -1;
 | 
			
		||||
        });
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    const groupedTables = useMemo(() => {
 | 
			
		||||
        if (groupBy === 'area') {
 | 
			
		||||
            // Group tables by area
 | 
			
		||||
            const tablesWithArea: Record<string, DBTable[]> = {};
 | 
			
		||||
            const tablesWithoutArea: DBTable[] = [];
 | 
			
		||||
 | 
			
		||||
            // Create a map of area id to area name
 | 
			
		||||
            const areaMap = areas.reduce(
 | 
			
		||||
                (acc, area) => {
 | 
			
		||||
                    acc[area.id] = area.name;
 | 
			
		||||
                    return acc;
 | 
			
		||||
                },
 | 
			
		||||
                {} as Record<string, string>
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            tables.forEach((table) => {
 | 
			
		||||
                if (table.parentAreaId && areaMap[table.parentAreaId]) {
 | 
			
		||||
                    if (!tablesWithArea[table.parentAreaId]) {
 | 
			
		||||
                        tablesWithArea[table.parentAreaId] = [];
 | 
			
		||||
                    }
 | 
			
		||||
                    tablesWithArea[table.parentAreaId].push(table);
 | 
			
		||||
                } else {
 | 
			
		||||
                    tablesWithoutArea.push(table);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            // Sort areas by their order or name
 | 
			
		||||
            const sortedAreas = areas
 | 
			
		||||
                .filter((area) => tablesWithArea[area.id])
 | 
			
		||||
                .sort((a, b) => {
 | 
			
		||||
                    if (a.order !== undefined && b.order !== undefined) {
 | 
			
		||||
                        return a.order - b.order;
 | 
			
		||||
                    }
 | 
			
		||||
                    return a.name.localeCompare(b.name);
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
            return [
 | 
			
		||||
                ...sortedAreas.map((area) => ({
 | 
			
		||||
                    id: area.id,
 | 
			
		||||
                    name: area.name,
 | 
			
		||||
                    tables: sortTables(tablesWithArea[area.id]),
 | 
			
		||||
                })),
 | 
			
		||||
                ...(tablesWithoutArea.length > 0
 | 
			
		||||
                    ? [
 | 
			
		||||
                          {
 | 
			
		||||
                              id: 'no-area',
 | 
			
		||||
                              name: t('side_panel.tables_section.no_area'),
 | 
			
		||||
                              tables: sortTables(tablesWithoutArea),
 | 
			
		||||
                          },
 | 
			
		||||
                      ]
 | 
			
		||||
                    : []),
 | 
			
		||||
            ];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Default - no grouping, just return all tables as one group
 | 
			
		||||
        return [
 | 
			
		||||
            {
 | 
			
		||||
                id: 'all',
 | 
			
		||||
                name: '',
 | 
			
		||||
                tables: sortTables(tables),
 | 
			
		||||
            },
 | 
			
		||||
        ];
 | 
			
		||||
    }, [tables, groupBy, areas, sortTables, t]);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <Accordion
 | 
			
		||||
            type="single"
 | 
			
		||||
            collapsible
 | 
			
		||||
            className="flex w-full flex-col gap-1"
 | 
			
		||||
            value={openedTableInSidebar}
 | 
			
		||||
            onValueChange={openTableFromSidebar}
 | 
			
		||||
            onAnimationEnd={handleScrollToTable}
 | 
			
		||||
        >
 | 
			
		||||
            <DndContext
 | 
			
		||||
                sensors={sensors}
 | 
			
		||||
                collisionDetection={closestCenter}
 | 
			
		||||
                onDragEnd={handleDragEnd}
 | 
			
		||||
            >
 | 
			
		||||
                <SortableContext
 | 
			
		||||
                    items={tables}
 | 
			
		||||
                    strategy={verticalListSortingStrategy}
 | 
			
		||||
                >
 | 
			
		||||
                    {tables
 | 
			
		||||
                        .sort((table1: DBTable, table2: DBTable) => {
 | 
			
		||||
                            // if one table has order and the other doesn't, the one with order should come first
 | 
			
		||||
                            if (table1.order && table2.order === undefined) {
 | 
			
		||||
                                return -1;
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            if (table1.order === undefined && table2.order) {
 | 
			
		||||
                                return 1;
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            // if both tables have order, sort by order
 | 
			
		||||
                            if (
 | 
			
		||||
                                table1.order !== undefined &&
 | 
			
		||||
                                table2.order !== undefined
 | 
			
		||||
                            ) {
 | 
			
		||||
                                return (
 | 
			
		||||
                                    (table1.order ?? 0) - (table2.order ?? 0)
 | 
			
		||||
                                );
 | 
			
		||||
                            }
 | 
			
		||||
 | 
			
		||||
                            // if both tables don't have order, sort by name
 | 
			
		||||
                            if (table1.isView === table2.isView) {
 | 
			
		||||
                                // Both are either tables or views, so sort alphabetically by name
 | 
			
		||||
                                return table1.name.localeCompare(table2.name);
 | 
			
		||||
                            }
 | 
			
		||||
                            // If one is a view and the other is not, put tables first
 | 
			
		||||
                            return table1.isView ? 1 : -1;
 | 
			
		||||
                        })
 | 
			
		||||
                        .map((table) => (
 | 
			
		||||
                            <TableListItem
 | 
			
		||||
                                key={table.id}
 | 
			
		||||
                                table={table}
 | 
			
		||||
                                ref={refs[table.id]}
 | 
			
		||||
                            />
 | 
			
		||||
                        ))}
 | 
			
		||||
                </SortableContext>
 | 
			
		||||
            </DndContext>
 | 
			
		||||
        </Accordion>
 | 
			
		||||
        <div className="flex flex-col gap-3">
 | 
			
		||||
            {groupedTables.map((group) => (
 | 
			
		||||
                <div key={group.id}>
 | 
			
		||||
                    {group.name && (
 | 
			
		||||
                        <div className="mb-2 px-2 text-xs font-medium text-muted-foreground">
 | 
			
		||||
                            {group.name}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    )}
 | 
			
		||||
                    <Accordion
 | 
			
		||||
                        type="single"
 | 
			
		||||
                        collapsible
 | 
			
		||||
                        className="flex w-full flex-col gap-1"
 | 
			
		||||
                        value={openedTableInSidebar}
 | 
			
		||||
                        onValueChange={openTableFromSidebar}
 | 
			
		||||
                        onAnimationEnd={handleScrollToTable}
 | 
			
		||||
                    >
 | 
			
		||||
                        <DndContext
 | 
			
		||||
                            sensors={sensors}
 | 
			
		||||
                            collisionDetection={closestCenter}
 | 
			
		||||
                            onDragEnd={handleDragEnd}
 | 
			
		||||
                        >
 | 
			
		||||
                            <SortableContext
 | 
			
		||||
                                items={group.tables}
 | 
			
		||||
                                strategy={verticalListSortingStrategy}
 | 
			
		||||
                            >
 | 
			
		||||
                                {group.tables.map((table) => (
 | 
			
		||||
                                    <TableListItem
 | 
			
		||||
                                        key={table.id}
 | 
			
		||||
                                        table={table}
 | 
			
		||||
                                        ref={refs[table.id]}
 | 
			
		||||
                                    />
 | 
			
		||||
                                ))}
 | 
			
		||||
                            </SortableContext>
 | 
			
		||||
                        </DndContext>
 | 
			
		||||
                    </Accordion>
 | 
			
		||||
                </div>
 | 
			
		||||
            ))}
 | 
			
		||||
        </div>
 | 
			
		||||
    );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import React, { useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import { TableList } from './table-list/table-list';
 | 
			
		||||
import { Button } from '@/components/button/button';
 | 
			
		||||
import { Table, List, X, Code } from 'lucide-react';
 | 
			
		||||
import { Table, List, X, Code, Layers, Database } from 'lucide-react';
 | 
			
		||||
import { Input } from '@/components/input/input';
 | 
			
		||||
import type { DBTable } from '@/lib/domain/db-table';
 | 
			
		||||
import { shouldShowTablesBySchemaFilter } from '@/lib/domain/db-table';
 | 
			
		||||
@@ -21,18 +21,32 @@ import { TableDBML } from './table-dbml/table-dbml';
 | 
			
		||||
import { useHotkeys } from 'react-hotkeys-hook';
 | 
			
		||||
import { getOperatingSystem } from '@/lib/utils';
 | 
			
		||||
import type { DBSchema } from '@/lib/domain';
 | 
			
		||||
import { ToggleGroup, ToggleGroupItem } from '@/components/toggle/toggle-group';
 | 
			
		||||
import { databasesWithSchemas } from '@/lib/domain/db-schema';
 | 
			
		||||
 | 
			
		||||
export interface TablesSectionProps {}
 | 
			
		||||
 | 
			
		||||
export const TablesSection: React.FC<TablesSectionProps> = () => {
 | 
			
		||||
    const { createTable, tables, filteredSchemas, schemas } = useChartDB();
 | 
			
		||||
    const {
 | 
			
		||||
        createTable,
 | 
			
		||||
        tables,
 | 
			
		||||
        filteredSchemas,
 | 
			
		||||
        schemas,
 | 
			
		||||
        areas,
 | 
			
		||||
        databaseType,
 | 
			
		||||
    } = useChartDB();
 | 
			
		||||
    const { openTableSchemaDialog } = useDialog();
 | 
			
		||||
    const viewport = useViewport();
 | 
			
		||||
    const { t } = useTranslation();
 | 
			
		||||
    const { openTableFromSidebar } = useLayout();
 | 
			
		||||
    const [filterText, setFilterText] = React.useState('');
 | 
			
		||||
    const [showDBML, setShowDBML] = useState(false);
 | 
			
		||||
    const [groupBy, setGroupBy] = useState<'schema' | 'area'>('schema');
 | 
			
		||||
    const filterInputRef = React.useRef<HTMLInputElement>(null);
 | 
			
		||||
    const supportsSchemas = useMemo(
 | 
			
		||||
        () => databasesWithSchemas.includes(databaseType),
 | 
			
		||||
        [databaseType]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const filteredTables = useMemo(() => {
 | 
			
		||||
        const filterTableName: (table: DBTable) => boolean = (table) =>
 | 
			
		||||
@@ -162,6 +176,37 @@ export const TablesSection: React.FC<TablesSectionProps> = () => {
 | 
			
		||||
                    {t('side_panel.tables_section.add_table')}
 | 
			
		||||
                </Button>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="mb-2">
 | 
			
		||||
                <ToggleGroup
 | 
			
		||||
                    type="single"
 | 
			
		||||
                    value={groupBy}
 | 
			
		||||
                    onValueChange={(value) => {
 | 
			
		||||
                        if (value) setGroupBy(value as 'schema' | 'area');
 | 
			
		||||
                    }}
 | 
			
		||||
                    className="w-full justify-start"
 | 
			
		||||
                >
 | 
			
		||||
                    <ToggleGroupItem
 | 
			
		||||
                        value="schema"
 | 
			
		||||
                        aria-label={
 | 
			
		||||
                            supportsSchemas ? 'Group by schema' : 'Default'
 | 
			
		||||
                        }
 | 
			
		||||
                        className="h-8 flex-1 gap-1.5 text-xs"
 | 
			
		||||
                    >
 | 
			
		||||
                        <Database className="size-3.5" />
 | 
			
		||||
                        {supportsSchemas
 | 
			
		||||
                            ? t('side_panel.tables_section.group_by_schema')
 | 
			
		||||
                            : t('side_panel.tables_section.default_grouping')}
 | 
			
		||||
                    </ToggleGroupItem>
 | 
			
		||||
                    <ToggleGroupItem
 | 
			
		||||
                        value="area"
 | 
			
		||||
                        aria-label="Group by area"
 | 
			
		||||
                        className="h-8 flex-1 gap-1.5 text-xs"
 | 
			
		||||
                    >
 | 
			
		||||
                        <Layers className="size-3.5" />
 | 
			
		||||
                        {t('side_panel.tables_section.group_by_area')}
 | 
			
		||||
                    </ToggleGroupItem>
 | 
			
		||||
                </ToggleGroup>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className="flex flex-1 flex-col overflow-hidden">
 | 
			
		||||
                {showDBML ? (
 | 
			
		||||
                    <TableDBML filteredTables={filteredTables} />
 | 
			
		||||
@@ -193,7 +238,11 @@ export const TablesSection: React.FC<TablesSectionProps> = () => {
 | 
			
		||||
                                </Button>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        ) : (
 | 
			
		||||
                            <TableList tables={filteredTables} />
 | 
			
		||||
                            <TableList
 | 
			
		||||
                                tables={filteredTables}
 | 
			
		||||
                                groupBy={groupBy}
 | 
			
		||||
                                areas={areas}
 | 
			
		||||
                            />
 | 
			
		||||
                        )}
 | 
			
		||||
                    </ScrollArea>
 | 
			
		||||
                )}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user