Add support for server tag-based filtering in the Object Explorer. #8917
parent
b2ec3a5acc
commit
99b822e472
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 |
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -362,6 +362,20 @@ basicSettings = createTheme(basicSettings, {
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
MuiBadge: {
|
||||
defaultProps: {
|
||||
overlap: 'circular',
|
||||
color: 'success',
|
||||
variant: 'dot',
|
||||
},
|
||||
styleOverrides: {
|
||||
badge: {
|
||||
height: '6px',
|
||||
minWidth: '6px',
|
||||
right: '16%',
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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')}}
|
||||
|
|
@ -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" />
|
||||
</>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -49,13 +49,6 @@ const StyledPopper = styled(Popper)(({theme}) => ({
|
|||
textAlign: 'right',
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}));
|
||||
|
||||
export default function FloatingNote({open, onClose, anchorEl, rows, noteNode}) {
|
||||
|
|
|
|||
|
|
@ -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', ()=>{
|
||||
|
|
|
|||
Loading…
Reference in New Issue