diff --git a/docs/en_US/images/server_tags.png b/docs/en_US/images/server_tags.png new file mode 100644 index 00000000000..ac849664f74 Binary files /dev/null and b/docs/en_US/images/server_tags.png differ diff --git a/docs/en_US/server_dialog.rst b/docs/en_US/server_dialog.rst index 5ba85014932..10652cd0a2a 100644 --- a/docs/en_US/server_dialog.rst +++ b/docs/en_US/server_dialog.rst @@ -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. \ No newline at end of file diff --git a/web/migrations/versions/f28be870d5ec_.py b/web/migrations/versions/f28be870d5ec_.py new file mode 100644 index 00000000000..4089341837e --- /dev/null +++ b/web/migrations/versions/f28be870d5ec_.py @@ -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 diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 3a9d1feccbd..5d7ca75a740 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -37,6 +37,7 @@ 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 @@ def get_nodes(self, gid): 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 @@ def update_connection_parameter(self, data, server): 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 @@ def nodes(self, gid): 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 @@ def node(self, gid, sid): 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 @@ def update(self, gid, sid): '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 @@ def update(self, gid, sid): # 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 @@ def update(self, gid, sid): 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 @@ def update(self, gid, sid): 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 @@ def properties(self, gid, sid): 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 @@ def properties(self, gid, sid): '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 @@ def create(self, gid): 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 @@ def create(self, gid): 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) ) ) diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js index 8ea3cf18972..06bcdefc693 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js @@ -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, + }, ]; } diff --git a/web/pgadmin/browser/server_groups/servers/tests/servers_test_data.json b/web/pgadmin/browser/server_groups/servers/tests/servers_test_data.json index 3de500b8f4a..5cba9062d19 100644 --- a/web/pgadmin/browser/server_groups/servers/tests/servers_test_data.json +++ b/web/pgadmin/browser/server_groups/servers/tests/servers_test_data.json @@ -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": [ diff --git a/web/pgadmin/browser/server_groups/servers/tests/test_add_server.py b/web/pgadmin/browser/server_groups/servers/tests/test_add_server.py index 1e388c4cad3..686661c3576 100644 --- a/web/pgadmin/browser/server_groups/servers/tests/test_add_server.py +++ b/web/pgadmin/browser/server_groups/servers/tests/test_add_server.py @@ -88,6 +88,9 @@ def runTest(self): 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 diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index 06bc3ee84fe..60adc1c09e6 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -33,7 +33,7 @@ # ########################################################################## -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): diff --git a/web/pgadmin/static/js/SchemaView/MappedControl.jsx b/web/pgadmin/static/js/SchemaView/MappedControl.jsx index e9f9489d271..18aee7c78db 100644 --- a/web/pgadmin/static/js/SchemaView/MappedControl.jsx +++ b/web/pgadmin/static/js/SchemaView/MappedControl.jsx @@ -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 ; case 'sql': return ; + case 'color': + return ; case 'file': return ; case 'keyCode': diff --git a/web/pgadmin/static/js/Theme/overrides/reactaspen.override.js b/web/pgadmin/static/js/Theme/overrides/reactaspen.override.js index 26e58bfc863..a46469190d4 100644 --- a/web/pgadmin/static/js/Theme/overrides/reactaspen.override.js +++ b/web/pgadmin/static/js/Theme/overrides/reactaspen.override.js @@ -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', - }, - }; } diff --git a/web/pgadmin/static/js/components/FormComponents.jsx b/web/pgadmin/static/js/components/FormComponents.jsx index d8b7e590082..019ace41ab0 100644 --- a/web/pgadmin/static/js/components/FormComponents.jsx +++ b/web/pgadmin/static/js/components/FormComponents.jsx @@ -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, diff --git a/web/pgadmin/static/js/components/PgTree/FileTreeItem/index.tsx b/web/pgadmin/static/js/components/PgTree/FileTreeItem/index.tsx index 27b83de5984..c3119d84b4b 100644 --- a/web/pgadmin/static/js/components/PgTree/FileTreeItem/index.tsx +++ b/web/pgadmin/static/js/components/PgTree/FileTreeItem/index.tsx @@ -72,6 +72,8 @@ export class FileTreeItem extends React.Component 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 (
{ _.unescape(this.props.item.getMetadata('data')._label)} - {itemChildren} - + {itemChildren} + {tags.map((tag)=>( +
+ {tag.text} +
+ ))}
); } diff --git a/web/pgadmin/static/js/components/ReactCodeMirror/components/Editor.jsx b/web/pgadmin/static/js/components/ReactCodeMirror/components/Editor.jsx index 3d3a38a4b0a..ea497eea9d2 100644 --- a/web/pgadmin/static/js/components/ReactCodeMirror/components/Editor.jsx +++ b/web/pgadmin/static/js/components/ReactCodeMirror/components/Editor.jsx @@ -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, diff --git a/web/pgadmin/static/js/helpers/withColorPicker.js b/web/pgadmin/static/js/helpers/withColorPicker.js index 2ac97de18e8..fd549b4f617 100644 --- a/web/pgadmin/static/js/helpers/withColorPicker.js +++ b/web/pgadmin/static/js/helpers/withColorPicker.js @@ -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; diff --git a/web/pgadmin/static/js/utils.js b/web/pgadmin/static/js/utils.js index 8a38c01ba79..3da2c40e949 100644 --- a/web/pgadmin/static/js/utils.js +++ b/web/pgadmin/static/js/utils.js @@ -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) { diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx index c7f2955f121..4f545d9aaac 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolDataGrid/index.jsx @@ -157,7 +157,7 @@ function SelectAllHeaderRenderer({isCellSelected}) { }, [isRowSelected]); return
; + tabIndex="0" onKeyDown={(e)=>dataGridExtras.handleShortcuts(e, true)}>; } SelectAllHeaderRenderer.propTypes = { onAllRowsSelectionChange: PropTypes.func, @@ -192,7 +192,7 @@ function SelectableHeaderRenderer({column, selectedColumns, onSelectedColumnsCha return ( + onKeyDown={(e)=>dataGridExtras.handleShortcuts(e, true)} data-column-key={column.key}> {(column.column_type_internal == 'geometry' || column.column_type_internal == 'geography') && } 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(); diff --git a/web/pgadmin/utils/__init__.py b/web/pgadmin/utils/__init__.py index e25fbf79b4f..decf1e03017 100644 --- a/web/pgadmin/utils/__init__.py +++ b/web/pgadmin/utils/__init__.py @@ -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 \