feat(canvas): Add filter tables on canvas (#774)

* feat(canvas): filter tables on canvas

* fix build

* fix

* fix
This commit is contained in:
Guy Ben-Aharon
2025-07-21 15:54:27 +03:00
committed by GitHub
parent f56fab9876
commit dfbcf05b2f
41 changed files with 1471 additions and 59 deletions

79
package-lock.json generated
View File

@@ -46,8 +46,9 @@
"html-to-image": "^1.11.11",
"i18next": "^23.14.0",
"i18next-browser-languagedetector": "^8.0.0",
"lucide-react": "^0.441.0",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.0",
"motion": "^12.23.6",
"nanoid": "^5.0.7",
"node-sql-parser": "^5.3.2",
"react": "^18.3.1",
@@ -6767,6 +6768,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.6.tgz",
"integrity": "sha512-dsJ389QImVE3lQvM8Mnk99/j8tiZDM/7706PCqvkQ8sSCnpmWxsgX+g0lj7r5OBVL0U36pIecCTBoIWcM2RuKw==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.6",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -8052,12 +8080,12 @@
}
},
"node_modules/lucide-react": {
"version": "0.441.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.441.0.tgz",
"integrity": "sha512-0vfExYtvSDhkC2lqg0zYVW1Uu9GsI4knuV9GP9by5z0Xhc4Zi5RejTxfz9LsjRmCyWVzHCJvxGKZWcRyvQCWVg==",
"version": "0.525.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
"integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
@@ -8198,6 +8226,47 @@
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
"license": "MIT"
},
"node_modules/motion": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.6.tgz",
"integrity": "sha512-6U55IW5i6Vut2ryKEhrZKg55490k9d6qdGXZoNSf98oQgDj5D7bqTnVJotQ6UW3AS6QfbW6KSLa7/e1gy+a07g==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.6.tgz",
"integrity": "sha512-G2w6Nw7ZOVSzcQmsdLc0doMe64O/Sbuc2bVAbgMz6oP/6/pRStKRiVRV4bQfHp5AHYAKEGhEdVHTM+R3FDgi5w==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",

View File

@@ -54,8 +54,9 @@
"html-to-image": "^1.11.11",
"i18next": "^23.14.0",
"i18next-browser-languagedetector": "^8.0.0",
"lucide-react": "^0.441.0",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.0",
"motion": "^12.23.6",
"nanoid": "^5.0.7",
"node-sql-parser": "^5.3.2",
"react": "^18.3.1",

View File

@@ -52,7 +52,7 @@ export const EmptyState = forwardRef<
</Label>
<Label
className={cn(
'text-sm font-normal text-muted-foreground',
'text-sm text-center font-normal text-muted-foreground',
descriptionClassName
)}
>

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Skeleton } from '../skeleton/skeleton';
import { cn } from '@/lib/utils';
export interface TreeItemSkeletonProps
extends React.HTMLAttributes<HTMLDivElement> {}
export const TreeItemSkeleton: React.FC<TreeItemSkeletonProps> = ({
className,
style,
}) => {
return (
<div className={cn('px-2 py-1', className)} style={style}>
<Skeleton className="h-3.5 w-full rounded-sm" />
</div>
);
};

View File

@@ -0,0 +1,461 @@
import {
ChevronRight,
File,
Folder,
Loader2,
type LucideIcon,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Button } from '@/components/button/button';
import type {
TreeNode,
FetchChildrenFunction,
SelectableTreeProps,
} from './tree';
import type { ExpandedState } from './use-tree';
import { useTree } from './use-tree';
import type { Dispatch, ReactNode, SetStateAction } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { TreeItemSkeleton } from './tree-item-skeleton';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip/tooltip';
interface TreeViewProps<
Type extends string,
Context extends Record<Type, unknown>,
> {
data: TreeNode<Type, Context>[];
fetchChildren?: FetchChildrenFunction<Type, Context>;
onNodeClick?: (node: TreeNode<Type, Context>) => void;
className?: string;
defaultIcon?: LucideIcon;
defaultFolderIcon?: LucideIcon;
defaultIconProps?: React.ComponentProps<LucideIcon>;
defaultFolderIconProps?: React.ComponentProps<LucideIcon>;
selectable?: SelectableTreeProps<Type, Context>;
expanded?: ExpandedState;
setExpanded?: Dispatch<SetStateAction<ExpandedState>>;
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
loadingNodeIds?: string[];
}
export function TreeView<
Type extends string,
Context extends Record<Type, unknown>,
>({
data,
fetchChildren,
onNodeClick,
className,
defaultIcon = File,
defaultFolderIcon = Folder,
defaultIconProps,
defaultFolderIconProps,
selectable,
expanded: expandedProp,
setExpanded: setExpandedProp,
renderHoverComponent,
renderActionsComponent,
loadingNodeIds,
}: TreeViewProps<Type, Context>) {
const { expanded, loading, loadedChildren, hasMoreChildren, toggleNode } =
useTree({
fetchChildren,
expanded: expandedProp,
setExpanded: setExpandedProp,
});
const [selectedIdInternal, setSelectedIdInternal] = React.useState<
string | undefined
>(selectable?.defaultSelectedId);
const selectedId = useMemo(() => {
return selectable?.selectedId ?? selectedIdInternal;
}, [selectable?.selectedId, selectedIdInternal]);
const setSelectedId = useCallback(
(value: SetStateAction<string | undefined>) => {
if (selectable?.setSelectedId) {
selectable.setSelectedId(value);
} else {
setSelectedIdInternal(value);
}
},
[selectable, setSelectedIdInternal]
);
useEffect(() => {
if (selectable?.enabled && selectable.defaultSelectedId) {
if (selectable.defaultSelectedId === selectedId) return;
setSelectedId(selectable.defaultSelectedId);
const { node, path } = findNodeById(
data,
selectable.defaultSelectedId
);
if (node) {
selectable.onSelectedChange?.(node);
// Expand all parent nodes
for (const parent of path) {
if (expanded[parent.id]) continue;
toggleNode(
parent.id,
parent.type,
parent.context,
parent.children
);
}
}
}
}, [selectable, toggleNode, selectedId, data, expanded, setSelectedId]);
const handleNodeSelect = (node: TreeNode<Type, Context>) => {
if (selectable?.enabled) {
setSelectedId(node.id);
selectable.onSelectedChange?.(node);
}
};
return (
<div className={cn('w-full', className)}>
{data.map((node, index) => (
<TreeNode
key={node.id}
node={node}
level={0}
expanded={expanded}
loading={loading}
loadedChildren={loadedChildren}
hasMoreChildren={hasMoreChildren}
onToggle={toggleNode}
onNodeClick={onNodeClick}
defaultIcon={defaultIcon}
defaultFolderIcon={defaultFolderIcon}
defaultIconProps={defaultIconProps}
defaultFolderIconProps={defaultFolderIconProps}
selectable={selectable?.enabled}
selectedId={selectedId}
onSelect={handleNodeSelect}
className={index > 0 ? 'mt-0.5' : ''}
renderHoverComponent={renderHoverComponent}
renderActionsComponent={renderActionsComponent}
loadingNodeIds={loadingNodeIds}
/>
))}
</div>
);
}
interface TreeNodeProps<
Type extends string,
Context extends Record<Type, unknown>,
> {
node: TreeNode<Type, Context>;
level: number;
expanded: Record<string, boolean>;
loading: Record<string, boolean>;
loadedChildren: Record<string, TreeNode<Type, Context>[]>;
hasMoreChildren: Record<string, boolean>;
onToggle: (
nodeId: string,
nodeType: Type,
nodeContext: Context[Type],
staticChildren?: TreeNode<Type, Context>[]
) => void;
onNodeClick?: (node: TreeNode<Type, Context>) => void;
defaultIcon: LucideIcon;
defaultFolderIcon: LucideIcon;
defaultIconProps?: React.ComponentProps<LucideIcon>;
defaultFolderIconProps?: React.ComponentProps<LucideIcon>;
selectable?: boolean;
selectedId?: string;
onSelect: (node: TreeNode<Type, Context>) => void;
className?: string;
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
loadingNodeIds?: string[];
}
function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
node,
level,
expanded,
loading,
loadedChildren,
hasMoreChildren,
onToggle,
onNodeClick,
defaultIcon: DefaultIcon,
defaultFolderIcon: DefaultFolderIcon,
defaultIconProps,
defaultFolderIconProps,
selectable,
selectedId,
onSelect,
className,
renderHoverComponent,
renderActionsComponent,
loadingNodeIds,
}: TreeNodeProps<Type, Context>) {
const [isHovered, setIsHovered] = useState(false);
const isExpanded = expanded[node.id];
const isLoading = loading[node.id];
const children = loadedChildren[node.id] || node.children;
const isSelected = selectedId === node.id;
const IconComponent =
node.icon || (node.isFolder ? DefaultFolderIcon : DefaultIcon);
const iconProps: React.ComponentProps<LucideIcon> = {
strokeWidth: isSelected ? 2.5 : 2,
...(node.isFolder ? defaultFolderIconProps : defaultIconProps),
...node.iconProps,
className: cn(
'h-3.5 w-3.5 text-muted-foreground flex-none',
isSelected && 'text-primary text-white',
node.iconProps?.className
),
};
return (
<div className={cn(className)}>
<div
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-lg cursor-pointer group h-6',
'transition-colors duration-200',
isSelected
? 'bg-sky-500 border border-sky-600 border dark:bg-sky-600 dark:border-sky-700'
: 'hover:bg-gray-200/50 border border-transparent dark:hover:bg-gray-700/50',
node.className
)}
{...(isSelected ? { 'data-selected': true } : {})}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={(e) => {
e.stopPropagation();
if (selectable && !node.unselectable) {
onSelect(node);
}
// if (node.isFolder) {
// onToggle(node.id, node.children);
// }
// called only once in case of double click
if (e.detail !== 2) {
onNodeClick?.(node);
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (node.isFolder) {
onToggle(
node.id,
node.type,
node.context,
node.children
);
}
}}
>
<div className="flex flex-none items-center gap-1.5">
<Button
variant="ghost"
size="icon"
className={cn(
'h-3.5 w-3.5 p-0 hover:bg-transparent flex-none',
isExpanded && 'rotate-90',
'transition-transform duration-200'
)}
onClick={(e) => {
e.stopPropagation();
if (node.isFolder) {
onToggle(
node.id,
node.type,
node.context,
node.children
);
}
}}
>
{node.isFolder &&
(isLoading ? (
<Loader2
className={cn('size-3.5 animate-spin', {
'text-white': isSelected,
})}
/>
) : (
<ChevronRight
className={cn('size-3.5', {
'text-white': isSelected,
})}
strokeWidth={2}
/>
))}
</Button>
{node.tooltip ? (
<Tooltip>
<TooltipTrigger asChild>
{loadingNodeIds?.includes(node.id) ? (
<Loader2
className={cn('size-3.5 animate-spin', {
'text-white': isSelected,
})}
/>
) : (
<IconComponent
{...(isSelected
? { 'data-selected': true }
: {})}
{...iconProps}
/>
)}
</TooltipTrigger>
<TooltipContent
align="center"
className="max-w-[400px]"
>
{node.tooltip}
</TooltipContent>
</Tooltip>
) : node.empty ? null : loadingNodeIds?.includes(
node.id
) ? (
<Loader2
className={cn('size-3.5 animate-spin', {
// 'text-white': isSelected,
})}
/>
) : (
<IconComponent
{...(isSelected ? { 'data-selected': true } : {})}
{...iconProps}
/>
)}
</div>
<span
{...node.labelProps}
className={cn(
'text-xs truncate min-w-0 flex-1 w-0',
isSelected && 'font-medium text-primary text-white',
node.labelProps?.className
)}
{...(isSelected ? { 'data-selected': true } : {})}
>
{node.empty ? '' : node.name}
</span>
{renderActionsComponent && renderActionsComponent(node)}
{isHovered && renderHoverComponent
? renderHoverComponent(node)
: null}
</div>
<AnimatePresence initial={false}>
{isExpanded && children && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{
height: 'auto',
opacity: 1,
transition: {
height: {
duration: Math.min(
0.3 + children.length * 0.018,
0.7
),
ease: 'easeInOut',
},
opacity: {
duration: Math.min(
0.2 + children.length * 0.012,
0.4
),
ease: 'easeInOut',
},
},
}}
exit={{
height: 0,
opacity: 0,
transition: {
height: {
duration: Math.min(
0.2 + children.length * 0.01,
0.45
),
ease: 'easeInOut',
},
opacity: {
duration: 0.1,
ease: 'easeOut',
},
},
}}
style={{ overflow: 'hidden' }}
>
{children.map((child) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
expanded={expanded}
loading={loading}
loadedChildren={loadedChildren}
hasMoreChildren={hasMoreChildren}
onToggle={onToggle}
onNodeClick={onNodeClick}
defaultIcon={DefaultIcon}
defaultFolderIcon={DefaultFolderIcon}
defaultIconProps={defaultIconProps}
defaultFolderIconProps={defaultFolderIconProps}
selectable={selectable}
selectedId={selectedId}
onSelect={onSelect}
className="mt-0.5"
renderHoverComponent={renderHoverComponent}
renderActionsComponent={renderActionsComponent}
loadingNodeIds={loadingNodeIds}
/>
))}
{isLoading ? (
<TreeItemSkeleton
style={{
paddingLeft: `${level + 2 * 16 + 8}px`,
}}
/>
) : null}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function findNodeById<
Type extends string,
Context extends Record<Type, unknown>,
>(
nodes: TreeNode<Type, Context>[],
id: string,
initialPath: TreeNode<Type, Context>[] = []
): { node: TreeNode<Type, Context> | null; path: TreeNode<Type, Context>[] } {
const path: TreeNode<Type, Context>[] = [...initialPath];
for (const node of nodes) {
if (node.id === id) return { node, path };
if (node.children) {
const found = findNodeById(node.children, id, [...path, node]);
if (found.node) {
return found;
}
}
}
return { node: null, path };
}

View File

@@ -0,0 +1,41 @@
import type { LucideIcon } from 'lucide-react';
import type React from 'react';
export interface TreeNode<
Type extends string,
Context extends Record<Type, unknown>,
> {
id: string;
name: string;
isFolder?: boolean;
children?: TreeNode<Type, Context>[];
icon?: LucideIcon;
iconProps?: React.ComponentProps<LucideIcon>;
labelProps?: React.ComponentProps<'span'>;
type: Type;
unselectable?: boolean;
tooltip?: string;
context: Context[Type];
empty?: boolean;
className?: string;
}
export type FetchChildrenFunction<
Type extends string,
Context extends Record<Type, unknown>,
> = (
nodeId: string,
nodeType: Type,
nodeContext: Context[Type]
) => Promise<TreeNode<Type, Context>[]>;
export interface SelectableTreeProps<
Type extends string,
Context extends Record<Type, unknown>,
> {
enabled: boolean;
defaultSelectedId?: string;
onSelectedChange?: (node: TreeNode<Type, Context>) => void;
selectedId?: string;
setSelectedId?: React.Dispatch<React.SetStateAction<string | undefined>>;
}

View File

@@ -0,0 +1,153 @@
import type { Dispatch, SetStateAction } from 'react';
import { useState, useCallback, useMemo } from 'react';
import type { TreeNode, FetchChildrenFunction } from './tree';
export interface ExpandedState {
[key: string]: boolean;
}
interface LoadingState {
[key: string]: boolean;
}
interface LoadedChildren<
Type extends string,
Context extends Record<Type, unknown>,
> {
[key: string]: TreeNode<Type, Context>[];
}
interface HasMoreChildrenState {
[key: string]: boolean;
}
export function useTree<
Type extends string,
Context extends Record<Type, unknown>,
>({
fetchChildren,
expanded: expandedProp,
setExpanded: setExpandedProp,
}: {
fetchChildren?: FetchChildrenFunction<Type, Context>;
expanded?: ExpandedState;
setExpanded?: Dispatch<SetStateAction<ExpandedState>>;
}) {
const [expandedInternal, setExpandedInternal] = useState<ExpandedState>({});
const expanded = useMemo(
() => expandedProp ?? expandedInternal,
[expandedProp, expandedInternal]
);
const setExpanded = useCallback(
(value: SetStateAction<ExpandedState>) => {
if (setExpandedProp) {
setExpandedProp(value);
} else {
setExpandedInternal(value);
}
},
[setExpandedProp, setExpandedInternal]
);
const [loading, setLoading] = useState<LoadingState>({});
const [loadedChildren, setLoadedChildren] = useState<
LoadedChildren<Type, Context>
>({});
const [hasMoreChildren, setHasMoreChildren] =
useState<HasMoreChildrenState>({});
const mergeChildren = useCallback(
(
staticChildren: TreeNode<Type, Context>[] = [],
fetchedChildren: TreeNode<Type, Context>[] = []
) => {
const fetchedChildrenIds = new Set(
fetchedChildren.map((child) => child.id)
);
const uniqueStaticChildren = staticChildren.filter(
(child) => !fetchedChildrenIds.has(child.id)
);
return [...uniqueStaticChildren, ...fetchedChildren];
},
[]
);
const toggleNode = useCallback(
async (
nodeId: string,
nodeType: Type,
nodeContext: Context[Type],
staticChildren?: TreeNode<Type, Context>[]
) => {
if (expanded[nodeId]) {
// If we're collapsing, just update expanded state
setExpanded((prev) => ({ ...prev, [nodeId]: false }));
return;
}
// Get any previously fetched children
const previouslyFetchedChildren = loadedChildren[nodeId] || [];
// If we have static children, merge them with any previously fetched children
if (staticChildren?.length) {
const mergedChildren = mergeChildren(
staticChildren,
previouslyFetchedChildren
);
setLoadedChildren((prev) => ({
...prev,
[nodeId]: mergedChildren,
}));
// Only show "more loading" if we haven't fetched children before
setHasMoreChildren((prev) => ({
...prev,
[nodeId]: !previouslyFetchedChildren.length,
}));
}
// Set expanded state immediately to show static/previously fetched children
setExpanded((prev) => ({ ...prev, [nodeId]: true }));
// If we haven't loaded dynamic children yet
if (!previouslyFetchedChildren.length) {
setLoading((prev) => ({ ...prev, [nodeId]: true }));
try {
const fetchedChildren = await fetchChildren?.(
nodeId,
nodeType,
nodeContext
);
// Merge static and newly fetched children
const allChildren = mergeChildren(
staticChildren || [],
fetchedChildren
);
setLoadedChildren((prev) => ({
...prev,
[nodeId]: allChildren,
}));
setHasMoreChildren((prev) => ({
...prev,
[nodeId]: false,
}));
} catch (error) {
console.error('Error loading children:', error);
} finally {
setLoading((prev) => ({ ...prev, [nodeId]: false }));
}
}
},
[expanded, loadedChildren, fetchChildren, mergeChildren, setExpanded]
);
return {
expanded,
loading,
loadedChildren,
hasMoreChildren,
toggleNode,
};
}

View File

@@ -12,6 +12,8 @@ export interface CanvasContext {
}) => void;
setOverlapGraph: (graph: Graph<string>) => void;
overlapGraph: Graph<string>;
setShowFilter: React.Dispatch<React.SetStateAction<boolean>>;
showFilter: boolean;
}
export const canvasContext = createContext<CanvasContext>({
@@ -19,4 +21,6 @@ export const canvasContext = createContext<CanvasContext>({
fitView: emptyFn,
setOverlapGraph: emptyFn,
overlapGraph: createGraph(),
setShowFilter: emptyFn,
showFilter: false,
});

View File

@@ -21,6 +21,8 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
const [overlapGraph, setOverlapGraph] =
useState<Graph<string>>(createGraph());
const [showFilter, setShowFilter] = useState(false);
const reorderTables = useCallback(
(
options: { updateHistory?: boolean } = {
@@ -77,6 +79,8 @@ export const CanvasProvider = ({ children }: CanvasProviderProps) => {
fitView,
setOverlapGraph,
overlapGraph,
setShowFilter,
showFilter,
}}
>
{children}

View File

@@ -277,6 +277,11 @@ export interface ChartDBContext {
customType: Partial<DBCustomType>,
options?: { updateHistory: boolean }
) => Promise<void>;
// Filters
hiddenTableIds?: string[];
addHiddenTableId: (tableId: string) => Promise<void>;
removeHiddenTableId: (tableId: string) => Promise<void>;
}
export const chartDBContext = createContext<ChartDBContext>({
@@ -372,4 +377,9 @@ export const chartDBContext = createContext<ChartDBContext>({
removeCustomType: emptyFn,
removeCustomTypes: emptyFn,
updateCustomType: emptyFn,
// Filters
hiddenTableIds: [],
addHiddenTableId: emptyFn,
removeHiddenTableId: emptyFn,
});

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { DBTable } from '@/lib/domain/db-table';
import { deepCopy, generateId } from '@/lib/utils';
import { randomColor } from '@/lib/colors';
@@ -29,6 +29,7 @@ import {
DBCustomTypeKind,
type DBCustomType,
} from '@/lib/domain/db-custom-type';
import { useConfig } from '@/hooks/use-config';
export interface ChartDBProviderProps {
diagram?: Diagram;
@@ -44,6 +45,11 @@ export const ChartDBProvider: React.FC<
const { setSchemasFilter, schemasFilter } = useLocalConfig();
const { addUndoAction, resetRedoStack, resetUndoStack } =
useRedoUndoStack();
const {
getHiddenTablesForDiagram,
hideTableForDiagram,
unhideTableForDiagram,
} = useConfig();
const [diagramId, setDiagramId] = useState('');
const [diagramName, setDiagramName] = useState('');
const [diagramCreatedAt, setDiagramCreatedAt] = useState<Date>(new Date());
@@ -65,6 +71,7 @@ export const ChartDBProvider: React.FC<
const [customTypes, setCustomTypes] = useState<DBCustomType[]>(
diagram?.customTypes ?? []
);
const [hiddenTableIds, setHiddenTableIds] = useState<string[]>([]);
const { events: diffEvents } = useDiff();
const diffCalculatedHandler = useCallback((event: DiffCalculatedEvent) => {
@@ -85,6 +92,14 @@ export const ChartDBProvider: React.FC<
diffEvents.useSubscription(diffCalculatedHandler);
// Sync hiddenTableIds with config
useEffect(() => {
if (diagramId) {
const hiddenTables = getHiddenTablesForDiagram(diagramId);
setHiddenTableIds(hiddenTables);
}
}, [diagramId, getHiddenTablesForDiagram]);
const defaultSchemaName = defaultSchemas[databaseType];
const readonly = useMemo(
@@ -1712,6 +1727,29 @@ export const ChartDBProvider: React.FC<
]
);
const addHiddenTableId: ChartDBContext['addHiddenTableId'] = useCallback(
async (tableId: string) => {
if (!hiddenTableIds.includes(tableId)) {
setHiddenTableIds((prev) => [...prev, tableId]);
await hideTableForDiagram(diagramId, tableId);
}
},
[hiddenTableIds, diagramId, hideTableForDiagram]
);
const removeHiddenTableId: ChartDBContext['removeHiddenTableId'] =
useCallback(
async (tableId: string) => {
if (hiddenTableIds.includes(tableId)) {
setHiddenTableIds((prev) =>
prev.filter((id) => id !== tableId)
);
await unhideTableForDiagram(diagramId, tableId);
}
},
[hiddenTableIds, diagramId, unhideTableForDiagram]
);
return (
<chartDBContext.Provider
value={{
@@ -1784,6 +1822,9 @@ export const ChartDBProvider: React.FC<
removeCustomType,
removeCustomTypes,
updateCustomType,
hiddenTableIds,
addHiddenTableId,
removeHiddenTableId,
}}
>
{children}

View File

@@ -8,9 +8,23 @@ export interface ConfigContext {
config?: Partial<ChartDBConfig>;
updateFn?: (config: ChartDBConfig) => ChartDBConfig;
}) => Promise<void>;
getHiddenTablesForDiagram: (diagramId: string) => string[];
setHiddenTablesForDiagram: (
diagramId: string,
hiddenTableIds: string[]
) => Promise<void>;
hideTableForDiagram: (diagramId: string, tableId: string) => Promise<void>;
unhideTableForDiagram: (
diagramId: string,
tableId: string
) => Promise<void>;
}
export const ConfigContext = createContext<ConfigContext>({
config: undefined,
updateConfig: emptyFn,
getHiddenTablesForDiagram: () => [],
setHiddenTablesForDiagram: emptyFn,
hideTableForDiagram: emptyFn,
unhideTableForDiagram: emptyFn,
});

View File

@@ -44,8 +44,86 @@ export const ConfigProvider: React.FC<React.PropsWithChildren> = ({
return promise;
};
const getHiddenTablesForDiagram = (diagramId: string): string[] => {
return config?.hiddenTablesByDiagram?.[diagramId] ?? [];
};
const setHiddenTablesForDiagram = async (
diagramId: string,
hiddenTableIds: string[]
): Promise<void> => {
return updateConfig({
updateFn: (currentConfig) => ({
...currentConfig,
hiddenTablesByDiagram: {
...currentConfig.hiddenTablesByDiagram,
[diagramId]: hiddenTableIds,
},
}),
});
};
const hideTableForDiagram = async (
diagramId: string,
tableId: string
): Promise<void> => {
return updateConfig({
updateFn: (currentConfig) => {
const currentHiddenTables =
currentConfig.hiddenTablesByDiagram?.[diagramId] ?? [];
if (currentHiddenTables.includes(tableId)) {
return currentConfig; // Already hidden, no change needed
}
return {
...currentConfig,
hiddenTablesByDiagram: {
...currentConfig.hiddenTablesByDiagram,
[diagramId]: [...currentHiddenTables, tableId],
},
};
},
});
};
const unhideTableForDiagram = async (
diagramId: string,
tableId: string
): Promise<void> => {
return updateConfig({
updateFn: (currentConfig) => {
const currentHiddenTables =
currentConfig.hiddenTablesByDiagram?.[diagramId] ?? [];
const filteredTables = currentHiddenTables.filter(
(id) => id !== tableId
);
if (filteredTables.length === currentHiddenTables.length) {
return currentConfig; // Not hidden, no change needed
}
return {
...currentConfig,
hiddenTablesByDiagram: {
...currentConfig.hiddenTablesByDiagram,
[diagramId]: filteredTables,
},
};
},
});
};
return (
<ConfigContext.Provider value={{ config, updateConfig }}>
<ConfigContext.Provider
value={{
config,
updateConfig,
getHiddenTablesForDiagram,
setHiddenTablesForDiagram,
hideTableForDiagram,
unhideTableForDiagram,
}}
>
{children}
</ConfigContext.Provider>
);

View File

@@ -8,6 +8,7 @@ export enum KeyboardShortcutAction {
TOGGLE_SIDE_PANEL = 'toggle_side_panel',
SHOW_ALL = 'show_all',
TOGGLE_THEME = 'toggle_theme',
TOGGLE_FILTER = 'toggle_filter',
}
export interface KeyboardShortcut {
@@ -71,6 +72,13 @@ export const keyboardShortcuts: Record<
keyCombinationMac: 'meta+m',
keyCombinationWin: 'ctrl+m',
},
[KeyboardShortcutAction.TOGGLE_FILTER]: {
action: KeyboardShortcutAction.TOGGLE_FILTER,
keyCombinationLabelMac: '⌘F',
keyCombinationLabelWin: 'Ctrl+F',
keyCombinationMac: 'meta+f',
keyCombinationWin: 'ctrl+f',
},
};
export interface KeyboardShortcutForOS {

View File

@@ -271,6 +271,8 @@ export const ar: LanguageTranslation = {
redo: 'إعادة',
reorder_diagram: 'إعادة ترتيب الرسم البياني',
highlight_overlapping_tables: 'تمييز الجداول المتداخلة',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -272,6 +272,8 @@ export const bn: LanguageTranslation = {
redo: 'পুনরায় করুন',
reorder_diagram: 'ডায়াগ্রাম পুনর্বিন্যাস করুন',
highlight_overlapping_tables: 'ওভারল্যাপিং টেবিল হাইলাইট করুন',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -274,6 +274,8 @@ export const de: LanguageTranslation = {
redo: 'Wiederholen',
reorder_diagram: 'Diagramm neu anordnen',
highlight_overlapping_tables: 'Überlappende Tabellen hervorheben',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -264,6 +264,7 @@ export const en = {
redo: 'Redo',
reorder_diagram: 'Reorder Diagram',
highlight_overlapping_tables: 'Highlight Overlapping Tables',
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -262,6 +262,8 @@ export const es: LanguageTranslation = {
redo: 'Rehacer',
reorder_diagram: 'Reordenar Diagrama',
highlight_overlapping_tables: 'Resaltar tablas superpuestas',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -260,6 +260,8 @@ export const fr: LanguageTranslation = {
redo: 'Rétablir',
reorder_diagram: 'Réorganiser le Diagramme',
highlight_overlapping_tables: 'Surligner les tables chevauchées',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -273,6 +273,8 @@ export const gu: LanguageTranslation = {
redo: 'રીડુ',
reorder_diagram: 'ડાયાગ્રામ ફરીથી વ્યવસ્થિત કરો',
highlight_overlapping_tables: 'ઓવરલેપ કરતો ટેબલ હાઇલાઇટ કરો',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -273,6 +273,8 @@ export const hi: LanguageTranslation = {
redo: 'पुनः करें',
reorder_diagram: 'आरेख पुनः व्यवस्थित करें',
highlight_overlapping_tables: 'ओवरलैपिंग तालिकाओं को हाइलाइट करें',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -271,6 +271,8 @@ export const id_ID: LanguageTranslation = {
redo: 'Redo',
reorder_diagram: 'Atur Ulang Diagram',
highlight_overlapping_tables: 'Sorot Tabel yang Tumpang Tindih',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -278,6 +278,8 @@ export const ja: LanguageTranslation = {
reorder_diagram: 'ダイアグラムを並べ替え',
// TODO: Translate
highlight_overlapping_tables: 'Highlight Overlapping Tables',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -271,6 +271,8 @@ export const ko_KR: LanguageTranslation = {
redo: '다시 실행',
reorder_diagram: '다이어그램 재정렬',
highlight_overlapping_tables: '겹치는 테이블 강조 표시',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -276,6 +276,8 @@ export const mr: LanguageTranslation = {
redo: 'पुन्हा करा',
reorder_diagram: 'आरेख पुनःक्रमित करा',
highlight_overlapping_tables: 'ओव्हरलॅपिंग टेबल्स हायलाइट करा',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -274,6 +274,8 @@ export const ne: LanguageTranslation = {
reorder_diagram: 'पुनः क्रमबद्ध गर्नुहोस्',
highlight_overlapping_tables:
'अतिरिक्त तालिकाहरू हाइलाइट गर्नुहोस्',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -272,6 +272,8 @@ export const pt_BR: LanguageTranslation = {
redo: 'Refazer',
reorder_diagram: 'Reordenar Diagrama',
highlight_overlapping_tables: 'Destacar Tabelas Sobrepostas',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -269,6 +269,8 @@ export const ru: LanguageTranslation = {
redo: 'Вернуть',
reorder_diagram: 'Переупорядочить диаграмму',
highlight_overlapping_tables: 'Выделение перекрывающихся таблиц',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -273,6 +273,8 @@ export const te: LanguageTranslation = {
redo: 'మరలా చేయు',
reorder_diagram: 'చిత్రాన్ని పునఃసరిచేయండి',
highlight_overlapping_tables: 'అవకాశించు పట్టికలను హైలైట్ చేయండి',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -271,6 +271,8 @@ export const tr: LanguageTranslation = {
redo: 'Yinele',
reorder_diagram: 'Diyagramı Yeniden Sırala',
highlight_overlapping_tables: 'Çakışan Tabloları Vurgula',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {
database_selection: {

View File

@@ -270,6 +270,8 @@ export const uk: LanguageTranslation = {
redo: 'Повторити',
reorder_diagram: 'Перевпорядкувати діаграму',
highlight_overlapping_tables: 'Показати таблиці, що перекриваються',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -271,6 +271,8 @@ export const vi: LanguageTranslation = {
redo: 'Làm lại',
reorder_diagram: 'Sắp xếp lại sơ đồ',
highlight_overlapping_tables: 'Làm nổi bật các bảng chồng chéo',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -268,6 +268,8 @@ export const zh_CN: LanguageTranslation = {
redo: '重做',
reorder_diagram: '重新排列关系图',
highlight_overlapping_tables: '突出显示重叠的表',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -268,6 +268,8 @@ export const zh_TW: LanguageTranslation = {
redo: '重做',
reorder_diagram: '重新排列圖表',
highlight_overlapping_tables: '突出顯示重疊表格',
// TODO: Translate
filter: 'Filter Tables',
},
new_diagram_dialog: {

View File

@@ -1,4 +1,5 @@
export interface ChartDBConfig {
defaultDiagramId: string;
exportActions?: Date[];
hiddenTablesByDiagram?: Record<string, string[]>; // Maps diagram ID to array of hidden table IDs
}

View File

@@ -69,13 +69,25 @@ export const dbTableSchema: z.ZodType<DBTable> = z.object({
parentAreaId: z.string().or(z.null()).optional(),
});
export const shouldShowTableSchemaBySchemaFilter = ({
filteredSchemas,
tableSchema,
}: {
tableSchema?: string | null;
filteredSchemas?: string[];
}): boolean =>
!filteredSchemas ||
!tableSchema ||
filteredSchemas.includes(schemaNameToSchemaId(tableSchema));
export const shouldShowTablesBySchemaFilter = (
table: DBTable,
filteredSchemas?: string[]
): boolean =>
!filteredSchemas ||
!table.schema ||
filteredSchemas.includes(schemaNameToSchemaId(table.schema));
shouldShowTableSchemaBySchemaFilter({
filteredSchemas,
tableSchema: table?.schema,
});
export const decodeViewDefinition = (
databaseType: DatabaseType,

View File

@@ -0,0 +1,410 @@
import React, { useMemo, useState, useCallback, useEffect } from 'react';
import { X, Search, Eye, EyeOff, Database, Table, Funnel } 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 { 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';
export interface CanvasFilterProps {
onClose: () => void;
}
type NodeType = 'schema' | 'table';
type SchemaContext = { name: string };
type TableContext = {
tableSchema?: string | null;
hidden: boolean;
};
type NodeContext = {
schema: SchemaContext;
table: TableContext;
};
type RelevantTableData = {
id: string;
name: string;
schema?: string | null;
};
export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
const { t } = useTranslation();
const {
tables,
databaseType,
hiddenTableIds,
addHiddenTableId,
removeHiddenTableId,
filteredSchemas,
filterSchemas,
} = useChartDB();
const { fitView, setNodes } = useReactFlow();
const [searchQuery, setSearchQuery] = useState('');
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [isFilterVisible, setIsFilterVisible] = useState(false);
// Extract only the properties needed for tree data
const relevantTableData = useMemo<RelevantTableData[]>(
() =>
tables.map((table) => ({
id: table.id,
name: table.name,
schema: table.schema,
})),
[tables]
);
// Convert tables to tree nodes
const treeData = useMemo(() => {
// Group tables by schema
const tablesBySchema = new Map<string, RelevantTableData[]>();
relevantTableData.forEach((table) => {
const schema =
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) => {
const schemaId = schemaNameToSchemaId(schemaName);
const schemaHidden = filteredSchemas
? !filteredSchemas.includes(schemaId)
: false;
const schemaNode: TreeNode<NodeType, NodeContext> = {
id: `schema-${schemaName}`,
name: `${schemaName} (${schemaTables.length})`,
type: 'schema',
isFolder: true,
icon: Database,
context: { name: schemaName },
className: schemaHidden ? 'opacity-50' : '',
children: schemaTables.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(schemaNode);
});
return nodes;
}, [relevantTableData, databaseType, hiddenTableIds, filteredSchemas]);
// Initialize expanded state with all schemas expanded
useMemo(() => {
const initialExpanded: Record<string, boolean> = {};
treeData.forEach((node) => {
initialExpanded[node.id] = true;
});
setExpanded(initialExpanded);
}, [treeData]);
// Filter tree data based on search query
const filteredTreeData: TreeNode<NodeType, NodeContext>[] = useMemo(() => {
if (!searchQuery.trim()) {
return treeData;
}
const query = searchQuery.toLowerCase();
const result: TreeNode<NodeType, NodeContext>[] = [];
treeData.forEach((schemaNode) => {
const filteredChildren = schemaNode.children?.filter((tableNode) =>
tableNode.name.toLowerCase().includes(query)
);
if (filteredChildren && filteredChildren.length > 0) {
result.push({
...schemaNode,
children: filteredChildren,
});
}
});
return result;
}, [treeData, searchQuery]);
const toggleTableVisibility = useCallback(
async (tableId: string, hidden: boolean) => {
if (hidden) {
await addHiddenTableId(tableId);
} else {
await removeHiddenTableId(tableId);
}
},
[addHiddenTableId, removeHiddenTableId]
);
const focusOnTable = useCallback(
(tableId: string) => {
// Make sure the table is visible
setNodes((nodes) =>
nodes.map((node) =>
node.id === tableId
? {
...node,
hidden: false,
selected: true,
}
: {
...node,
selected: false,
}
)
);
// Focus on the table
setTimeout(() => {
fitView({
duration: 500,
maxZoom: 1,
minZoom: 1,
nodes: [
{
id: tableId,
},
],
});
}, 100);
},
[fitView, setNodes]
);
// Render component that's always visible (eye indicator)
const renderActions = useCallback(
(node: TreeNode<NodeType, NodeContext>) => {
if (node.type === 'schema') {
const schemaContext = node.context as SchemaContext;
const schemaId = schemaNameToSchemaId(schemaContext.name);
const schemaHidden = filteredSchemas
? !filteredSchemas.includes(schemaId)
: false;
return (
<Button
variant="ghost"
size="sm"
className="size-7 p-0"
onClick={(e) => {
e.stopPropagation();
// unhide all tables in this schema
node.children?.forEach((child) => {
if (
child.type === 'table' &&
hiddenTableIds?.includes(child.id)
) {
removeHiddenTableId(child.id);
}
});
if (schemaHidden) {
filterSchemas([
...(filteredSchemas ?? []),
schemaId,
]);
} else {
filterSchemas(
filteredSchemas?.filter(
(s) => s !== schemaId
) ?? []
);
}
}}
>
{schemaHidden ? (
<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 hidden = tableContext.hidden;
const tableSchema = tableContext.tableSchema;
const visibleBySchema = shouldShowTableSchemaBySchemaFilter({
tableSchema,
filteredSchemas,
});
return (
<Button
variant="ghost"
size="sm"
className="size-7 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);
}
}}
>
{hidden || !visibleBySchema ? (
<EyeOff className="size-3.5 text-muted-foreground" />
) : (
<Eye className="size-3.5" />
)}
</Button>
);
}
return null;
},
[
toggleTableVisibility,
filteredSchemas,
filterSchemas,
treeData,
hiddenTableIds,
addHiddenTableId,
removeHiddenTableId,
]
);
// Handle node click
const handleNodeClick = useCallback(
(node: TreeNode<NodeType, NodeContext>) => {
if (node.type === 'table') {
const tableContext = node.context as TableContext;
const tableSchema = tableContext.tableSchema;
const visibleBySchema = shouldShowTableSchemaBySchemaFilter({
tableSchema,
filteredSchemas,
});
// Only focus if neither table is hidden nor filtered by schema
if (!tableContext.hidden && visibleBySchema) {
focusOnTable(node.id);
}
}
},
[focusOnTable, filteredSchemas]
);
// Animate in on mount
useEffect(() => {
setIsFilterVisible(true);
}, []);
return (
<div
className={`absolute right-2 top-2 z-10 flex flex-col rounded-lg border bg-background/85 shadow-lg backdrop-blur-sm transition-all duration-300 md:right-4 md:top-4 ${
isFilterVisible
? 'translate-x-0 opacity-100'
: 'translate-x-full opacity-0'
} size-[calc(100%-1rem)] max-w-sm md:h-[calc(100%-2rem)] md:w-80`}
>
{/* Header */}
<div className="flex items-center justify-between rounded-t-lg border-b px-2 py-1">
<div className="flex items-center gap-2">
<Funnel className="size-3.5 text-muted-foreground md:size-4" />
<h2 className="text-sm font-medium">
{t('canvas_filter.title', 'Filter Tables')}
</h2>
</div>
<Button
variant="ghost"
size="sm"
className="size-8 p-0"
onClick={onClose}
>
<X className="size-4" />
</Button>
</div>
{/* Search */}
<div className="border-b p-2">
<div className="relative h-9">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t(
'canvas_filter.search_placeholder',
'Search tables...'
)}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-full pl-9"
/>
</div>
</div>
{/* Table Tree */}
<div className="flex-1 overflow-y-auto rounded-b-lg">
<TreeView
data={filteredTreeData}
onNodeClick={handleNodeClick}
renderActionsComponent={renderActions}
defaultFolderIcon={Database}
defaultIcon={Table}
expanded={expanded}
setExpanded={setExpanded}
className="py-2"
/>
</div>
</div>
);
};

View File

@@ -84,6 +84,8 @@ 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 { CanvasFilter } from './canvas-filter/canvas-filter';
import { useHotkeys } from 'react-hotkeys-hook';
const HIGHLIGHTED_EDGE_Z_INDEX = 1;
const DEFAULT_EDGE_Z_INDEX = 0;
@@ -108,7 +110,13 @@ const initialEdges: EdgeType[] = [];
const tableToTableNode = (
table: DBTable,
filteredSchemas?: string[]
{
filteredSchemas,
hiddenTableIds,
}: {
filteredSchemas?: string[];
hiddenTableIds?: string[];
}
): TableNodeType => {
// Always use absolute position for now
const position = { x: table.x, y: table.y };
@@ -122,7 +130,9 @@ const tableToTableNode = (
isOverlapping: false,
},
width: table.width ?? MIN_TABLE_SIZE,
hidden: !shouldShowTablesBySchemaFilter(table, filteredSchemas),
hidden:
!shouldShowTablesBySchemaFilter(table, filteredSchemas) ||
(hiddenTableIds?.includes(table.id) ?? false),
};
};
@@ -165,6 +175,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
readonly,
removeArea,
updateArea,
hiddenTableIds,
} = useChartDB();
const { showSidePanel } = useLayout();
const { effectiveTheme } = useTheme();
@@ -174,13 +185,21 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
const { isMd: isDesktop } = useBreakpoint('md');
const [highlightOverlappingTables, setHighlightOverlappingTables] =
useState(false);
const { reorderTables, fitView, setOverlapGraph, overlapGraph } =
useCanvas();
const {
reorderTables,
fitView,
setOverlapGraph,
overlapGraph,
showFilter,
setShowFilter,
} = useCanvas();
const [isInitialLoadingNodes, setIsInitialLoadingNodes] = useState(true);
const [nodes, setNodes, onNodesChange] = useNodesState<NodeType>(
initialTables.map((table) => tableToTableNode(table, filteredSchemas))
initialTables.map((table) =>
tableToTableNode(table, { filteredSchemas, hiddenTableIds })
)
);
const [edges, setEdges, onEdgesChange] =
useEdgesState<EdgeType>(initialEdges);
@@ -193,12 +212,12 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
useEffect(() => {
const initialNodes = initialTables.map((table) =>
tableToTableNode(table, filteredSchemas)
tableToTableNode(table, { filteredSchemas, hiddenTableIds })
);
if (equal(initialNodes, nodes)) {
setIsInitialLoadingNodes(false);
}
}, [initialTables, nodes, filteredSchemas]);
}, [initialTables, nodes, filteredSchemas, hiddenTableIds]);
useEffect(() => {
if (!isInitialLoadingNodes) {
@@ -361,7 +380,10 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
...tables.map((table) => {
const isOverlapping =
(overlapGraph.graph.get(table.id) ?? []).length > 0;
const node = tableToTableNode(table, filteredSchemas);
const node = tableToTableNode(table, {
filteredSchemas,
hiddenTableIds,
});
return {
...node,
@@ -387,6 +409,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
areas,
setNodes,
filteredSchemas,
hiddenTableIds,
overlapGraph.lastUpdated,
overlapGraph.graph,
highlightOverlappingTables,
@@ -1040,6 +1063,17 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
const shiftPressed = useKeyPress('Shift');
const operatingSystem = getOperatingSystem();
useHotkeys(
operatingSystem === 'mac' ? 'meta+f' : 'ctrl+f',
() => {
setShowFilter((prev) => !prev);
},
{
preventDefault: true,
},
[]
);
return (
<CanvasContextMenu>
<div className="relative flex h-full" id="canvas">
@@ -1216,6 +1250,9 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
gap={16}
size={1}
/>
{showFilter ? (
<CanvasFilter onClose={() => setShowFilter(false)} />
) : null}
</ReactFlow>
<MarkerDefinitions />
</div>

View File

@@ -1,10 +1,9 @@
import React, { useCallback, useState } from 'react';
import { Card, CardContent } from '@/components/card/card';
import { ZoomIn, ZoomOut, Save, Redo, Undo, Scan } from 'lucide-react';
import { ZoomIn, ZoomOut, Funnel, Redo, Undo, Scan } from 'lucide-react';
import { Separator } from '@/components/separator/separator';
import { ToolbarButton } from './toolbar-button';
import { useHistory } from '@/hooks/use-history';
import { useChartDB } from '@/hooks/use-chartdb';
import { useOnViewportChange, useReactFlow } from '@xyflow/react';
import {
Tooltip,
@@ -16,6 +15,9 @@ import { Button } from '@/components/button/button';
import { keyboardShortcutsForOS } from '@/context/keyboard-shortcuts-context/keyboard-shortcuts';
import { KeyboardShortcutAction } from '@/context/keyboard-shortcuts-context/keyboard-shortcuts';
import { useIsLostInCanvas } from '../hooks/use-is-lost-in-canvas';
import { useCanvas } from '@/hooks/use-canvas';
import { useChartDB } from '@/hooks/use-chartdb';
import { cn } from '@/lib/utils';
const convertToPercentage = (value: number) => `${Math.round(value * 100)}%`;
@@ -23,13 +25,18 @@ export interface ToolbarProps {
readonly?: boolean;
}
export const Toolbar: React.FC<ToolbarProps> = ({ readonly }) => {
const { updateDiagramUpdatedAt } = useChartDB();
export const Toolbar: React.FC<ToolbarProps> = () => {
const { t } = useTranslation();
const { redo, undo, hasRedo, hasUndo } = useHistory();
const { getZoom, zoomIn, zoomOut, fitView } = useReactFlow();
const [zoom, setZoom] = useState<string>(convertToPercentage(getZoom()));
const { isLostInCanvas } = useIsLostInCanvas();
const { setShowFilter } = useCanvas();
const { hiddenTableIds } = useChartDB();
const toggleFilter = useCallback(() => {
setShowFilter((prev) => !prev);
}, [setShowFilter]);
useOnViewportChange({
onChange: ({ zoom }) => {
@@ -66,33 +73,36 @@ export const Toolbar: React.FC<ToolbarProps> = ({ readonly }) => {
<div className="px-1">
<Card className="h-[44px] bg-secondary p-0 shadow-none">
<CardContent className="flex h-full flex-row items-center p-1">
{!readonly ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<span>
<ToolbarButton
onClick={updateDiagramUpdatedAt}
>
<Save />
</ToolbarButton>
</span>
</TooltipTrigger>
<TooltipContent>
{t('toolbar.save')}
<span className="ml-2 text-muted-foreground">
<Tooltip>
<TooltipTrigger asChild>
<span>
<ToolbarButton
onClick={toggleFilter}
className={cn(
'transition-all duration-200',
{
keyboardShortcutsForOS[
KeyboardShortcutAction
.SAVE_DIAGRAM
].keyCombinationLabel
'bg-pink-500 text-white hover:bg-pink-600 hover:text-white':
(hiddenTableIds ?? []).length >
0,
}
</span>
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" />
</>
) : null}
)}
>
<Funnel />
</ToolbarButton>
</span>
</TooltipTrigger>
<TooltipContent>
{t('toolbar.filter')}
<span className="ml-2 text-muted-foreground">
{
keyboardShortcutsForOS[
KeyboardShortcutAction.TOGGLE_FILTER
].keyCombinationLabel
}
</span>
</TooltipContent>
</Tooltip>
<Separator orientation="vertical" />
<Tooltip>
<TooltipTrigger asChild>
<span>

View File

@@ -98,16 +98,16 @@ export const TablesSection: React.FC<TablesSectionProps> = () => {
const operatingSystem = useMemo(() => getOperatingSystem(), []);
useHotkeys(
operatingSystem === 'mac' ? 'meta+f' : 'ctrl+f',
() => {
filterInputRef.current?.focus();
},
{
preventDefault: true,
},
[filterInputRef]
);
// useHotkeys(
// operatingSystem === 'mac' ? 'meta+f' : 'ctrl+f',
// () => {
// filterInputRef.current?.focus();
// },
// {
// preventDefault: true,
// },
// [filterInputRef]
// );
useHotkeys(
operatingSystem === 'mac' ? 'meta+p' : 'ctrl+p',