diff --git a/src/components/select-box/select-box.tsx b/src/components/select-box/select-box.tsx new file mode 100644 index 00000000..aef70fc1 --- /dev/null +++ b/src/components/select-box/select-box.tsx @@ -0,0 +1,293 @@ +import { CaretSortIcon, CheckIcon, Cross2Icon } from '@radix-ui/react-icons'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/command/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/popover/popover'; +import { ScrollArea } from '@/components/scroll-area/scroll-area'; + +export interface SelectBoxOption { + value: string; + label: string; + description?: string; +} + +interface SelectBoxProps { + options: SelectBoxOption[]; + value?: string[] | string; + onChange?: (values: string[] | string) => void; + placeholder?: string; + inputPlaceholder?: string; + emptyPlaceholder?: string; + className?: string; + multiple?: boolean; + oneLine?: boolean; + selectAll?: boolean; + deselectAll?: boolean; + onSelectAll?: () => void; + onDeselectAll?: () => void; +} + +export const SelectBox = React.forwardRef( + ( + { + inputPlaceholder, + emptyPlaceholder, + placeholder, + className, + options, + value, + onChange, + multiple, + oneLine, + selectAll, + deselectAll, + onSelectAll, + onDeselectAll, + }, + ref + ) => { + const [searchTerm, setSearchTerm] = React.useState(''); + const [isOpen, setIsOpen] = React.useState(false); + + const handleSelect = React.useCallback( + (selectedValue: string) => { + if (multiple) { + const newValue = + value?.includes(selectedValue) && Array.isArray(value) + ? value.filter((v) => v !== selectedValue) + : [...(value ?? []), selectedValue]; + onChange?.(newValue); + } else { + onChange?.(selectedValue); + setIsOpen(false); + } + }, + [multiple, onChange, value] + ); + + const handleClear = React.useCallback(() => { + onChange?.(multiple ? [] : ''); + }, [multiple, onChange]); + + const selectedMultipleOptions = React.useMemo( + () => + options + .filter( + (option) => + Array.isArray(value) && value.includes(option.value) + ) + .map((option) => ( + + {option.label} + { + e.preventDefault(); + handleSelect(option.value); + }} + className="flex items-center rounded-sm px-px text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground" + > + + + + )), + [options, value, handleSelect, oneLine] + ); + + const isAllSelected = React.useMemo( + () => + multiple && + Array.isArray(value) && + options.every((option) => value.includes(option.value)), + [options, value, multiple] + ); + + return ( + + +
+
+ {value && value.length > 0 ? ( + multiple ? ( + oneLine ? ( +
+ {selectedMultipleOptions} +
+ ) : ( + selectedMultipleOptions + ) + ) : ( + options.find((opt) => opt.value === value) + ?.label + ) + ) : ( + + {placeholder} + + )} +
+
+ {value && value.length > 0 ? ( +
{ + e.preventDefault(); + handleClear(); + }} + > + Clear + {/* */} +
+ ) : ( +
+ +
+ )} +
+
+
+ + +
+ setSearchTerm(e)} + ref={ref} + placeholder={inputPlaceholder ?? 'Search...'} + className="h-9" + /> + {searchTerm && ( +
setSearchTerm('')} + > + +
+ )} + {!searchTerm && + multiple && + selectAll && + !isAllSelected && ( +
onSelectAll?.()} + > + Select All +
+ )} + {!searchTerm && + multiple && + deselectAll && + isAllSelected && ( +
onDeselectAll?.()} + > + Deselect All +
+ )} +
+ + {emptyPlaceholder ?? 'No results found.'} + + + +
+ + + {options.map((option) => { + const isSelected = + Array.isArray(value) && + value.includes(option.value); + return ( + + handleSelect( + option.value + ) + } + > + {multiple && ( +
+ +
+ )} +
+ + {option.label} + + {option.description && ( + + { + option.description + } + + )} +
+ {!multiple && + option.value === + value && ( + + )} +
+ ); + })} +
+
+
+
+
+
+
+ ); + } +); + +SelectBox.displayName = 'SelectBox'; diff --git a/src/context/chartdb-context/chartdb-context.tsx b/src/context/chartdb-context/chartdb-context.tsx index 81292a18..e033d608 100644 --- a/src/context/chartdb-context/chartdb-context.tsx +++ b/src/context/chartdb-context/chartdb-context.tsx @@ -7,15 +7,20 @@ import { DBIndex } from '@/lib/domain/db-index'; import { DBRelationship } from '@/lib/domain/db-relationship'; import { Diagram } from '@/lib/domain/diagram'; import { DatabaseEdition } from '@/lib/domain/database-edition'; +import { DBSchema } from '@/lib/domain/db-schema'; export interface ChartDBContext { diagramId: string; diagramName: string; databaseType: DatabaseType; tables: DBTable[]; + schemas: DBSchema[]; relationships: DBRelationship[]; currentDiagram: Diagram; + filteredSchemas?: string[]; + filterSchemas: (schemaIds: string[]) => void; + // General operations updateDiagramId: (id: string) => Promise; updateDiagramName: ( @@ -129,6 +134,9 @@ export const chartDBContext = createContext({ diagramId: '', tables: [], relationships: [], + schemas: [], + filteredSchemas: [], + filterSchemas: emptyFn, currentDiagram: { id: '', name: '', diff --git a/src/context/chartdb-context/chartdb-provider.tsx b/src/context/chartdb-context/chartdb-provider.tsx index 2b0a29af..1cb19104 100644 --- a/src/context/chartdb-context/chartdb-provider.tsx +++ b/src/context/chartdb-context/chartdb-provider.tsx @@ -13,12 +13,16 @@ import { Diagram } from '@/lib/domain/diagram'; import { useNavigate } from 'react-router-dom'; import { useConfig } from '@/hooks/use-config'; import { DatabaseEdition } from '@/lib/domain/database-edition'; +import { DBSchema, schemaNameToSchemaId } from '@/lib/domain/db-schema'; +import { useLocalConfig } from '@/hooks/use-local-config'; +import { defaultSchemas } from '@/lib/data/default-schemas'; export const ChartDBProvider: React.FC = ({ children, }) => { const db = useStorage(); const navigate = useNavigate(); + const { setSchemasFilter, schemasFilter } = useLocalConfig(); const { addUndoAction, resetRedoStack, resetUndoStack } = useRedoUndoStack(); const [diagramId, setDiagramId] = useState(''); @@ -35,6 +39,56 @@ export const ChartDBProvider: React.FC = ({ const [tables, setTables] = useState([]); const [relationships, setRelationships] = useState([]); + const defaultSchemaName = defaultSchemas[databaseType]; + + const schemas = useMemo( + () => + databaseType === DatabaseType.POSTGRESQL || + databaseType === DatabaseType.SQL_SERVER + ? [ + ...new Set( + tables + .map((table) => table.schema) + .filter((schema) => !!schema) as string[] + ), + ] + .sort((a, b) => + a === defaultSchemaName ? -1 : a.localeCompare(b) + ) + .map( + (schema): DBSchema => ({ + id: schemaNameToSchemaId(schema), + name: schema, + tableCount: tables.filter( + (table) => table.schema === schema + ).length, + }) + ) + : [], + [tables, defaultSchemaName, databaseType] + ); + + const filterSchemas: ChartDBContext['filterSchemas'] = useCallback( + (schemaIds) => { + setSchemasFilter((prev) => ({ + ...prev, + [diagramId]: schemaIds, + })); + }, + [diagramId, setSchemasFilter] + ); + + const filteredSchemas: ChartDBContext['filteredSchemas'] = useMemo( + () => + schemas.length > 0 + ? (schemasFilter[diagramId] ?? [ + schemas.find((s) => s.name === defaultSchemaName)?.id ?? + schemas[0]?.id, + ]) + : undefined, + [schemasFilter, diagramId, schemas, defaultSchemaName] + ); + const currentDiagram: Diagram = useMemo( () => ({ id: diagramId, @@ -1010,6 +1064,9 @@ export const ChartDBProvider: React.FC = ({ tables, relationships, currentDiagram, + schemas, + filteredSchemas, + filterSchemas, updateDiagramId, updateDiagramName, loadDiagram, diff --git a/src/context/local-config-context/local-config-context.tsx b/src/context/local-config-context/local-config-context.tsx new file mode 100644 index 00000000..0e689786 --- /dev/null +++ b/src/context/local-config-context/local-config-context.tsx @@ -0,0 +1,29 @@ +import { createContext } from 'react'; +import { emptyFn } from '@/lib/utils'; +import { Theme } from '../theme-context/theme-context'; + +export type ScrollAction = 'pan' | 'zoom'; + +export type SchemasFilter = Record; + +export interface LocalConfigContext { + theme: Theme; + setTheme: (theme: Theme) => void; + + scrollAction: ScrollAction; + setScrollAction: (action: ScrollAction) => void; + + schemasFilter: SchemasFilter; + setSchemasFilter: React.Dispatch>; +} + +export const LocalConfigContext = createContext({ + theme: 'system', + setTheme: emptyFn, + + scrollAction: 'pan', + setScrollAction: emptyFn, + + schemasFilter: {}, + setSchemasFilter: emptyFn, +}); diff --git a/src/context/local-config-context/local-config-provider.tsx b/src/context/local-config-context/local-config-provider.tsx new file mode 100644 index 00000000..0e40c28a --- /dev/null +++ b/src/context/local-config-context/local-config-provider.tsx @@ -0,0 +1,52 @@ +import React, { useEffect } from 'react'; +import { + LocalConfigContext, + SchemasFilter, + ScrollAction, +} from './local-config-context'; +import { Theme } from '../theme-context/theme-context'; + +export const LocalConfigProvider: React.FC = ({ + children, +}) => { + const [theme, setTheme] = React.useState( + (localStorage.getItem('theme') as Theme) || 'system' + ); + + const [scrollAction, setScrollAction] = React.useState( + (localStorage.getItem('scroll_action') as ScrollAction) || 'pan' + ); + + const [schemasFilter, setSchemasFilter] = React.useState( + JSON.parse( + localStorage.getItem('schemas_filter') || '{}' + ) as SchemasFilter + ); + + useEffect(() => { + localStorage.setItem('theme', theme); + }, [theme]); + + useEffect(() => { + localStorage.setItem('scroll_action', scrollAction); + }, [scrollAction]); + + useEffect(() => { + localStorage.setItem('schemas_filter', JSON.stringify(schemasFilter)); + }, [schemasFilter]); + + return ( + + {children} + + ); +}; diff --git a/src/context/scroll-context/scroll-context.tsx b/src/context/scroll-context/scroll-context.tsx deleted file mode 100644 index 6ce2da4b..00000000 --- a/src/context/scroll-context/scroll-context.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createContext } from 'react'; -import { emptyFn } from '@/lib/utils'; - -export type ScrollActionType = 'pan' | 'zoom'; - -export interface ScrollContext { - scrollAction: ScrollActionType; - setScrollAction: (action: ScrollActionType) => void; -} - -export const ScrollContext = createContext({ - scrollAction: 'pan', - setScrollAction: emptyFn, -}); diff --git a/src/context/scroll-context/scroll-provider.tsx b/src/context/scroll-context/scroll-provider.tsx deleted file mode 100644 index 3684fe12..00000000 --- a/src/context/scroll-context/scroll-provider.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { ScrollContext, ScrollActionType } from './scroll-context'; - -export const ScrollProvider: React.FC = ({ - children, -}) => { - const [scrollAction, setScrollAction] = useState(() => { - const savedAction = localStorage.getItem( - 'scrollAction' - ) as ScrollActionType | null; - return savedAction || 'pan'; - }); - - useEffect(() => { - localStorage.setItem('scrollAction', scrollAction); - }, [scrollAction]); - - return ( - - {children} - - ); -}; diff --git a/src/context/theme-context/theme-context.tsx b/src/context/theme-context/theme-context.tsx index df1a62c7..0728ab93 100644 --- a/src/context/theme-context/theme-context.tsx +++ b/src/context/theme-context/theme-context.tsx @@ -1,13 +1,13 @@ import { createContext } from 'react'; import { emptyFn } from '@/lib/utils'; -export type ThemeType = 'light' | 'dark' | 'system'; -export type EffectiveThemeType = Exclude; +export type Theme = 'light' | 'dark' | 'system'; +export type EffectiveTheme = Exclude; export interface ThemeContext { - theme: ThemeType; - setTheme: (theme: ThemeType) => void; - effectiveTheme: EffectiveThemeType; + theme: Theme; + setTheme: (theme: Theme) => void; + effectiveTheme: EffectiveTheme; } export const ThemeContext = createContext({ diff --git a/src/context/theme-context/theme-provider.tsx b/src/context/theme-context/theme-provider.tsx index 4284ea82..c4e89390 100644 --- a/src/context/theme-context/theme-provider.tsx +++ b/src/context/theme-context/theme-provider.tsx @@ -1,14 +1,12 @@ import React, { useEffect, useState } from 'react'; -import { EffectiveThemeType, ThemeContext, ThemeType } from './theme-context'; +import { EffectiveTheme, ThemeContext } from './theme-context'; import { useMediaQuery } from 'react-responsive'; +import { useLocalConfig } from '@/hooks/use-local-config'; export const ThemeProvider: React.FC = ({ children, }) => { - const [theme, setTheme] = useState(() => { - const savedTheme = localStorage.getItem('theme') as ThemeType | null; - return savedTheme || 'system'; - }); + const { theme, setTheme } = useLocalConfig(); const isDarkSystemTheme = useMediaQuery({ query: '(prefers-color-scheme: dark)', }); @@ -16,10 +14,9 @@ export const ThemeProvider: React.FC = ({ const systemTheme = isDarkSystemTheme ? 'dark' : 'light'; const [effectiveTheme, setEffectiveTheme] = - useState(systemTheme); + useState(systemTheme); useEffect(() => { - localStorage.setItem('theme', theme); setEffectiveTheme(theme === 'system' ? systemTheme : theme); }, [theme, systemTheme]); diff --git a/src/hooks/use-local-config.ts b/src/hooks/use-local-config.ts new file mode 100644 index 00000000..d7228043 --- /dev/null +++ b/src/hooks/use-local-config.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { LocalConfigContext } from '@/context/local-config-context/local-config-context'; + +export const useLocalConfig = () => useContext(LocalConfigContext); diff --git a/src/hooks/use-scroll-action.ts b/src/hooks/use-scroll-action.ts deleted file mode 100644 index a88b3ac4..00000000 --- a/src/hooks/use-scroll-action.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { ScrollContext } from '@/context/scroll-context/scroll-context'; -import { useContext } from 'react'; - -export const useScrollAction = () => useContext(ScrollContext); diff --git a/src/lib/data/default-schemas.ts b/src/lib/data/default-schemas.ts new file mode 100644 index 00000000..153cd8a3 --- /dev/null +++ b/src/lib/data/default-schemas.ts @@ -0,0 +1,6 @@ +import { DatabaseType } from '../domain/database-type'; + +export const defaultSchemas: { [key in DatabaseType]?: string } = { + [DatabaseType.POSTGRESQL]: 'public', + [DatabaseType.SQL_SERVER]: 'dbo', +}; diff --git a/src/lib/databases.ts b/src/lib/databases.ts index 5370fa38..cde170fc 100644 --- a/src/lib/databases.ts +++ b/src/lib/databases.ts @@ -15,7 +15,7 @@ import SqliteLogo2 from '@/assets/sqlite_logo_2.png'; import SqlServerLogo2 from '@/assets/sql_server_logo_2.png'; import GeneralDBLogo2 from '@/assets/general_db_logo_2.png'; import { DatabaseType } from './domain/database-type'; -import { EffectiveThemeType } from '@/context/theme-context/theme-context'; +import { EffectiveTheme } from '@/context/theme-context/theme-context'; export const databaseTypeToLabelMap: Record = { [DatabaseType.GENERIC]: 'Generic', @@ -46,7 +46,7 @@ export const databaseDarkLogoMap: Record = { export const getDatabaseLogo = ( databaseType: DatabaseType, - theme: EffectiveThemeType + theme: EffectiveTheme ) => theme === 'dark' ? databaseDarkLogoMap[databaseType] diff --git a/src/lib/domain/db-schema.ts b/src/lib/domain/db-schema.ts new file mode 100644 index 00000000..cb1bda30 --- /dev/null +++ b/src/lib/domain/db-schema.ts @@ -0,0 +1,8 @@ +export interface DBSchema { + id: string; + name: string; + tableCount: number; +} + +export const schemaNameToSchemaId = (schema: string): string => + schema.toLowerCase().split(' ').join('_'); diff --git a/src/lib/domain/db-table.ts b/src/lib/domain/db-table.ts index 88be119f..126dff86 100644 --- a/src/lib/domain/db-table.ts +++ b/src/lib/domain/db-table.ts @@ -22,6 +22,7 @@ export interface DBTable { createdAt: number; width?: number; comments?: string; + hidden?: boolean; } export const createTablesFromMetadata = ({ diff --git a/src/pages/editor-page/canvas/canvas.tsx b/src/pages/editor-page/canvas/canvas.tsx index 6bec2aa8..f83b548c 100644 --- a/src/pages/editor-page/canvas/canvas.tsx +++ b/src/pages/editor-page/canvas/canvas.tsx @@ -31,13 +31,17 @@ import { Badge } from '@/components/badge/badge'; import { useTheme } from '@/hooks/use-theme'; import { useTranslation } from 'react-i18next'; import { DBTable } from '@/lib/domain/db-table'; -import { useScrollAction } from '@/hooks/use-scroll-action'; +import { useLocalConfig } from '@/hooks/use-local-config'; +import { schemaNameToSchemaId } from '@/lib/domain/db-schema'; type AddEdgeParams = Parameters>[0]; const initialEdges: TableEdgeType[] = []; -const tableToTableNode = (table: DBTable): TableNodeType => ({ +const tableToTableNode = ( + table: DBTable, + filteredSchemas?: string[] +): TableNodeType => ({ id: table.id, type: 'table', position: { x: table.x, y: table.y }, @@ -45,6 +49,10 @@ const tableToTableNode = (table: DBTable): TableNodeType => ({ table, }, width: table.width ?? MIN_TABLE_SIZE, + hidden: + !!table.schema && + !!filteredSchemas && + !filteredSchemas.includes(schemaNameToSchemaId(table.schema)), }); export interface CanvasProps { @@ -53,6 +61,7 @@ export interface CanvasProps { export const Canvas: React.FC = ({ initialTables }) => { const { getEdge, getInternalNode, fitView } = useReactFlow(); + const { filteredSchemas } = useChartDB(); const { toast } = useToast(); const { t } = useTranslation(); const { @@ -65,14 +74,14 @@ export const Canvas: React.FC = ({ initialTables }) => { } = useChartDB(); const { showSidePanel } = useLayout(); const { effectiveTheme } = useTheme(); - const { scrollAction } = useScrollAction(); + const { scrollAction } = useLocalConfig(); const { isMd: isDesktop } = useBreakpoint('md'); const nodeTypes = useMemo(() => ({ table: TableNode }), []); const edgeTypes = useMemo(() => ({ 'table-edge': TableEdge }), []); const [isInitialLoadingNodes, setIsInitialLoadingNodes] = useState(true); const [nodes, setNodes, onNodesChange] = useNodesState( - initialTables.map(tableToTableNode) + initialTables.map((table) => tableToTableNode(table, filteredSchemas)) ); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -82,11 +91,13 @@ export const Canvas: React.FC = ({ initialTables }) => { }, [initialTables]); useEffect(() => { - const initialNodes = initialTables.map(tableToTableNode); + const initialNodes = initialTables.map((table) => + tableToTableNode(table, filteredSchemas) + ); if (equal(initialNodes, nodes)) { setIsInitialLoadingNodes(false); } - }, [initialTables, nodes]); + }, [initialTables, nodes, filteredSchemas]); useEffect(() => { if (!isInitialLoadingNodes) { @@ -118,8 +129,10 @@ export const Canvas: React.FC = ({ initialTables }) => { }, [relationships, setEdges]); useEffect(() => { - setNodes(tables.map(tableToTableNode)); - }, [tables, setNodes]); + setNodes( + tables.map((table) => tableToTableNode(table, filteredSchemas)) + ); + }, [tables, setNodes, filteredSchemas]); const onConnectHandler = useCallback( async (params: AddEdgeParams) => { diff --git a/src/pages/editor-page/side-panel/relationships-section/relationships-section.tsx b/src/pages/editor-page/side-panel/relationships-section/relationships-section.tsx index 2cf8b9ea..df7cc513 100644 --- a/src/pages/editor-page/side-panel/relationships-section/relationships-section.tsx +++ b/src/pages/editor-page/side-panel/relationships-section/relationships-section.tsx @@ -18,20 +18,29 @@ import { export interface RelationshipsSectionProps {} export const RelationshipsSection: React.FC = () => { - const { relationships } = useChartDB(); + const { relationships, filteredSchemas } = useChartDB(); const [filterText, setFilterText] = React.useState(''); const { closeAllRelationshipsInSidebar } = useLayout(); const { t } = useTranslation(); const filteredRelationships = useMemo(() => { - const filter: (relationship: DBRelationship) => boolean = ( + const filterName: (relationship: DBRelationship) => boolean = ( relationship ) => !filterText?.trim?.() || relationship.name.toLowerCase().includes(filterText.toLowerCase()); - return relationships.filter(filter); - }, [relationships, filterText]); + const filterSchema: (relationship: DBRelationship) => boolean = ( + relationship + ) => + !filteredSchemas || + !relationship.sourceSchema || + !relationship.targetSchema || + (filteredSchemas.includes(relationship.sourceSchema) && + filteredSchemas.includes(relationship.targetSchema)); + + return relationships.filter(filterSchema).filter(filterName); + }, [relationships, filterText, filteredSchemas]); return (
diff --git a/src/pages/editor-page/side-panel/side-panel.tsx b/src/pages/editor-page/side-panel/side-panel.tsx index 792d2e41..96a8bcaa 100644 --- a/src/pages/editor-page/side-panel/side-panel.tsx +++ b/src/pages/editor-page/side-panel/side-panel.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { Select, SelectContent, @@ -12,14 +12,65 @@ import { RelationshipsSection } from './relationships-section/relationships-sect import { useLayout } from '@/hooks/use-layout'; import { SidebarSection } from '@/context/layout-context/layout-context'; import { useTranslation } from 'react-i18next'; +import { SelectBox, SelectBoxOption } from '@/components/select-box/select-box'; +import { useChartDB } from '@/hooks/use-chartdb'; export interface SidePanelProps {} export const SidePanel: React.FC = () => { const { t } = useTranslation(); + const { schemas, filterSchemas, filteredSchemas } = useChartDB(); const { selectSidebarSection, selectedSidebarSection } = useLayout(); + + const schemasOptions: SelectBoxOption[] = useMemo( + () => + schemas.map( + (schema): SelectBoxOption => ({ + label: schema.name, + value: schema.id, + description: `(${schema.tableCount} tables)`, + }) + ), + [schemas] + ); + + const deselectAllSchemas = () => { + filterSchemas([]); + }; + + const selectAllSchemas = () => { + filterSchemas(schemas.map((schema) => schema.id)); + }; + return (