Add support for server tag-based filtering in the Object Explorer. #8917

pull/9013/head
Aditya Toshniwal 2025-07-31 17:06:40 +05:30 committed by GitHub
parent b2ec3a5acc
commit 99b822e472
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 320 additions and 29 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -13,6 +13,8 @@ the selected object node.
:alt: pgAdmin Toolbar
:align: center
* Use the :ref:`Object filter <object-explorer-filter>` button to access
the Object Filter popup. It helps you filter objects in the Object Explorer tree.
* Use the :ref:`Query Tool <query_tool>` button to open the Query Tool in the
current database context.
* Use the :ref:`View Data <editgrid>` 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 <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.

View File

@ -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.
"""

View File

@ -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')

View File

@ -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

View File

@ -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: <ObjectExplorer />, group: 'object-explorer'
id: BROWSER_PANELS.OBJECT_EXPLORER,
title: gettext('Object Explorer'),
content: <ObjectExplorer />,
group: 'object-explorer'
}),
],
},

View File

@ -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',

View File

@ -362,6 +362,20 @@ basicSettings = createTheme(basicSettings, {
}
}
}
},
MuiBadge: {
defaultProps: {
overlap: 'circular',
color: 'success',
variant: 'dot',
},
styleOverrides: {
badge: {
height: '6px',
minWidth: '6px',
right: '16%',
},
}
}
},
});

View File

@ -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 (
<ObjectExplorerFilterRoot sx={{ display: open ? 'flex' : 'none' }}>
<Loader message={loadingText} />
<FormInputSelect inputRef={firstEleRef} label={gettext('Tags')} controlProps={{
multiple: true,
allowClear: true,
creatable: true,
noDropdown: true,
}} value={currFilter.tags} onChange={onChange} placeholder={gettext('Specify the tags...')} />
<FormNote text={gettext('Applying the filter will only hide the servers from view, it won\'t close any active connections.')} />
<Box sx={{display: 'flex', justifyContent: 'flex-end'}}>
<DefaultButton size="small" onClick={()=>onClose()}>Close</DefaultButton>
<PrimaryButton size="small" onClick={applyFilter} sx={{marginLeft: '8px'}}>Apply</PrimaryButton>
</Box>
</ObjectExplorerFilterRoot>
);
}

View File

@ -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 (
<Box display="flex" alignItems="center" gap="2px">
<PgButtonGroup size="small">
<ToolbarButton icon={
<Badge badgeContent=" " overlap="circular" variant='dot' color="success" invisible={!hasFilters}>
<FilterAltRoundedIcon />
</Badge>
} menuItem={{
label: gettext('Filter Objects'),
isDisabled: false,
callback: () => {
pgAdmin.Browser.Events.trigger('pgadmin:object-explorer:filter:show');
}
}} id="filter-objects" isDropdown />
<ToolbarButton icon={<QueryToolIcon />} menuItem={menus['query_tool']} shortcut={browserPref?.sub_menu_query_tool} />
<ToolbarButton icon={<ViewDataIcon />} menuItem={menus['view_all_rows_context'] ??
{label :gettext('All Rows')}}

View File

@ -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);
}}
/>
<ObjectExplorerFilter />
<ContextMenu position={contextPos} onClose={()=>setContextPos(null)}
menuItems={contextMenuItems} label="Object Context Menu" />
</>

View File

@ -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',

View File

@ -49,13 +49,6 @@ const StyledPopper = styled(Popper)(({theme}) => ({
textAlign: 'right',
}
},
}));
export default function FloatingNote({open, onClose, anchorEl, rows, noteNode}) {

View File

@ -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', ()=>{