diff --git a/docs/en_US/images/main_left_pane.png b/docs/en_US/images/main_left_pane.png index 96d6607dd..b2411b0c4 100644 Binary files a/docs/en_US/images/main_left_pane.png and b/docs/en_US/images/main_left_pane.png differ diff --git a/docs/en_US/images/object_explorer_filter.png b/docs/en_US/images/object_explorer_filter.png new file mode 100644 index 000000000..21dc2f2ce Binary files /dev/null and b/docs/en_US/images/object_explorer_filter.png differ diff --git a/docs/en_US/images/toolbar.png b/docs/en_US/images/toolbar.png index ffc772d4b..1e74ac9ae 100644 Binary files a/docs/en_US/images/toolbar.png and b/docs/en_US/images/toolbar.png differ diff --git a/docs/en_US/toolbar.rst b/docs/en_US/toolbar.rst index c376d6c83..18e440072 100644 --- a/docs/en_US/toolbar.rst +++ b/docs/en_US/toolbar.rst @@ -13,6 +13,8 @@ the selected object node. :alt: pgAdmin Toolbar :align: center +* Use the :ref:`Object filter ` button to access + the Object Filter popup. It helps you filter objects in the Object Explorer tree. * Use the :ref:`Query Tool ` button to open the Query Tool in the current database context. * Use the :ref:`View Data ` button to view/edit the data stored in a @@ -23,3 +25,24 @@ the selected object node. dialog. It helps you search any database object. * Use the :ref:`PSQL Tool ` button to open the PSQL in the current database context. + + + +.. _object-explorer-filter: + +******************************* +`Object Explorer Filter`:index: +******************************* +.. image:: /images/object_explorer_filter.png + :alt: Object Explorer Filter Dialog + :align: center + +Use this tool to filter objects in the Object Explorer by +following fields: + +* Use the *Tags* field to filter the servers with one or more server tags. The + servers with any of the selected tags will be displayed in the Object Explorer. + You can also create a new tag by typing in the field and pressing Enter. + +Click the **Apply** button to apply the filter. Please note the object explorer will +refresh after applying the filter. \ No newline at end of file diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 2f26b7b33..1f00620cd 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -43,6 +43,7 @@ from sqlalchemy.orm.attributes import flag_modified from pgadmin.utils.preferences import Preferences from .... import socketio as sio from pgadmin.utils import get_complete_file_path +from pgadmin.settings.utils import with_object_filters def has_any(data, keys): @@ -220,8 +221,25 @@ class ServerModule(sg.ServerGroupPluginModule): return servers + def has_tag(self, server, object_filters): + try: + # No tags filter, show all + if len(object_filters['tags']) == 0: + return True + + # No tags on server, don't show + if server.tags is None or len(server.tags) == 0: + return False + + # Check if any of the tag exists + return any([t['text'] in object_filters['tags'] + for t in server.tags]) + except Exception as _: + return True + + @with_object_filters @pga_login_required - def get_nodes(self, gid): + def get_nodes(self, gid, object_filters): """Return a JSON document listing the server groups for the user""" hide_shared_server = get_preferences() @@ -241,6 +259,10 @@ class ServerModule(sg.ServerGroupPluginModule): wal_paused = None server_type = 'pg' user_info = None + + if not self.has_tag(server, object_filters): + continue + try: manager = driver.connection_manager(server.id) conn = manager.connection() @@ -926,7 +948,7 @@ class ServerNode(PGChildNodeView): ) @pga_login_required - def list(self, gid): + def list(self, gid, object_filters): """ Return list of attributes of all servers. """ diff --git a/web/pgadmin/settings/__init__.py b/web/pgadmin/settings/__init__.py index fcc18f348..02a56fa95 100644 --- a/web/pgadmin/settings/__init__.py +++ b/web/pgadmin/settings/__init__.py @@ -57,7 +57,8 @@ class SettingsModule(PgAdminModule): 'settings.save_application_state', 'settings.get_application_state', 'settings.delete_application_state', - 'settings.get_tool_data' + 'settings.get_tool_data', + 'settings.object_explorer_filter' ] @@ -440,6 +441,31 @@ def get_tool_data(trans_id): )) +@blueprint.route( + '/object_explorer_filter', + methods=["GET", "PUT"], endpoint='object_explorer_filter') +@pga_login_required +def object_explorer_filter(): + if request.method == 'GET': + result = get_setting('Object Explorer/Filter', '{}') + return make_json_response( + data={ + 'status': True, + 'msg': '', + 'result': json.loads(result) + } + ) + else: + data = json.loads(request.data.decode('utf-8')) + store_setting('Object Explorer/Filter', json.dumps(data)) + return make_json_response( + data={ + 'status': True, + 'msg': 'Success', + } + ) + + @blueprint.route( '/delete_application_state/', methods=["DELETE"], endpoint='delete_application_state') diff --git a/web/pgadmin/settings/utils.py b/web/pgadmin/settings/utils.py index 69d8e9792..7a891bd2f 100644 --- a/web/pgadmin/settings/utils.py +++ b/web/pgadmin/settings/utils.py @@ -1,4 +1,15 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + from flask_login import current_user +import functools +import json from pgadmin.model import Setting @@ -34,3 +45,18 @@ def get_file_type_setting(file_types): return '*' else: return data.value + + +def with_object_filters(f): + @functools.wraps(f) + def wrapped(*args, **kwargs): + data = Setting.query.filter_by( + user_id=current_user.id, setting='Object Explorer/Filter').first() + if not data or data.value is None: + data = {} + else: + data = json.loads(data.value) + + return f(*args, **kwargs, object_filters=data) + + return wrapped diff --git a/web/pgadmin/static/js/BrowserComponent.jsx b/web/pgadmin/static/js/BrowserComponent.jsx index 5240e8578..ac9401f78 100644 --- a/web/pgadmin/static/js/BrowserComponent.jsx +++ b/web/pgadmin/static/js/BrowserComponent.jsx @@ -21,7 +21,7 @@ import Dependencies from '../../misc/dependencies/static/js/Dependencies'; import Dependents from '../../misc/dependents/static/js/Dependents'; import ModalProvider from './helpers/ModalProvider'; import { NotifierProvider } from './helpers/Notifier'; -import ObjectExplorerToolbar from './helpers/ObjectExplorerToolbar'; +import ObjectExplorerToolbar from './tree/ObjectExplorer/ObjectExplorerToolbar'; import MainMoreToolbar from './helpers/MainMoreToolbar'; import Dashboard from '../../dashboard/static/js/Dashboard'; import usePreferences from '../../preferences/static/js/store'; @@ -91,8 +91,10 @@ let defaultLayout = { size: 20, tabs: [ LayoutDocker.getPanel({ - id: BROWSER_PANELS.OBJECT_EXPLORER, title: gettext('Object Explorer'), - content: , group: 'object-explorer' + id: BROWSER_PANELS.OBJECT_EXPLORER, + title: gettext('Object Explorer'), + content: , + group: 'object-explorer' }), ], }, diff --git a/web/pgadmin/static/js/Explain/Graphical.jsx b/web/pgadmin/static/js/Explain/Graphical.jsx index 7df0257a4..81661fd04 100644 --- a/web/pgadmin/static/js/Explain/Graphical.jsx +++ b/web/pgadmin/static/js/Explain/Graphical.jsx @@ -32,7 +32,6 @@ const StyledBox = styled(Box)(({theme}) => ({ bottom: '0.25rem', right: '0.25rem', borderColor: theme.otherVars.borderColor, - // box-shadow: 0 0.125rem 0.5rem rgb(132 142 160 / 28%); wordBreak: 'break-all', display: 'flex', flexDirection: 'column', diff --git a/web/pgadmin/static/js/Theme/index.jsx b/web/pgadmin/static/js/Theme/index.jsx index 29323fba3..3002e309b 100644 --- a/web/pgadmin/static/js/Theme/index.jsx +++ b/web/pgadmin/static/js/Theme/index.jsx @@ -362,6 +362,20 @@ basicSettings = createTheme(basicSettings, { } } } + }, + MuiBadge: { + defaultProps: { + overlap: 'circular', + color: 'success', + variant: 'dot', + }, + styleOverrides: { + badge: { + height: '6px', + minWidth: '6px', + right: '16%', + }, + } } }, }); diff --git a/web/pgadmin/static/js/tree/ObjectExplorer/ObjectExplorerFilter.jsx b/web/pgadmin/static/js/tree/ObjectExplorer/ObjectExplorerFilter.jsx new file mode 100644 index 000000000..54c7a68fe --- /dev/null +++ b/web/pgadmin/static/js/tree/ObjectExplorer/ObjectExplorerFilter.jsx @@ -0,0 +1,160 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { usePgAdmin } from '../../PgAdminProvider'; +import { Box, styled } from '@mui/material'; +import { DefaultButton, PrimaryButton } from '../../components/Buttons'; +import gettext from 'sources/gettext'; +import { FormInputSelect, FormNote } from '../../components/FormComponents'; +import getApiInstance, { parseApiError } from '../../api_instance'; +import url_for from 'sources/url_for'; +import Loader from '../../components/Loader'; + +const ObjectExplorerFilterRoot = styled('div')(({ theme }) => ({ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + display: 'flex', + backgroundColor: theme.palette.background.paper, + padding: '8px', + flexDirection: 'column', + gap: '8px', + ...theme.mixins.panelBorder?.bottom, +})); + + +export default function ObjectExplorerFilter() { + const [open, setOpen] = useState(false); + const appliedFiltersRef = useRef(null); + const [currFilter, setCurrFilter] = useState({ + tags: [], + }); + const [loadingText, setLoadingText] = useState(''); + const api = useMemo(()=>getApiInstance(), []); + const pgAdmin = usePgAdmin(); + const firstEleRef = useRef(null); + const openServersRef = useRef({}); + + const onClose = ()=>{ + setOpen(false); + }; + + const updateAppliedFilters = (filters) => { + appliedFiltersRef.current = filters; + const hasFilters = filters?.tags?.length > 0; + pgAdmin.Browser.Events.trigger('pgadmin:object-explorer:filter:apply', hasFilters); + }; + + const fetchFilter = async () => { + try { + setLoadingText(gettext('Loading...')); + const {data: resp} = await api.get(url_for('settings.object_explorer_filter')); + setCurrFilter(resp.data.result); + updateAppliedFilters(resp.data.result); + } catch(error) { + console.error('Error fetching object explorer filter:', error); + } + setLoadingText(''); + }; + + const applyFilter = async () => { + try { + setLoadingText(gettext('Applying filter...')); + await api.put(url_for('settings.object_explorer_filter'), currFilter); + updateAppliedFilters(currFilter); + + // Save the state of the browser tree + await pgAdmin.Browser.browserTreeState.save_state(); + + // register to add event to open the server + const deregister = pgAdmin.Browser.Events.on('pgadmin-browser:tree:added', async (item, d)=>{ + if(d._type == 'server' && openServersRef.current[d._id]) { + delete openServersRef.current[d._id]; + await pgAdmin.Browser.tree.ensureLoaded(item); + await pgAdmin.Browser.tree.open(item); + } + if(Object.keys(openServersRef.current).length === 0) { + // all servers are opened, deregister the event to avoid unnecessary calls + deregister(); + }; + }); + + // lets do one server group at a time + (pgAdmin.Browser.tree.children()||[]).forEach(async (serverGroup)=>{ + // restore tree state works after a server is opened + // We will note server open state here before refreshing + pgAdmin.Browser.tree.children(serverGroup).forEach((server)=>{ + const serverData = server._metadata.data; + if(pgAdmin.Browser.tree.isOpen(server)) { + openServersRef.current[serverData._id] = serverData._id; + } else { + delete openServersRef.current[serverData._id]; + } + }); + + // refresh the server group to apply the filter + await pgAdmin.Browser.tree.refresh(serverGroup); + }); + setLoadingText(''); + onClose(); + } catch(error) { + console.error('Error applying object explorer filter:', error); + pgAdmin.Browser.notifier.error(parseApiError(error)); + setLoadingText(''); + } + }; + + const onChange = (v) => { + setCurrFilter((prev)=>({...prev, tags: v})); + }; + + useEffect(()=>{ + fetchFilter(); + const deregister = pgAdmin.Browser.Events.on('pgadmin:object-explorer:filter:show', ()=>{ + setOpen(true); + }); + return ()=>{ + deregister(); + }; + }, []); + + useEffect(()=>{ + if(!open) return; + setCurrFilter(appliedFiltersRef.current); + }, [open]); + + useLayoutEffect(()=>{ + if(!open) return; + // Focus on the first element when the filter is opened + firstEleRef.current?.focus(); + }, [open]); + + if(!open) { + return <>; + } + + return ( + + + + + + onClose()}>Close + Apply + + + ); +} diff --git a/web/pgadmin/static/js/helpers/ObjectExplorerToolbar.jsx b/web/pgadmin/static/js/tree/ObjectExplorer/ObjectExplorerToolbar.jsx similarity index 72% rename from web/pgadmin/static/js/helpers/ObjectExplorerToolbar.jsx rename to web/pgadmin/static/js/tree/ObjectExplorer/ObjectExplorerToolbar.jsx index fe9319517..e7ee152f3 100644 --- a/web/pgadmin/static/js/helpers/ObjectExplorerToolbar.jsx +++ b/web/pgadmin/static/js/tree/ObjectExplorer/ObjectExplorerToolbar.jsx @@ -8,16 +8,17 @@ ////////////////////////////////////////////////////////////// import React, { useEffect, useState } from 'react'; -import { usePgAdmin } from '../PgAdminProvider'; -import { Box } from '@mui/material'; -import { QueryToolIcon, RowFilterIcon, ViewDataIcon } from '../components/ExternalIcon'; +import { usePgAdmin } from '../../PgAdminProvider'; +import { Badge, Box } from '@mui/material'; +import { QueryToolIcon, RowFilterIcon, ViewDataIcon } from '../../components/ExternalIcon'; import TerminalRoundedIcon from '@mui/icons-material/TerminalRounded'; import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; -import { PgButtonGroup, PgIconButton } from '../components/Buttons'; +import FilterAltRoundedIcon from '@mui/icons-material/FilterAltRounded'; +import { PgButtonGroup, PgIconButton } from '../../components/Buttons'; import _ from 'lodash'; import PropTypes from 'prop-types'; -import CustomPropTypes from '../custom_prop_types'; -import usePreferences from '../../../preferences/static/js/store'; +import CustomPropTypes from '../../custom_prop_types'; +import usePreferences from '../../../../preferences/static/js/store'; import gettext from 'sources/gettext'; function ToolbarButton({menuItem, ...props}) { @@ -40,7 +41,9 @@ export default function ObjectExplorerToolbar() { 'psql': undefined, }); const browserPref = usePreferences().getPreferencesForModule('browser'); + const [hasFilters, setHasFilters] = useState(false); const pgAdmin = usePgAdmin(); + const checkMenuState = ()=>{ const viewMenus = pgAdmin.Browser.MainMenus. find((m)=>(m.name=='object'))?. @@ -63,15 +66,31 @@ export default function ObjectExplorerToolbar() { useEffect(()=>{ const deregister = pgAdmin.Browser.Events.on('pgadmin:enable-disable-menu-items', _.debounce(checkMenuState, 100)); + + const deregisterFilter = pgAdmin.Browser.Events.on('pgadmin:object-explorer:filter:apply', (hasFilters)=>{ + setHasFilters(hasFilters); + }); checkMenuState(); return ()=>{ deregister(); + deregisterFilter(); }; }, []); return ( + + + + } menuItem={{ + label: gettext('Filter Objects'), + isDisabled: false, + callback: () => { + pgAdmin.Browser.Events.trigger('pgadmin:object-explorer:filter:show'); + } + }} id="filter-objects" isDropdown /> } menuItem={menus['query_tool']} shortcut={browserPref?.sub_menu_query_tool} /> } menuItem={menus['view_all_rows_context'] ?? {label :gettext('All Rows')}} diff --git a/web/pgadmin/static/js/tree/ObjectExplorer.jsx b/web/pgadmin/static/js/tree/ObjectExplorer/index.jsx similarity index 92% rename from web/pgadmin/static/js/tree/ObjectExplorer.jsx rename to web/pgadmin/static/js/tree/ObjectExplorer/index.jsx index a860fead1..cb2697c1d 100644 --- a/web/pgadmin/static/js/tree/ObjectExplorer.jsx +++ b/web/pgadmin/static/js/tree/ObjectExplorer/index.jsx @@ -8,16 +8,17 @@ ////////////////////////////////////////////////////////////// import React, { useEffect, useMemo, useRef, useState } from 'react'; -import {Tree} from './tree'; -import * as pgadminUtils from '../utils'; +import {Tree} from '../tree'; +import * as pgadminUtils from '../../utils'; import { Directory } from 'react-aspen'; -import { ManageTreeNodes } from './tree_nodes'; -import { FileTreeX, TreeModelX } from '../components/PgTree'; -import ContextMenu from '../components/ContextMenu'; -import { generateNodeUrl } from '../../../browser/static/js/node_ajax'; -import { copyToClipboard } from '../clipboard'; -import { usePgAdmin } from '../PgAdminProvider'; +import { ManageTreeNodes } from '../tree_nodes'; +import { FileTreeX, TreeModelX } from '../../components/PgTree'; +import ContextMenu from '../../components/ContextMenu'; +import { generateNodeUrl } from '../../../../browser/static/js/node_ajax'; +import { copyToClipboard } from '../../clipboard'; +import { usePgAdmin } from '../../PgAdminProvider'; +import ObjectExplorerFilter from './ObjectExplorerFilter'; function postTreeReady(b) { const draggableTypes = [ @@ -198,6 +199,7 @@ export default function ObjectExplorer() { contextPos && setContextPos(null); }} /> + setContextPos(null)} menuItems={contextMenuItems} label="Object Context Menu" /> diff --git a/web/pgadmin/static/js/tree/pgadmin_tree_save_state.js b/web/pgadmin/static/js/tree/pgadmin_tree_save_state.js index 4bd583795..22a4b93c2 100644 --- a/web/pgadmin/static/js/tree/pgadmin_tree_save_state.js +++ b/web/pgadmin/static/js/tree/pgadmin_tree_save_state.js @@ -108,7 +108,7 @@ _.extend(pgBrowser.browserTreeState, { /* Using fetch with keepalive as the browser may cancel the axios request on tab close. keepalive will make sure the request is completed */ - callFetch( + return callFetch( url_for('settings.save_tree_state'), { keepalive: true, method: 'POST', diff --git a/web/pgadmin/tools/erd/static/js/erd_tool/components/FloatingNote.jsx b/web/pgadmin/tools/erd/static/js/erd_tool/components/FloatingNote.jsx index b0a249951..ff4d5e8e4 100644 --- a/web/pgadmin/tools/erd/static/js/erd_tool/components/FloatingNote.jsx +++ b/web/pgadmin/tools/erd/static/js/erd_tool/components/FloatingNote.jsx @@ -49,13 +49,6 @@ const StyledPopper = styled(Popper)(({theme}) => ({ textAlign: 'right', } }, - - - - - - - })); export default function FloatingNote({open, onClose, anchorEl, rows, noteNode}) { diff --git a/web/regression/javascript/SchemaView/SchemaDialogView.spec.js b/web/regression/javascript/SchemaView/SchemaDialogView.spec.js index 6eaba295d..de68f3ae7 100644 --- a/web/regression/javascript/SchemaView/SchemaDialogView.spec.js +++ b/web/regression/javascript/SchemaView/SchemaDialogView.spec.js @@ -66,6 +66,7 @@ describe('SchemaView', ()=>{ }); }, simulateValidData = async ()=>{ + await user.type(ctrl.container.querySelector('[name="field1"]'), 'val1'); await user.type(ctrl.container.querySelector('[name="field2"]'), '2'); await user.type(ctrl.container.querySelector('[name="field5"]'), 'val5'); @@ -74,6 +75,10 @@ describe('SchemaView', ()=>{ await user.click(ctrl.container.querySelector('button[data-test="add-row"]')); await user.type(ctrl.container.querySelectorAll('[name="field5"]')[0], 'rval51'); await user.type(ctrl.container.querySelectorAll('[name="field5"]')[1], 'rval52'); + // Wait for validations to run + await act(async ()=>{ + await new Promise(resolve => setTimeout(resolve)); + }); }; describe('form fields', ()=>{