Added support for adding tags on a server node. #8192

This commit is contained in:
Aditya Toshniwal 2024-12-05 12:18:26 +05:30 committed by GitHub
parent 5e8a75cdf9
commit bd2a484c2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 218 additions and 37 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -233,3 +233,18 @@ Use the fields in the *Advanced* tab to configure a connection:
.. toctree::
clear_saved_passwords
Click the *Tags* tab to continue.
.. image:: images/server_tags.png
:alt: Server dialog tags tab
:align: center
Use the table in the *Tags* tab to add tags. The tags will be shown on the right side of
a server node label in the object explorer tree.
Click on the *+* button to add a new tag. Some of the parameters are:
* *Text* field to specify the tag name.
* *Color* field to select the accent color of the tag.

View File

@ -0,0 +1,35 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2024, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""
Revision ID: f28be870d5ec
Revises: ac2c2e27dc2d
Create Date: 2024-11-29 14:59:30.882464
"""
from alembic import op, context
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f28be870d5ec'
down_revision = 'ac2c2e27dc2d'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table(
"server", table_kwargs={'sqlite_autoincrement': True}) as batch_op:
batch_op.add_column(sa.Column('tags', sa.JSON(), nullable=True))
def downgrade():
# pgAdmin only upgrades, downgrade not implemented.
pass

View File

@ -37,6 +37,7 @@ from pgadmin.browser.server_groups.servers.utils import \
from pgadmin.utils.constants import UNAUTH_REQ, MIMETYPE_APP_JS, \
SERVER_CONNECTION_CLOSED
from sqlalchemy import or_
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
@ -278,7 +279,8 @@ class ServerModule(sg.ServerGroupPluginModule):
is_kerberos_conn=bool(server.kerberos_conn),
gss_authenticated=manager.gss_authenticated,
cloud_status=server.cloud_status,
description=server.comment
description=server.comment,
tags=server.tags
)
@property
@ -550,6 +552,44 @@ class ServerNode(PGChildNodeView):
data['connection_params'] = existing_conn_params
@staticmethod
def update_tags(data, server):
"""
This function is used to update tags
"""
old_tags = getattr(server, 'tags', [])
# add old_text for comparison
old_tags = [{**tag, 'old_text': tag['text']}
for tag in old_tags] if old_tags is not None else []
new_tags_info = data.get('tags', None)
def update_tag(tags, changed):
for i, item in enumerate(tags):
if item['old_text'] == changed['old_text']:
item = {**item, **changed}
tags[i] = item
break
if new_tags_info:
deleted_ids = [t['old_text']
for t in new_tags_info.get('deleted', [])]
if len(deleted_ids) > 0:
old_tags = [
t for t in old_tags if t['old_text'] not in deleted_ids
]
for item in new_tags_info.get('changed', []):
update_tag(old_tags, item)
for item in new_tags_info.get('added', []):
old_tags.append(item)
# remove the old_text key
data['tags'] = [
{k: v for k, v in tag.items()
if k != 'old_text'} for tag in old_tags
]
@pga_login_required
def nodes(self, gid):
res = []
@ -609,7 +649,8 @@ class ServerNode(PGChildNodeView):
shared=server.shared,
is_kerberos_conn=bool(server.kerberos_conn),
gss_authenticated=manager.gss_authenticated,
description=server.comment
description=server.comment,
tags=server.tags
)
)
@ -678,7 +719,8 @@ class ServerNode(PGChildNodeView):
shared=server.shared,
username=server.username,
is_kerberos_conn=bool(server.kerberos_conn),
gss_authenticated=manager.gss_authenticated
gss_authenticated=manager.gss_authenticated,
tags=server.tags
),
)
@ -783,7 +825,8 @@ class ServerNode(PGChildNodeView):
'shared_username': 'shared_username',
'kerberos_conn': 'kerberos_conn',
'connection_params': 'connection_params',
'prepare_threshold': 'prepare_threshold'
'prepare_threshold': 'prepare_threshold',
'tags': 'tags'
}
disp_lbl = {
@ -808,6 +851,7 @@ class ServerNode(PGChildNodeView):
# Update connection parameter if any.
self.update_connection_parameter(data, server)
self.update_tags(data, server)
if 'connection_params' in data and \
'hostaddr' in data['connection_params'] and \
@ -838,6 +882,10 @@ class ServerNode(PGChildNodeView):
errormsg=gettext('No parameters were changed.')
)
# tags is JSON type, sqlalchemy sometimes will not detect change
if 'tags' in data:
flag_modified(server, 'tags')
try:
db.session.commit()
except Exception as e:
@ -872,7 +920,8 @@ class ServerNode(PGChildNodeView):
username=server.username,
role=server.role,
is_password_saved=bool(server.save_password),
description=server.comment
description=server.comment,
tags=server.tags
)
)
@ -1022,6 +1071,10 @@ class ServerNode(PGChildNodeView):
tunnel_authentication = bool(server.tunnel_authentication)
tunnel_keep_alive = server.tunnel_keep_alive
tags = None
if server.tags is not None:
tags = [{**tag, 'old_text': tag['text']}
for tag in server.tags]
response = {
'id': server.id,
'name': server.name,
@ -1064,7 +1117,8 @@ class ServerNode(PGChildNodeView):
'cloud_status': server.cloud_status,
'connection_params': connection_params,
'connection_string': display_connection_str,
'prepare_threshold': server.prepare_threshold
'prepare_threshold': server.prepare_threshold,
'tags': tags,
}
return ajax_response(response)
@ -1180,7 +1234,8 @@ class ServerNode(PGChildNodeView):
passexec_expiration=data.get('passexec_expiration', None),
kerberos_conn=1 if data.get('kerberos_conn', False) else 0,
connection_params=connection_params,
prepare_threshold=data.get('prepare_threshold', None)
prepare_threshold=data.get('prepare_threshold', None),
tags=data.get('tags', None)
)
db.session.add(server)
db.session.commit()
@ -1273,7 +1328,8 @@ class ServerNode(PGChildNodeView):
manager and manager.gss_authenticated else False,
is_password_saved=bool(server.save_password),
is_tunnel_password_saved=tunnel_password_saved,
user_id=server.user_id
user_id=server.user_id,
tags=data.get('tags', None)
)
)

View File

@ -15,6 +15,35 @@ import {default as supportedServers} from 'pgadmin.server.supported_servers';
import current_user from 'pgadmin.user_management.current_user';
import { isEmptyString } from 'sources/validators';
import VariableSchema from './variable.ui';
import { getRandomColor } from '../../../../../static/js/utils';
class TagsSchema extends BaseUISchema {
get idAttribute() { return 'old_text'; }
get baseFields() {
return [
{
id: 'text', label: gettext('Text'), cell: 'text', group: null,
mode: ['create', 'edit'], noEmpty: true, controlProps: {
maxLength: 30,
}
},
{
id: 'color', label: gettext('Color'), cell: 'color', group: null,
mode: ['create', 'edit'], controlProps: {
input: true,
}
},
];
}
getNewData(data) {
return {
...data,
color: getRandomColor(),
};
}
}
export default class ServerSchema extends BaseUISchema {
constructor(serverGroupOptions=[], userId=0, initValues={}) {
@ -50,11 +79,13 @@ export default class ServerSchema extends BaseUISchema {
connection_params: [
{'name': 'sslmode', 'value': 'prefer', 'keyword': 'sslmode'},
{'name': 'connect_timeout', 'value': 10, 'keyword': 'connect_timeout'}],
tags: [],
...initValues,
});
this.serverGroupOptions = serverGroupOptions;
this.paramSchema = new VariableSchema(this.getConnectionParameters(), null, null, ['name', 'keyword', 'value']);
this.tagsSchema = new TagsSchema();
this.userId = userId;
_.bindAll(this, 'isShared');
}
@ -109,8 +140,8 @@ export default class ServerSchema extends BaseUISchema {
{
id: 'bgcolor', label: gettext('Background'), type: 'color',
group: null, mode: ['edit', 'create'],
disabled: obj.isConnected, deps: ['fgcolor'], depChange: (state)=>{
if(!state.bgcolor && state.fgcolor) {
disabled: obj.isConnected, deps: ['fgcolor'], depChange: (state, source)=>{
if(source[0] == 'fgcolor' && !state.bgcolor && state.fgcolor) {
return {'bgcolor': '#ffffff'};
}
}
@ -365,7 +396,13 @@ export default class ServerSchema extends BaseUISchema {
mode: ['properties', 'edit', 'create'],
helpMessageMode: ['edit', 'create'],
helpMessage: gettext('If it is set to 0, every query is prepared the first time it is executed. If it is set to blank, prepared statements are disabled on the connection.')
}
},
{
id: 'tags', label: '',
type: 'collection', group: gettext('Tags'),
schema: this.tagsSchema, mode: ['edit', 'create'], uniqueCol: ['text'],
canAdd: true, canEdit: false, canDelete: true,
},
];
}

View File

@ -225,6 +225,21 @@
"expected_data": {
"status_code": 200
}
},
{
"name": "Add server with tags",
"url": "/browser/server/obj/",
"is_positive_test": true,
"test_data": {
"tags": [
{"text": "tag1", "color": "#000"}
]
},
"mocking_required": false,
"mock_data": {},
"expected_data": {
"status_code": 200
}
}
],
"is_password_saved": [

View File

@ -88,6 +88,9 @@ class AddServerTest(BaseTestGenerator):
if 'bgcolor' in self.test_data:
self.server['bgcolor'] = self.test_data['bgcolor']
if 'tags' in self.test_data:
self.server['tags'] = self.test_data['tags']
if self.is_positive_test:
if hasattr(self, 'with_save'):
self.server['save_password'] = self.with_save

View File

@ -33,7 +33,7 @@ import config
#
##########################################################################
SCHEMA_VERSION = 40
SCHEMA_VERSION = 41
##########################################################################
#
@ -209,6 +209,7 @@ class Server(db.Model):
cloud_status = db.Column(db.Integer(), nullable=False, default=0)
connection_params = db.Column(MutableDict.as_mutable(types.JSON))
prepare_threshold = db.Column(db.Integer(), nullable=True)
tags = db.Column(types.JSON)
class ModulePreference(db.Model):

View File

@ -31,6 +31,7 @@ import {
useFieldOptions, useFieldValue, useFieldError, useSchemaStateSubscriber,
} from './hooks';
import { listenDepChanges } from './utils';
import { InputColor } from '../components/FormComponents';
/* Control mapping for form view */
@ -263,6 +264,8 @@ function MappedCellControlBase({
return <InputDateTimePicker name={name} value={value} onChange={onTextChange} {...props}/>;
case 'sql':
return <InputSQL name={name} value={value} onChange={onSqlChange} {...props} />;
case 'color':
return <InputColor name={name} value={value} onChange={onTextChange} {...props} />;
case 'file':
return <InputFileSelect name={name} value={value} onChange={onTextChange} inputRef={props.inputRef} {...props} />;
case 'keyCode':

View File

@ -7,6 +7,7 @@
//
//////////////////////////////////////////////////////////////
export default function reactAspenOverride(theme) {
return {
'.drag-tree-node': {
@ -47,7 +48,7 @@ export default function reactAspenOverride(theme) {
top: '0px' + ' !important',
'>div': {
scrollbarGutter: 'stable',
scrollbarGutter: 'auto',
overflow: 'overlay' + ' !important',
},
},
@ -136,6 +137,7 @@ export default function reactAspenOverride(theme) {
'span.file-label': {
display: 'flex',
gap: '2px',
alignItems: 'center',
padding: '0 2px 0 2px',
border: '1px solid transparent',
@ -153,13 +155,20 @@ export default function reactAspenOverride(theme) {
flexGrow: 1,
userSelect: 'none',
color: theme.otherVars.tree.textFg,
marginLeft: '3px',
cursor: 'pointer !important',
whiteSpace: 'nowrap',
'&:hover, &.pseudo-active': {
color: theme.otherVars.tree.fgHover,
},
},
'div.file-tag': {
color: 'var(--tag-color)',
border: '1px solid color-mix(in srgb, var(--tag-color) 90%, #fff)',
padding: '0px 4px',
borderRadius: theme.shape.borderRadius,
backgroundColor: 'color-mix(in srgb, color-mix(in srgb, var(--tag-color) 10%, #fff) 50%, transparent);',
lineHeight: 1.2
},
i: {
display: 'inline-block',
@ -221,10 +230,5 @@ export default function reactAspenOverride(theme) {
})
),
},
'.children-count': {
marginLeft: '3px',
},
};
}

View File

@ -50,14 +50,6 @@ const Root = styled('div')(({theme}) => ({
'& .Form-optionIcon': {
...theme.mixins.nodeIcon,
},
// '& .Form-label': {
// margin: theme.spacing(0.75, 0.75, 0.75, 0.75),
// display: 'flex',
// wordBreak: 'break-word'
// },
// '& .Form-labelError': {
// color: theme.palette.error.main,
// },
'& .Form-sql': {
border: '1px solid ' + theme.otherVars.inputBorderColor,
borderRadius: theme.shape.borderRadius,

View File

@ -72,6 +72,8 @@ export class FileTreeItem extends React.Component<IItemRendererXProps & IItemRen
const itemChildren = item.children && item.children.length > 0 && item._metadata.data._type.indexOf('coll-') !== -1 ? '(' + item.children.length + ')' : '';
const extraClasses = item._metadata.data.extraClasses ? item._metadata.data.extraClasses.join(' ') : '';
const tags = item._metadata.data?.tags ?? [];
return (
<div
className={cn('file-entry', {
@ -103,9 +105,13 @@ export class FileTreeItem extends React.Component<IItemRendererXProps & IItemRen
}
<span className='file-name'>
{ _.unescape(this.props.item.getMetadata('data')._label)}
<span className='children-count'>{itemChildren}</span>
</span>
<span className='children-count'>{itemChildren}</span>
{tags.map((tag)=>(
<div key={tag.text} className='file-tag' style={{'--tag-color': tag.color} as React.CSSProperties}>
{tag.text}
</div>
))}
</span>
</div>);
}

View File

@ -126,13 +126,13 @@ const defaultExtensions = [
indentOnInput(),
syntaxHighlighting,
keymap.of([{
key: 'Tab',
run: acceptCompletion,
},{
key: 'Tab',
preventDefault: true,
run: insertTabWithUnit,
shift: indentLess,
},{
key: 'Tab',
run: acceptCompletion,
},{
key: 'Backspace',
preventDefault: true,

View File

@ -15,7 +15,7 @@ import PropTypes from 'prop-types';
import { fullHexColor } from '../utils';
export function withColorPicker(Component) {
const HOCComponent = ({value, currObj, onChange, onSave, options, ...props})=>{
const pickrOptions = {
showPalette: true,
@ -74,10 +74,11 @@ export function withColorPicker(Component) {
defaultRepresentation: pickrOptions.colorFormat,
disabled: pickrOptions.disabled,
save: pickrOptions.allowSave,
input: pickrOptions.input,
},
},
}).on('init', instance => {
setColor(value);
setColor(value, true);
pickrOptions.disabled && instance.disable();
const { lastColor } = instance.getRoot().preview;

View File

@ -684,6 +684,10 @@ export function getChartColor(index, theme='light', colorPalette=CHART_THEME_COL
return palette[index % palette.length];
}
export function getRandomColor() {
return '#' + ((1 << 24) * Math.random() | 0).toString(16).padStart(6, '0');
}
// Using this function instead of 'btoa' directly.
// https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
function stringToBase64(str) {

View File

@ -157,7 +157,7 @@ function SelectAllHeaderRenderer({isCellSelected}) {
}, [isRowSelected]);
return <div ref={cellRef} style={{width: '100%', height: '100%'}} onClick={onClick}
tabIndex="0" onKeyDown={dataGridExtras.handleShortcuts}></div>;
tabIndex="0" onKeyDown={(e)=>dataGridExtras.handleShortcuts(e, true)}></div>;
}
SelectAllHeaderRenderer.propTypes = {
onAllRowsSelectionChange: PropTypes.func,
@ -192,7 +192,7 @@ function SelectableHeaderRenderer({column, selectedColumns, onSelectedColumnsCha
return (
<Box ref={cellRef} className={'QueryTool-columnHeader ' + (isSelected ? 'QueryTool-colHeaderSelected' : null)} onClick={onClick} tabIndex="0"
onKeyDown={dataGridExtras.handleShortcuts} data-column-key={column.key}>
onKeyDown={(e)=>dataGridExtras.handleShortcuts(e, true)} data-column-key={column.key}>
{(column.column_type_internal == 'geometry' || column.column_type_internal == 'geography') &&
<Box>
<PgIconButton title={gettext('View all geometries in this column')} icon={<MapIcon data-label="MapIcon"/>} size="small" style={{marginRight: '0.25rem'}} onClick={(e)=>{
@ -385,7 +385,13 @@ export default function QueryToolDataGrid({columns, rows, totalRowCount, dataCha
eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_COPY_DATA);
}
function handleShortcuts(e) {
function handleShortcuts(e, withCopy=false) {
// Handle Copy shortcut Cmd/Ctrl + c
if((e.ctrlKey || e.metaKey) && e.key !== 'Control' && e.keyCode == 67 && withCopy) {
e.preventDefault();
handleCopy();
}
// Handle Select All Cmd + A(mac) / Ctrl + a (others)
if(((isMac() && e.metaKey) || (!isMac() && e.ctrlKey)) && e.key === 'a') {
e.preventDefault();

View File

@ -528,6 +528,7 @@ def dump_database_servers(output_file, selected_servers,
server.kerberos_conn),
add_value(attr_dict, "ConnectionParameters",
server.connection_params)
add_value(attr_dict, "Tags", server.tags)
# if desktop mode or server mode with
# ENABLE_SERVER_PASS_EXEC_CMD flag is True
@ -766,6 +767,8 @@ def load_database_servers(input_file, selected_servers,
new_server.kerberos_conn = obj.get("KerberosAuthentication", None)
new_server.tags = obj.get("Tags", None)
# if desktop mode or server mode with
# ENABLE_SERVER_PASS_EXEC_CMD flag is True
if not current_app.config['SERVER_MODE'] or \