diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c032bcfe..959996a8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3730,6 +3730,146 @@ pub async fn get_view_columns( result } +#[tauri::command] +pub async fn get_materialized_views( + app: AppHandle, + connection_id: String, + schema: Option, +) -> Result, String> { + log::info!("Fetching materialized views for connection: {}", connection_id); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv.get_materialized_views(¶ms, schema.as_deref()).await; + + match &result { + Ok(views) => log::info!( + "Retrieved {} materialized views from {}", + views.len(), + params.database + ), + Err(e) => log::error!( + "Failed to get materialized views from {}: {}", + params.database, + e + ), + } + + result +} + +#[tauri::command] +pub async fn get_materialized_view_columns( + app: AppHandle, + connection_id: String, + view_name: String, + schema: Option, +) -> Result, String> { + log::info!( + "Fetching materialized view columns for: {} on connection: {}", + view_name, + connection_id + ); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv + .get_materialized_view_columns(¶ms, &view_name, schema.as_deref()) + .await; + + match &result { + Ok(columns) => log::info!( + "Retrieved {} columns for materialized view {}", + columns.len(), + view_name + ), + Err(e) => log::error!( + "Failed to get materialized view columns for {}: {}", + view_name, + e + ), + } + + result +} + +#[tauri::command] +pub async fn get_materialized_view_definition( + app: AppHandle, + connection_id: String, + view_name: String, + schema: Option, +) -> Result { + log::info!( + "Fetching materialized view definition for: {} on connection: {}", + view_name, + connection_id + ); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv + .get_materialized_view_definition(¶ms, &view_name, schema.as_deref()) + .await; + + match &result { + Ok(_) => log::info!( + "Successfully retrieved materialized view definition for {}", + view_name + ), + Err(e) => log::error!( + "Failed to get materialized view definition for {}: {}", + view_name, + e + ), + } + + result +} + +#[tauri::command] +pub async fn refresh_materialized_view( + app: AppHandle, + connection_id: String, + view_name: String, + schema: Option, +) -> Result<(), String> { + log::info!( + "Refreshing materialized view: {} on connection: {}", + view_name, + connection_id + ); + + let saved_conn = find_connection_by_id(&app, &connection_id)?; + let expanded_params = expand_ssh_connection_params(&app, &saved_conn.params).await?; + let expanded_params = expand_k8s_connection_params(&app, &expanded_params).await?; + let params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + + let drv = driver_for(&saved_conn.params.driver).await?; + let result = drv + .refresh_materialized_view(¶ms, &view_name, schema.as_deref()) + .await; + + match &result { + Ok(_) => log::info!("Successfully refreshed materialized view: {}", view_name), + Err(e) => log::error!("Failed to refresh materialized view {}: {}", view_name, e), + } + + result +} + #[tauri::command] pub async fn get_triggers( app: AppHandle, diff --git a/src-tauri/src/drivers/driver_trait.rs b/src-tauri/src/drivers/driver_trait.rs index e66e4f6d..4eccf61a 100644 --- a/src-tauri/src/drivers/driver_trait.rs +++ b/src-tauri/src/drivers/driver_trait.rs @@ -56,6 +56,11 @@ pub struct DriverCapabilities { pub schemas: bool, /// Supports views. pub views: bool, + /// Supports materialized views (e.g. PostgreSQL). When `false`, the + /// frontend skips the materialized-view metadata fetch entirely (so + /// other drivers don't pay for an empty round-trip). Defaults to `false`. + #[serde(default)] + pub materialized_views: bool, /// Supports stored procedures and functions. pub routines: bool, /// File-based database (e.g. SQLite); no host/port required. @@ -336,6 +341,46 @@ pub trait DatabaseDriver: Send + Sync { schema: Option<&str>, ) -> Result<(), String>; + // --- Materialized views ------------------------------------------------- + // Default impls return empty / unsupported so drivers without materialized + // views (MySQL, SQLite, plugins) need no changes; the UI hides the group + // unless `DriverCapabilities::materialized_views` is set. + + async fn get_materialized_views( + &self, + _params: &ConnectionParams, + _schema: Option<&str>, + ) -> Result, String> { + Ok(Vec::new()) + } + + async fn get_materialized_view_columns( + &self, + _params: &ConnectionParams, + _view_name: &str, + _schema: Option<&str>, + ) -> Result, String> { + Ok(Vec::new()) + } + + async fn get_materialized_view_definition( + &self, + _params: &ConnectionParams, + _view_name: &str, + _schema: Option<&str>, + ) -> Result { + Err("Materialized views are not supported by this driver".to_string()) + } + + async fn refresh_materialized_view( + &self, + _params: &ConnectionParams, + _view_name: &str, + _schema: Option<&str>, + ) -> Result<(), String> { + Err("Materialized views are not supported by this driver".to_string()) + } + // --- Routines ----------------------------------------------------------- async fn get_routines( diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index 07242ad0..63935cc6 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -1489,6 +1489,7 @@ impl MysqlDriver { capabilities: DriverCapabilities { schemas: false, views: true, + materialized_views: false, routines: true, file_based: false, folder_based: false, diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 0ba089b1..695c7754 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -388,7 +388,7 @@ pub async fn get_indexes( JOIN pg_class i ON i.oid = ix.indexrelid JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) WHERE - t.relkind = 'r' + t.relkind IN ('r', 'm') AND n.nspname = $1 AND t.relname = $2 ORDER BY @@ -1156,6 +1156,120 @@ pub async fn get_view_columns( .collect()) } +pub async fn get_materialized_views( + params: &ConnectionParams, + schema: &str, +) -> Result, String> { + log::debug!( + "PostgreSQL: Fetching materialized views for database: {} schema: {}", + params.database, + schema + ); + let pool = get_postgres_pool(params).await?; + let rows = query_all( + &pool, + "SELECT matviewname as name FROM pg_matviews WHERE schemaname = $1 ORDER BY matviewname ASC", + &[&schema], + ) + .await?; + + let views: Vec = rows + .iter() + .map(|r| ViewInfo { + name: r.try_get("name").unwrap_or_default(), + definition: None, + }) + .collect(); + Ok(views) +} + +/// Materialized views are not exposed via `information_schema.columns`, so their +/// columns must be read from the system catalog (`pg_attribute`/`pg_class`). +pub async fn get_materialized_view_columns( + params: &ConnectionParams, + view_name: &str, + schema: &str, +) -> Result, String> { + let pool = get_postgres_pool(params).await?; + let query = r#" + SELECT + a.attname AS column_name, + format_type(a.atttypid, a.atttypmod) AS data_type, + a.attnotnull AS not_null + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind = 'm' + AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum + "#; + + let rows = query_all(&pool, query, &[&schema, &view_name]).await?; + + Ok(rows + .iter() + .map(|r| TableColumn { + name: r.try_get("column_name").unwrap_or_default(), + data_type: r.try_get("data_type").unwrap_or_default(), + is_pk: false, + is_nullable: !r.try_get::<_, bool>("not_null").unwrap_or(false), + is_auto_increment: false, + default_value: None, + character_maximum_length: None, + }) + .collect()) +} + +pub async fn get_materialized_view_definition( + params: &ConnectionParams, + view_name: &str, + schema: &str, +) -> Result { + let pool = get_postgres_pool(params).await?; + let qualified = format!( + "\"{}\".\"{}\"", + escape_identifier(schema), + escape_identifier(view_name) + ); + + let client = pool.get().await.map_err(|e| e.to_string())?; + + let row = client + .query_one( + "SELECT pg_get_viewdef($1::regclass, true) as definition", + &[&qualified], + ) + .await + .map_err(|e| format!("Failed to get materialized view definition: {}", e))?; + + let definition: String = row.try_get("definition").unwrap_or_default(); + Ok(format!( + "CREATE MATERIALIZED VIEW {} AS\n{}", + qualified, definition + )) +} + +pub async fn refresh_materialized_view( + params: &ConnectionParams, + view_name: &str, + schema: &str, +) -> Result<(), String> { + let pool = get_postgres_pool(params).await?; + let query = format!( + "REFRESH MATERIALIZED VIEW \"{}\".\"{}\"", + escape_identifier(schema), + escape_identifier(view_name) + ); + + let client = pool.get().await.map_err(|e| e.to_string())?; + client + .execute(&query, &[]) + .await + .map_err(|e| format!("Failed to refresh materialized view: {}", e))?; + + Ok(()) +} + pub async fn get_routines( params: &ConnectionParams, schema: &str, @@ -1424,6 +1538,7 @@ impl PostgresDriver { capabilities: DriverCapabilities { schemas: true, views: true, + materialized_views: true, routines: true, file_based: false, folder_based: false, @@ -1616,6 +1731,41 @@ impl DatabaseDriver for PostgresDriver { drop_view(params, view_name, self.resolve_schema(schema)).await } + async fn get_materialized_views( + &self, + params: &crate::models::ConnectionParams, + schema: Option<&str>, + ) -> Result, String> { + get_materialized_views(params, self.resolve_schema(schema)).await + } + + async fn get_materialized_view_columns( + &self, + params: &crate::models::ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result, String> { + get_materialized_view_columns(params, view_name, self.resolve_schema(schema)).await + } + + async fn get_materialized_view_definition( + &self, + params: &crate::models::ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result { + get_materialized_view_definition(params, view_name, self.resolve_schema(schema)).await + } + + async fn refresh_materialized_view( + &self, + params: &crate::models::ConnectionParams, + view_name: &str, + schema: Option<&str>, + ) -> Result<(), String> { + refresh_materialized_view(params, view_name, self.resolve_schema(schema)).await + } + async fn get_routines( &self, params: &crate::models::ConnectionParams, diff --git a/src-tauri/src/drivers/sqlite/mod.rs b/src-tauri/src/drivers/sqlite/mod.rs index c97c3703..3c7479ab 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -879,6 +879,7 @@ impl SqliteDriver { capabilities: DriverCapabilities { schemas: false, views: true, + materialized_views: false, routines: false, file_based: true, folder_based: false, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 03cd2540..61f4f8d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -343,6 +343,10 @@ pub fn run() { commands::alter_view, commands::drop_view, commands::get_view_columns, + commands::get_materialized_views, + commands::get_materialized_view_columns, + commands::get_materialized_view_definition, + commands::refresh_materialized_view, commands::set_window_title, commands::open_er_diagram_window, explain_import::load_explain_from_file, diff --git a/src/components/layout/ExplorerSidebar.tsx b/src/components/layout/ExplorerSidebar.tsx index ddf18508..835a00b3 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -206,6 +206,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar const [favoriteDeleteConfirm, setFavoriteDeleteConfirm] = useState(null); const [tableFilter, setTableFilter] = useState(""); const [favoritesFilter, setFavoritesFilter] = useState(""); + const [refreshingMatView, setRefreshingMatView] = useState(null); const [selectedFavoriteId, setSelectedFavoriteId] = useState(null); const [tablesOpen, setTablesOpen] = useState(true); const [viewsOpen, setViewsOpen] = useState(true); @@ -354,13 +355,18 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar setActiveView(viewName); }; - const handleOpenView = (viewName: string, schema?: string) => { + const handleOpenView = ( + viewName: string, + schema?: string, + materialized = false, + ) => { const quotedView = quoteTableRef(viewName, activeDriver, schema); navigate("/editor", { state: { initialQuery: `SELECT * FROM ${quotedView}`, tableName: viewName, schema, + materialized, targetConnectionId: activeConnectionId, }, }); @@ -1060,7 +1066,9 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar onTableClick={(name, schema) => handleTableClick(name, schema)} onTableDoubleClick={(name, schema) => handleOpenTable(name, schema)} onViewClick={handleViewClick} - onViewDoubleClick={(name, schema) => handleOpenView(name, schema)} + onViewDoubleClick={(name, schema, materialized) => + handleOpenView(name, schema, materialized) + } onRoutineDoubleClick={(routine, schema) => handleRoutineDoubleClick(routine, schema)} onTriggerDoubleClick={(trigger, schema) => handleTriggerDoubleClick(trigger, schema)} onContextMenu={handleContextMenu} @@ -1124,6 +1132,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar setTriggerEditorModal({ isOpen: true, isNewTrigger: true, schema }) } showTriggers={activeCapabilities?.triggers === true} + refreshingMatView={refreshingMatView} /> ))} @@ -2012,6 +2021,71 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar }, ]; })() + : contextMenu.type === "materialized_view" + ? (() => { + const mvCtxSchema = contextMenu.data && "schema" in contextMenu.data ? contextMenu.data.schema : undefined; + return [ + { + label: t("sidebar.showData"), + icon: PlaySquare, + action: () => { + const quotedView = quoteTableRef(contextMenu.id, activeDriver, mvCtxSchema); + runQuery(`SELECT * FROM ${quotedView}`, undefined, contextMenu.id); + }, + }, + { + label: t("sidebar.countRows"), + icon: Hash, + action: () => { + const quotedView = quoteTableRef(contextMenu.id, activeDriver, mvCtxSchema); + runQuery(`SELECT COUNT(*) as count FROM ${quotedView}`); + }, + }, + { + label: t("sidebar.refreshMaterializedView"), + icon: RefreshCw, + action: async () => { + const mvName = contextMenu.id; + setRefreshingMatView(mvName); + try { + await invoke("refresh_materialized_view", { + connectionId: activeConnectionId, + viewName: mvName, + ...(mvCtxSchema ? { schema: mvCtxSchema } : {}), + }); + showAlert(t("views.refreshSuccess", { view: mvName }), { kind: "info" }); + } catch (e) { + console.error(e); + showAlert(t("views.refreshError") + String(e), { kind: "error" }); + } finally { + setRefreshingMatView(null); + } + }, + }, + { + label: t("sidebar.showDefinition"), + icon: FileText, + action: async () => { + try { + const definition = await invoke("get_materialized_view_definition", { + connectionId: activeConnectionId, + viewName: contextMenu.id, + ...(mvCtxSchema ? { schema: mvCtxSchema } : {}), + }); + runQuery(definition, `${contextMenu.id} Definition`, undefined, true, mvCtxSchema, true); + } catch (e) { + console.error(e); + showAlert(t("views.failGetDefinition") + String(e), { kind: "error" }); + } + }, + }, + { + label: t("sidebar.copyName"), + icon: Copy, + action: () => navigator.clipboard.writeText(contextMenu.id), + }, + ]; + })() : contextMenu.type === "routine" ? [ { diff --git a/src/components/layout/sidebar/SidebarIndexList.tsx b/src/components/layout/sidebar/SidebarIndexList.tsx new file mode 100644 index 00000000..adc2fc4a --- /dev/null +++ b/src/components/layout/sidebar/SidebarIndexList.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Folder, List } from "lucide-react"; +import type { GroupedIndex } from "../../../utils/indexes"; + +interface SidebarIndexListProps { + indexes: GroupedIndex[]; + isOpen: boolean; + onToggle: () => void; + onIndexContextMenu?: (e: React.MouseEvent, indexName: string) => void; + onFolderContextMenu?: (e: React.MouseEvent) => void; +} + +export const SidebarIndexList = ({ + indexes, + isOpen, + onToggle, + onIndexContextMenu, + onFolderContextMenu, +}: SidebarIndexListProps) => { + const { t } = useTranslation(); + + return ( +
+ + {isOpen && ( +
+ {indexes.map((idx) => ( +
onIndexContextMenu(e, idx.name) : undefined + } + > + + + {idx.name}{" "} + ({idx.columns.join(", ")}) + + {idx.is_unique && ( + + UNIQUE + + )} +
+ ))} +
+ )} +
+ ); +}; diff --git a/src/components/layout/sidebar/SidebarSchemaItem.tsx b/src/components/layout/sidebar/SidebarSchemaItem.tsx index 580c4da6..bb1abd3c 100644 --- a/src/components/layout/sidebar/SidebarSchemaItem.tsx +++ b/src/components/layout/sidebar/SidebarSchemaItem.tsx @@ -34,7 +34,11 @@ interface SidebarSchemaItemProps { onTableClick: (name: string, schema: string) => void; onTableDoubleClick: (name: string, schema: string) => void; onViewClick: (name: string) => void; - onViewDoubleClick: (name: string, schema: string) => void; + onViewDoubleClick: ( + name: string, + schema: string, + materialized?: boolean, + ) => void; onRoutineDoubleClick: (routine: RoutineInfo, schema: string) => void; onTriggerDoubleClick: (trigger: TriggerInfo, schema: string) => void; onContextMenu: ( @@ -54,6 +58,7 @@ interface SidebarSchemaItemProps { onCreateView: () => void; onCreateTrigger: (schema: string) => void; showTriggers?: boolean; + refreshingMatView?: string | null; } export const SidebarSchemaItem = ({ @@ -83,6 +88,7 @@ export const SidebarSchemaItem = ({ onCreateView, onCreateTrigger, showTriggers = false, + refreshingMatView = null, }: SidebarSchemaItemProps) => { const { t } = useTranslation(); @@ -92,6 +98,7 @@ export const SidebarSchemaItem = ({ const [prevActiveSchema, setPrevActiveSchema] = useState(activeSchema); const [tablesOpen, setTablesOpen] = useState(true); const [viewsOpen, setViewsOpen] = useState(true); + const [materializedViewsOpen, setMaterializedViewsOpen] = useState(false); const [routinesOpen, setRoutinesOpen] = useState(false); const [triggersOpen, setTriggersOpen] = useState(false); const [functionsOpen, setFunctionsOpen] = useState(true); @@ -112,6 +119,7 @@ export const SidebarSchemaItem = ({ ? tables.filter((t) => t.name.toLowerCase().includes(tableFilter.toLowerCase())) : tables; const views = schemaData?.views ?? []; + const materializedViews = schemaData?.materializedViews ?? []; const routines = schemaData?.routines ?? []; const triggers = schemaData?.triggers ?? []; const filteredTriggers = triggerFilter @@ -305,6 +313,34 @@ export const SidebarSchemaItem = ({ )} + {materializedViews.length > 0 && ( + setMaterializedViewsOpen(!materializedViewsOpen)} + > +
+ {materializedViews.map((view) => ( + + onViewDoubleClick(name, schemaName, true) + } + onContextMenu={onContextMenu} + connectionId={connectionId} + driver={driver} + schema={schemaName} + materialized + isRefreshing={refreshingMatView === view.name} + /> + ))} +
+
+ )} + {/* Triggers */} {showTriggers && ( { - const groups: Record = {}; - indexes.forEach((idx) => { - if (!groups[idx.name]) { - groups[idx.name] = { ...idx, columns: [] }; - } - groups[idx.name].columns.push(idx.column_name); - }); - return Object.values(groups); - }, [indexes]); + const groupedIndexes = React.useMemo(() => groupIndexes(indexes), [indexes]); const keys = groupedIndexes.filter((i) => i.is_primary || i.is_unique); const indexesList = groupedIndexes; @@ -325,57 +316,21 @@ const SidebarTableItemImpl = ({ {/* Indexes Folder */} -
- - {expandIndexes && ( -
- {indexesList.map((idx) => ( -
- handleContextMenu(e, "index", idx.name) - : undefined} - > - - - {idx.name}{" "} - - ({idx.columns.join(", ")}) - - - {idx.is_unique && ( - - UNIQUE - - )} -
- ))} -
- )} -
+ setExpandIndexes(!expandIndexes)} + onFolderContextMenu={ + canManage !== false + ? (e) => handleContextMenu(e, "folder_indexes", "indexes") + : undefined + } + onIndexContextMenu={ + canManage !== false + ? (e, name) => handleContextMenu(e, "index", name) + : undefined + } + /> )} diff --git a/src/components/layout/sidebar/SidebarViewItem.tsx b/src/components/layout/sidebar/SidebarViewItem.tsx index b100509a..2e9fd45b 100644 --- a/src/components/layout/sidebar/SidebarViewItem.tsx +++ b/src/components/layout/sidebar/SidebarViewItem.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { invoke } from "@tauri-apps/api/core"; import { Eye, + Layers, Loader2, Folder, ChevronDown, @@ -10,7 +11,9 @@ import { } from "lucide-react"; import clsx from "clsx"; import { SidebarColumnItem } from "./SidebarColumnItem"; -import type { TableColumn } from "../../../types/schema"; +import { SidebarIndexList } from "./SidebarIndexList"; +import { groupIndexes } from "../../../utils/indexes"; +import type { TableColumn, Index } from "../../../types/schema"; import type { ContextMenuData } from "../../../types/sidebar"; interface SidebarViewItemProps { @@ -28,6 +31,8 @@ interface SidebarViewItemProps { connectionId: string; driver: string; schema?: string; + materialized?: boolean; + isRefreshing?: boolean; } export const SidebarViewItem = ({ @@ -39,29 +44,48 @@ export const SidebarViewItem = ({ connectionId, driver, schema, + materialized = false, + isRefreshing = false, }: SidebarViewItemProps) => { const { t } = useTranslation(); + const ViewIcon = materialized ? Layers : Eye; const [isExpanded, setIsExpanded] = useState(false); const [columns, setColumns] = useState([]); + const [indexes, setIndexes] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [expandIndexes, setExpandIndexes] = useState(false); const refreshColumns = React.useCallback(async () => { if (!connectionId) return; setIsLoading(true); try { - const cols = await invoke("get_view_columns", { - connectionId, - viewName: view.name, - ...(schema ? { schema } : {}), - }); + const [cols, idxs] = await Promise.all([ + invoke( + materialized ? "get_materialized_view_columns" : "get_view_columns", + { + connectionId, + viewName: view.name, + ...(schema ? { schema } : {}), + }, + ), + // Materialized views can carry indexes (regular views cannot). + materialized + ? invoke("get_indexes", { + connectionId, + tableName: view.name, + ...(schema ? { schema } : {}), + }) + : Promise.resolve([] as Index[]), + ]); setColumns(cols); + setIndexes(idxs); } catch (err) { console.error("Failed to load view columns:", err); } finally { setIsLoading(false); } - }, [connectionId, view.name, schema]); + }, [connectionId, view.name, schema, materialized]); useEffect(() => { if (isExpanded) { @@ -77,9 +101,11 @@ export const SidebarViewItem = ({ const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - onContextMenu(e, "view", view.name, view.name, { tableName: view.name, schema }); + onContextMenu(e, materialized ? "materialized_view" : "view", view.name, view.name, { tableName: view.name, schema }); }; + const groupedIndexes = React.useMemo(() => groupIndexes(indexes), [indexes]); + return (
{isExpanded ? : } - + {isRefreshing ? ( + + ) : ( + + )} {view.name}
{isExpanded && ( @@ -141,6 +174,13 @@ export const SidebarViewItem = ({ /> ))}
+ {materialized && ( + setExpandIndexes(!expandIndexes)} + /> + )} )} diff --git a/src/contexts/DatabaseContext.ts b/src/contexts/DatabaseContext.ts index 66382344..a475d144 100644 --- a/src/contexts/DatabaseContext.ts +++ b/src/contexts/DatabaseContext.ts @@ -73,6 +73,7 @@ export interface ConnectionsFile { export interface SchemaData { tables: TableInfo[]; views: ViewInfo[]; + materializedViews?: ViewInfo[]; routines: RoutineInfo[]; triggers: TriggerInfo[]; isLoading: boolean; @@ -116,6 +117,7 @@ export interface DatabaseContextType { activeDatabaseName: string | null; tables: TableInfo[]; views: ViewInfo[]; + materializedViews: ViewInfo[]; routines: RoutineInfo[]; triggers: TriggerInfo[]; isLoadingTables: boolean; diff --git a/src/contexts/DatabaseProvider.tsx b/src/contexts/DatabaseProvider.tsx index af7f94fc..750ce0a0 100644 --- a/src/contexts/DatabaseProvider.tsx +++ b/src/contexts/DatabaseProvider.tsx @@ -85,6 +85,11 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { const isLoadingSchemas = activeData?.isLoadingSchemas ?? false; const schemaDataMap = activeData?.schemaDataMap ?? {}; const activeSchema = activeData?.activeSchema ?? null; + // Materialized views are schema-scoped (Postgres only), so resolve them from + // the active schema rather than the connection level (where they never load). + const materializedViews = activeSchema + ? (schemaDataMap[activeSchema]?.materializedViews ?? []) + : []; const selectedSchemas = activeData?.selectedSchemas ?? []; const needsSchemaSelection = activeData?.needsSchemaSelection ?? false; const selectedDatabases = useMemo(() => activeData?.selectedDatabases ?? [], [activeData?.selectedDatabases]); @@ -190,9 +195,12 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { }); try { - const [tablesResult, viewsResult, routinesResult, triggersResult] = await Promise.all([ + const [tablesResult, viewsResult, materializedViewsResult, routinesResult, triggersResult] = await Promise.all([ invoke('get_tables', { connectionId: connId, schema }), invoke('get_views', { connectionId: connId, schema }), + (currentData.capabilities?.materialized_views + ? invoke('get_materialized_views', { connectionId: connId, schema }).catch(() => [] as ViewInfo[]) + : Promise.resolve([] as ViewInfo[])), invoke('get_routines', { connectionId: connId, schema }), invoke('get_triggers', { connectionId: connId, schema }).catch(() => [] as TriggerInfo[]), ]); @@ -205,6 +213,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { [schema]: { tables: tablesResult, views: viewsResult, + materializedViews: materializedViewsResult, routines: routinesResult, triggers: triggersResult, isLoading: false, @@ -245,9 +254,12 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { }); try { - const [tablesResult, viewsResult, routinesResult, triggersResult] = await Promise.all([ + const [tablesResult, viewsResult, materializedViewsResult, routinesResult, triggersResult] = await Promise.all([ invoke('get_tables', { connectionId: connId, schema }), invoke('get_views', { connectionId: connId, schema }), + (currentData.capabilities?.materialized_views + ? invoke('get_materialized_views', { connectionId: connId, schema }).catch(() => [] as ViewInfo[]) + : Promise.resolve([] as ViewInfo[])), invoke('get_routines', { connectionId: connId, schema }), invoke('get_triggers', { connectionId: connId, schema }).catch(() => [] as TriggerInfo[]), ]); @@ -260,6 +272,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { [schema]: { tables: tablesResult, views: viewsResult, + materializedViews: materializedViewsResult, routines: routinesResult, triggers: triggersResult, isLoading: false, @@ -595,9 +608,12 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { // Ignore - no saved preference exists yet } - const [tablesResult, viewsResult, routinesResult, triggersResult] = await Promise.all([ + const [tablesResult, viewsResult, materializedViewsResult, routinesResult, triggersResult] = await Promise.all([ invoke('get_tables', { connectionId, schema: preferredSchema }), invoke('get_views', { connectionId, schema: preferredSchema }), + (capabilities?.materialized_views + ? invoke('get_materialized_views', { connectionId, schema: preferredSchema }).catch(() => [] as ViewInfo[]) + : Promise.resolve([] as ViewInfo[])), invoke('get_routines', { connectionId, schema: preferredSchema }), invoke('get_triggers', { connectionId, schema: preferredSchema }).catch(() => [] as TriggerInfo[]), ]); @@ -610,6 +626,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { [preferredSchema]: { tables: tablesResult, views: viewsResult, + materializedViews: materializedViewsResult, routines: routinesResult, triggers: triggersResult, isLoading: false, @@ -899,6 +916,7 @@ export const DatabaseProvider = ({ children }: { children: ReactNode }) => { activeDatabaseName, tables, views, + materializedViews, routines, triggers, isLoadingTables, diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 2c71b48c..6bf12985 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -52,6 +52,9 @@ "createView": "Neue Ansicht erstellen", "views": "Ansichten", "noViews": "Keine Ansichten gefunden", + "materializedViews": "Materialisierte Ansichten", + "refreshMaterializedView": "Aktualisieren", + "showDefinition": "Definition anzeigen", "editView": "Ansicht bearbeiten", "viewDefinition": "Ansichtsdefinition", "dropView": "Ansicht löschen", @@ -1226,6 +1229,9 @@ "createSuccess": "Ansicht erfolgreich erstellt", "alterSuccess": "Ansicht erfolgreich aktualisiert", "saveError": "Speichern der Ansicht fehlgeschlagen: ", + "refreshSuccess": "Materialisierte Ansicht \"{{view}}\" aktualisiert", + "refreshError": "Aktualisieren der materialisierten Ansicht fehlgeschlagen: ", + "failGetDefinition": "Abrufen der Definition fehlgeschlagen: ", "confirmAlter": "Möchtest du die Ansicht \"{{view}}\" wirklich ändern?" }, "community": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 85fb24fc..3f126659 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -52,6 +52,9 @@ "createView": "Create New View", "views": "Views", "noViews": "No views found", + "materializedViews": "Materialized Views", + "refreshMaterializedView": "Refresh", + "showDefinition": "Show Definition", "editView": "Edit View", "viewDefinition": "View Definition", "dropView": "Drop View", @@ -1262,7 +1265,10 @@ "createSuccess": "View created successfully", "alterSuccess": "View updated successfully", "saveError": "Failed to save view: ", - "confirmAlter": "Are you sure you want to modify view \"{{view}}\"?" + "confirmAlter": "Are you sure you want to modify view \"{{view}}\"?", + "refreshSuccess": "Materialized view \"{{view}}\" refreshed", + "refreshError": "Failed to refresh materialized view: ", + "failGetDefinition": "Failed to get definition: " }, "triggers": { "createTrigger": "Create Trigger", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ac679ce7..d4f3aa41 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -52,6 +52,9 @@ "createView": "Crear Nueva Vista", "views": "Vistas", "noViews": "No se encontraron vistas", + "materializedViews": "Vistas Materializadas", + "refreshMaterializedView": "Actualizar", + "showDefinition": "Mostrar Definición", "editView": "Editar Vista", "viewDefinition": "Definición de Vista", "dropView": "Eliminar Vista", @@ -1221,6 +1224,9 @@ "createSuccess": "Vista creada correctamente", "alterSuccess": "Vista actualizada correctamente", "saveError": "Error al guardar la vista: ", + "refreshSuccess": "Vista materializada \"{{view}}\" actualizada", + "refreshError": "Error al actualizar la vista materializada: ", + "failGetDefinition": "Error al obtener la definición: ", "confirmAlter": "¿Estás seguro de que deseas modificar la vista \"{{view}}\"?" }, "dump": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2420cb4f..eedf2cf2 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -52,6 +52,9 @@ "createView": "Créer une nouvelle vue", "views": "Vues", "noViews": "Aucune vue trouvée", + "materializedViews": "Vues matérialisées", + "refreshMaterializedView": "Actualiser", + "showDefinition": "Afficher la définition", "editView": "Modifier la vue", "viewDefinition": "Définition de la vue", "dropView": "Supprimer la vue", @@ -1226,6 +1229,9 @@ "createSuccess": "Vue créée avec succès", "alterSuccess": "Vue mise à jour avec succès", "saveError": "Échec de l’enregistrement de la vue : ", + "refreshSuccess": "Vue matérialisée \"{{view}}\" actualisée", + "refreshError": "Échec de l’actualisation de la vue matérialisée : ", + "failGetDefinition": "Échec de la récupération de la définition : ", "confirmAlter": "Voulez-vous vraiment modifier la vue \"{{view}}\" ?" }, "community": { diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 5b7df5ff..4bf32e0c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -52,6 +52,9 @@ "createView": "Crea Nuova Vista", "views": "Viste", "noViews": "Nessuna vista trovata", + "materializedViews": "Viste Materializzate", + "refreshMaterializedView": "Aggiorna", + "showDefinition": "Mostra Definizione", "editView": "Modifica Vista", "viewDefinition": "Definizione Vista", "dropView": "Elimina Vista", @@ -1228,6 +1231,9 @@ "createSuccess": "Vista creata con successo", "alterSuccess": "Vista aggiornata con successo", "saveError": "Salvataggio vista fallito: ", + "refreshSuccess": "Vista materializzata \"{{view}}\" aggiornata", + "refreshError": "Aggiornamento vista materializzata fallito: ", + "failGetDefinition": "Recupero della definizione fallito: ", "confirmAlter": "Sei sicuro di voler modificare la vista \"{{view}}\"?" }, "community": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9914178f..e8f79120 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -52,6 +52,9 @@ "createView": "ビューを新規作成", "views": "ビュー", "noViews": "ビューが見つかりません", + "materializedViews": "マテリアライズドビュー", + "refreshMaterializedView": "更新", + "showDefinition": "定義を表示", "editView": "ビューを編集", "viewDefinition": "ビュー定義", "dropView": "ビューを削除", @@ -1238,6 +1241,9 @@ "createSuccess": "ビューを正常に作成しました", "alterSuccess": "ビューを正常に更新しました", "saveError": "ビューの保存に失敗しました: ", + "refreshSuccess": "マテリアライズドビュー「{{view}}」を更新しました", + "refreshError": "マテリアライズドビューの更新に失敗しました: ", + "failGetDefinition": "定義の取得に失敗しました: ", "confirmAlter": "ビュー「{{view}}」を変更してもよろしいですか?" }, "triggers": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 54cc92ab..7611649a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -52,6 +52,9 @@ "createView": "Создать новое представление", "views": "Представления", "noViews": "Представления не найдены", + "materializedViews": "Материализованные представления", + "refreshMaterializedView": "Обновить", + "showDefinition": "Показать определение", "editView": "Изменить представление", "viewDefinition": "Определение представления", "dropView": "Удалить представление", @@ -1232,6 +1235,9 @@ "createSuccess": "Представление создано", "alterSuccess": "Представление обновлено", "saveError": "Не удалось сохранить представление: ", + "refreshSuccess": "Материализованное представление \"{{view}}\" обновлено", + "refreshError": "Не удалось обновить материализованное представление: ", + "failGetDefinition": "Не удалось получить определение: ", "confirmAlter": "Изменить представление \"{{view}}\"?" }, "triggers": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 059d7dfb..7dabdba7 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -51,6 +51,9 @@ "createView": "创建新视图", "views": "视图", "noViews": "未找到视图", + "materializedViews": "物化视图", + "refreshMaterializedView": "刷新", + "showDefinition": "显示定义", "editView": "编辑视图", "viewDefinition": "视图定义", "dropView": "删除视图", @@ -1171,6 +1174,9 @@ "createSuccess": "视图创建成功", "alterSuccess": "视图更新成功", "saveError": "保存视图失败:", + "refreshSuccess": "物化视图 \"{{view}}\" 已刷新", + "refreshError": "刷新物化视图失败:", + "failGetDefinition": "获取定义失败:", "confirmAlter": "确定要修改视图 \"{{view}}\" 吗?" }, "community": { diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 26cfc6e2..96a971a1 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -137,6 +137,7 @@ interface EditorState { queryName?: string; preventAutoRun?: boolean; readOnly?: boolean; + materialized?: boolean; schema?: string; targetConnectionId?: string; title?: string; @@ -158,6 +159,7 @@ export const Editor = () => { activeConnectionId, connections, views, + materializedViews, activeDriver, activeSchema, activeCapabilities, @@ -766,8 +768,13 @@ export const Editor = () => { // If not a table tab, try to extract table name from the query if (!tableName && textToRun) { const extracted = extractTableName(textToRun); - // Reject views — they may not be updatable - if (extracted && !views.some((v) => v.name === extracted)) { + // Reject views and materialized views — they are not row-editable + // (materialized views only accept REFRESH, not INSERT/UPDATE/DELETE). + if ( + extracted && + !views.some((v) => v.name === extracted) && + !materializedViews.some((v) => v.name === extracted) + ) { tableName = extracted; } } @@ -837,6 +844,7 @@ export const Editor = () => { activeSchema, activeCapabilities?.schemas, views, + materializedViews, isMultiDb, activeDatabaseName, addHistoryEntry, @@ -2510,6 +2518,7 @@ export const Editor = () => { queryName, preventAutoRun, readOnly: navReadOnly, + materialized: navMaterialized, schema: navSchema, title: navTitle, } = state; @@ -2520,6 +2529,7 @@ export const Editor = () => { activeTable: table, schema: navSchema, readOnly: navReadOnly, + materialized: navMaterialized, }); if (tabId && !preventAutoRun) { @@ -3620,7 +3630,8 @@ export const Editor = () => {
{activeFkQuery && activeConnectionId && ( diff --git a/src/types/editor.ts b/src/types/editor.ts index 79fb2190..befa106c 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -111,6 +111,7 @@ export interface Tab { queryParams?: Record; // Saved values for query parameters schema?: string; // Schema name (PostgreSQL) for query reconstruction readOnly?: boolean; // Hides the Run button (e.g. for definition views) + materialized?: boolean; // Grid data is read-only (e.g. materialized views: only REFRESH writes) results?: QueryResultEntry[]; activeResultId?: string; notebookId?: string; // Reference to notebook file in config dir diff --git a/src/types/plugins.ts b/src/types/plugins.ts index ca13ad89..0b46a2dd 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -31,6 +31,8 @@ export interface DriverCapabilities { readonly?: boolean; /** Supports listing and managing database triggers. Defaults to false. */ triggers?: boolean; + /** Supports materialized views (e.g. PostgreSQL). When false, the frontend skips fetching materialized views entirely. Defaults to false. */ + materialized_views?: boolean; /** Shows the SSL/TLS configuration tab (mode + CA/client cert/key) in the connection modal. * Built-in network drivers (postgres, mysql) set this; plugins opt in via their manifest. Defaults to false. */ supports_ssl?: boolean; diff --git a/src/utils/indexes.ts b/src/utils/indexes.ts new file mode 100644 index 00000000..9002e2a8 --- /dev/null +++ b/src/utils/indexes.ts @@ -0,0 +1,13 @@ +import type { Index } from "../types/schema"; + +export type GroupedIndex = Index & { columns: string[] }; + +// The backend returns one row per indexed column; collapse to one entry per index. +export function groupIndexes(indexes: Index[]): GroupedIndex[] { + const groups: Record = {}; + indexes.forEach((idx) => { + if (!groups[idx.name]) groups[idx.name] = { ...idx, columns: [] }; + groups[idx.name].columns.push(idx.column_name); + }); + return Object.values(groups); +} diff --git a/tests/components/layout/sidebar/SidebarSchemaItem.test.tsx b/tests/components/layout/sidebar/SidebarSchemaItem.test.tsx new file mode 100644 index 00000000..1e4dfa36 --- /dev/null +++ b/tests/components/layout/sidebar/SidebarSchemaItem.test.tsx @@ -0,0 +1,99 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import React from "react"; +import { SidebarSchemaItem } from "../../../../src/components/layout/sidebar/SidebarSchemaItem"; +import { invoke } from "@tauri-apps/api/core"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(() => Promise.resolve([])), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe("SidebarSchemaItem — materialized view double-click", () => { + const baseSchemaData = { + tables: [], + views: [], + materializedViews: [], + routines: [], + triggers: [], + isLoaded: true, + isLoading: false, + }; + + const defaultProps = { + schemaName: "public", + activeTable: null, + // Matching activeSchema auto-expands the schema body on first render. + activeSchema: "public", + connectionId: "conn-123", + driver: "postgres", + schemaVersion: 1, + onLoadSchema: vi.fn(), + onRefreshSchema: vi.fn(), + onTableClick: vi.fn(), + onTableDoubleClick: vi.fn(), + onViewClick: vi.fn(), + onViewDoubleClick: vi.fn(), + onRoutineDoubleClick: vi.fn(), + onTriggerDoubleClick: vi.fn(), + onContextMenu: vi.fn(), + onAddColumn: vi.fn(), + onEditColumn: vi.fn(), + onAddIndex: vi.fn(), + onDropIndex: vi.fn(), + onAddForeignKey: vi.fn(), + onDropForeignKey: vi.fn(), + onCreateTable: vi.fn(), + onCreateView: vi.fn(), + onCreateTrigger: vi.fn(), + }; + + beforeEach(() => { + vi.mocked(invoke).mockClear(); + }); + + it("flags a materialized view (materialized=true) on double-click", () => { + const onViewDoubleClick = vi.fn(); + render( + , + ); + + // The materialized-views group is collapsed by default — open it first. + fireEvent.click(screen.getByText("sidebar.materializedViews (1)")); + fireEvent.doubleClick(screen.getByText("mv_sales")); + + expect(onViewDoubleClick).toHaveBeenCalledWith("mv_sales", "public", true); + }); + + it("does not flag a regular view as materialized on double-click", () => { + const onViewDoubleClick = vi.fn(); + render( + , + ); + + // The views group is open by default. + fireEvent.doubleClick(screen.getByText("v_active")); + + // Called with only (name, schema) — the materialized arg stays undefined. + expect(onViewDoubleClick).toHaveBeenCalledWith("v_active", "public"); + expect(onViewDoubleClick).not.toHaveBeenCalledWith( + "v_active", + "public", + true, + ); + }); +}); diff --git a/tests/components/layout/sidebar/SidebarViewItem.test.tsx b/tests/components/layout/sidebar/SidebarViewItem.test.tsx index d3d9be56..29b04669 100644 --- a/tests/components/layout/sidebar/SidebarViewItem.test.tsx +++ b/tests/components/layout/sidebar/SidebarViewItem.test.tsx @@ -16,6 +16,23 @@ vi.mock("react-i18next", () => ({ }), })); +// Render lucide icons as identifiable stubs so tests can assert which icon +// is showing (e.g. Loader2 while refreshing vs Layers otherwise). +vi.mock("lucide-react", () => ({ + Eye: () => , + Layers: () => , + List: () => , + Loader2: () => , + Folder: () => , + ChevronDown: () => , + ChevronRight: () => , + Key: () => , + Columns: () => , + Edit: () => , + Copy: () => , + Trash2: () => , +})); + describe("SidebarViewItem", () => { const mockView = { name: "active_users" }; const mockColumns = [ @@ -168,4 +185,66 @@ describe("SidebarViewItem", () => { consoleSpy.mockRestore(); }); + + describe("when materialized", () => { + const mockMaterializedInvoke = (cmd: string) => { + if (cmd === "get_materialized_view_columns") return Promise.resolve(mockColumns); + if (cmd === "get_indexes") return Promise.resolve([]); + return Promise.reject(new Error(`Unexpected command: ${cmd}`)); + }; + + it("fetches columns via get_materialized_view_columns", async () => { + vi.mocked(invoke).mockImplementation(mockMaterializedInvoke); + + render(); + fireEvent.click(screen.getByRole("button")); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_materialized_view_columns", { + connectionId: "conn-123", + viewName: "active_users", + }); + }); + }); + + it("also fetches indexes via get_indexes", async () => { + vi.mocked(invoke).mockImplementation(mockMaterializedInvoke); + + render(); + fireEvent.click(screen.getByRole("button")); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("get_indexes", { + connectionId: "conn-123", + tableName: "active_users", + }); + }); + }); + + it("emits a materialized_view context menu type", () => { + const onContextMenu = vi.fn(); + render(); + + fireEvent.contextMenu(screen.getByText("active_users")); + expect(onContextMenu).toHaveBeenCalledWith( + expect.anything(), + "materialized_view", + "active_users", + "active_users", + expect.objectContaining({ tableName: "active_users" }), + ); + }); + + it("shows the spinner (Loader2) instead of the view icon while refreshing", () => { + const { rerender } = render( + , + ); + expect(screen.queryByTestId("icon-Loader2")).toBeNull(); + expect(screen.queryByTestId("icon-Layers")).not.toBeNull(); + + rerender(); + expect(screen.queryByTestId("icon-Loader2")).not.toBeNull(); + expect(screen.queryByTestId("icon-Layers")).toBeNull(); + }); + }); });