Added support for the 'Add to macros' feature and fixed various usability issues. #4735

pull/7506/head
Rohit Bhati 2024-05-24 15:30:31 +05:30 committed by GitHub
parent 36a71dc7fa
commit 4e3ec91d23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 284 additions and 56 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

View File

@ -485,6 +485,12 @@ To create a macro, select the *Manage Macros* option from the *Macros* menu on t
:alt: Query Tool Manage Macros dialogue
:align: center
To add a query to macros, write and select your query, then go to the *Macros* menu in the Query Tool and click *Add to macros*. Your query will be automatically saved to macros.
.. image:: images/query_tool_add_to_macro.png
:alt: Query Tool Add To Macros
:align: center
To delete a macro, select the macro on the *Manage Macros* dialogue, and then click the *Delete* button.
The server will prompt you for confirmation to delete the macro.

View File

@ -0,0 +1,61 @@
"""empty message
Revision ID: ac2c2e27dc2d
Revises: ec0f11f9a4e6
Create Date: 2024-05-17 19:35:03.700104
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ac2c2e27dc2d'
down_revision = 'ec0f11f9a4e6'
branch_labels = None
depends_on = None
def upgrade():
meta = sa.MetaData()
meta.reflect(op.get_bind(), only=('user_macros',))
user_macros_table = sa.Table('user_macros', meta)
# Create a select statement
stmt = sa.select(
user_macros_table.columns.mid,
user_macros_table.columns.uid, user_macros_table.columns.name,
user_macros_table.columns.sql
)
# Fetch the data from the user_macros table
results = op.get_bind().execute(stmt).fetchall()
# Drop and re-create user macro table.
op.drop_table('user_macros')
op.create_table(
'user_macros',
sa.Column('id', sa.Integer(), autoincrement=True),
sa.Column('mid', sa.Integer(), nullable=True),
sa.Column('uid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=1024), nullable=False),
sa.Column('sql', sa.String()),
sa.ForeignKeyConstraint(['mid'], ['macros.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['uid'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id',))
# Reflect the new table structure
meta.reflect(op.get_bind(), only=('user_macros',))
new_user_macros_table = sa.Table('user_macros', meta)
# Bulk insert the fetched data into the new user_macros table
op.bulk_insert(
new_user_macros_table,
[
{'mid': row[0], 'uid': row[1], 'name': row[2], 'sql': row[3]}
for row in results
]
)
def downgrade():
# pgAdmin only upgrades, downgrade not implemented.
pass

View File

@ -33,7 +33,7 @@ import config
#
##########################################################################
SCHEMA_VERSION = 39
SCHEMA_VERSION = 40
##########################################################################
#
@ -433,11 +433,12 @@ class Macros(db.Model):
class UserMacros(db.Model):
"""Define the macro for a particular user."""
__tablename__ = 'user_macros'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
mid = db.Column(
db.Integer, db.ForeignKey('macros.id'), primary_key=True
db.Integer, db.ForeignKey('macros.id'), nullable=True
)
uid = db.Column(
db.Integer, db.ForeignKey(USER_ID), primary_key=True
db.Integer, db.ForeignKey(USER_ID)
)
name = db.Column(db.String(1024), nullable=False)
sql = db.Column(db.Text(), nullable=False)

View File

@ -518,8 +518,13 @@ export default function DataGridView({
if(!props.canAddRow) {
return;
}
let newRow = schemaRef.current.getNewData();
const current_macros = schemaRef.current?._top?._sessData?.macro || null;
if (current_macros){
newRow = schemaRef.current.getNewData(current_macros);
}
if(props.expandEditOnAdd && props.canEdit) {
newRowIndex.current = rows.length;
}

View File

@ -107,11 +107,15 @@ export const PgMenuItem = applyStatics(MenuItem)(({hasCheck=false, checked=false
props.onClick(e);
};
}
const keyVal = shortcutToString(shortcut, accesskey);
const dataLabel = typeof(children) == 'string' ? children : props.datalabel;
return <MenuItem {...props} onClick={onClick} data-label={dataLabel} data-checked={checked}>
{hasCheck && <CheckIcon className={classes.checkIcon} style={checked ? {} : {visibility: 'hidden'}} data-label="CheckIcon"/>}
{children}
{(shortcut || accesskey) && <div className={classes.shortcut}>({shortcutToString(shortcut, accesskey)})</div>}
<div className={classes.shortcut}>
{keyVal ? `(${keyVal})` : ''}
</div>
</MenuItem>;
});

View File

@ -7,7 +7,7 @@
//
//////////////////////////////////////////////////////////////
import React, { useCallback, useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { makeStyles } from '@mui/styles';
import FileCopyRoundedIcon from '@mui/icons-material/FileCopyRounded';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
@ -62,7 +62,7 @@ CopyButton.propTypes = {
};
export default function CodeMirror({className, currEditor, showCopyBtn=false, customKeyMap=[], ...props}) {
export default function CodeMirror({className, currEditor, showCopyBtn=false, customKeyMap=[], onTextSelect, ...props}) {
const classes = useStyles();
const editor = useRef();
const [[showFind, isReplace], setShowFind] = useState([false, false]);
@ -111,8 +111,36 @@ export default function CodeMirror({className, currEditor, showCopyBtn=false, cu
const onMouseEnter = useCallback(()=>{showCopyBtn && setShowCopy(true);}, []);
const onMouseLeave = useCallback(()=>{showCopyBtn && setShowCopy(false);}, []);
// Use to handle text selection events.
useEffect(() => {
if (!onTextSelect) return;
const handleSelection = () => {
const selectedText = window.getSelection().toString();
if (selectedText) {
onTextSelect(selectedText);
} else {
onTextSelect(''); // Reset if no text is selected
}
};
const handleKeyUp = () => {
handleSelection();
};
// Add event listeners for mouseup and keyup events to detect text selection.
document.addEventListener('mouseup', handleSelection);
document.addEventListener('keyup', handleKeyUp);
// Cleanup function to remove event listeners when component unmounts or onTextSelect changes.
return () => {
document.removeEventListener('mouseup', handleSelection);
document.removeEventListener('keyup', handleKeyUp);
};
}, [onTextSelect]);
return (
<div className={clsx(className, classes.root)} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} >
<div className={clsx(className, classes.root)} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Editor currEditor={currEditorWrap} customKeyMap={finalCustomKeyMap} {...props} />
{showCopy && <CopyButton editor={editor.current} />}
<FindDialog editor={editor.current} show={showFind} replace={isReplace} onClose={closeFind} />
@ -126,4 +154,5 @@ CodeMirror.propTypes = {
className: CustomPropTypes.className,
showCopyBtn: PropTypes.bool,
customKeyMap: PropTypes.array,
onTextSelect:PropTypes.func
};

View File

@ -49,7 +49,7 @@ from pgadmin.tools.sqleditor.utils.query_tool_fs_utils import \
read_file_generator
from pgadmin.tools.sqleditor.utils.filter_dialog import FilterDialog
from pgadmin.tools.sqleditor.utils.query_history import QueryHistory
from pgadmin.tools.sqleditor.utils.macros import get_macros,\
from pgadmin.tools.sqleditor.utils.macros import get_macros, \
get_user_macros, set_macros
from pgadmin.utils.constants import MIMETYPE_APP_JS, \
SERVER_CONNECTION_CLOSED, ERROR_MSG_TRANS_ID_NOT_FOUND, \
@ -130,6 +130,7 @@ class SqlEditorModule(PgAdminModule):
'sqleditor.clear_query_history',
'sqleditor.get_macro',
'sqleditor.get_macros',
'sqleditor.get_user_macros',
'sqleditor.set_macros',
'sqleditor.get_new_connection_data',
'sqleditor.get_new_connection_servers',
@ -2692,3 +2693,15 @@ def update_macros(trans_id):
_, _, _, _, _ = check_transaction_status(trans_id)
return set_macros()
@blueprint.route(
'/get_user_macros',
methods=["GET"], endpoint='get_user_macros'
)
@pga_login_required
def user_macros(json_resp=True):
"""
This method is used to fetch all user macros.
"""
return get_user_macros()

View File

@ -50,6 +50,21 @@ function initConnection(api, params, passdata) {
return api.post(url_for('NODE-server.connect_id', params), passdata);
}
export function getRandomName(existingNames) {
const maxNumber = existingNames.reduce((max, name) => {
const match = name.match(/\d+$/); // Extract the number from the name
if (match) {
const number = parseInt(match[0], 10);
return number > max ? number : max;
}
return max;
}, 0);
// Generate the new name
const newName = `Macro ${maxNumber + 1}`;
return newName;
}
function setPanelTitle(docker, panelId, title, qtState, dirty=false) {
if(qtState.current_file) {
title = qtState.current_file.split('\\').pop().split('/').pop();
@ -194,6 +209,8 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
}],
});
const [selectedText, setSelectedText] = useState('');
const setQtState = (state)=>{
_setQtState((prev)=>({...prev,...evalFunc(null, state, prev)}));
};
@ -250,7 +267,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
{
maximizable: true,
tabs: [
LayoutDocker.getPanel({id: PANELS.QUERY, title: gettext('Query'), content: <Query />}),
LayoutDocker.getPanel({id: PANELS.QUERY, title: gettext('Query'), content: <Query onTextSelect={(text) => setSelectedText(text)}/>}),
LayoutDocker.getPanel({id: PANELS.HISTORY, title: gettext('Query History'), content: <QueryHistory />,
cached: undefined}),
],
@ -796,7 +813,38 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
}}
onClose={onClose}/>
}, 850, 500);
}, [qtState.preferences.browser]);
}, [qtState.preferences.browser]);
const onAddToMacros = () => {
if (selectedText){
let currMacros = qtState.params.macros;
const existingNames = currMacros.map(macro => macro.name);
const newName = getRandomName(existingNames);
let changed = [{ 'name': newName, 'sql': selectedText }];
api.put(
url_for('sqleditor.set_macros', {
'trans_id': qtState.params.trans_id,
}),
{ changed: changed }
)
.then(({ data: respData }) => {
const filteredData = respData.filter(m => Boolean(m.name));
setQtState(prev => ({
...prev,
params: {
...prev.params,
macros: filteredData,
},
}));
})
.catch(error => {
console.error(error);
});
}
setSelectedText('');
};
const onFilterClick = useCallback(()=>{
const onClose = ()=>docker.current.close('filter-dialog');
@ -884,8 +932,9 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
<MainToolBar
containerRef={containerRef}
onManageMacros={onManageMacros}
onAddToMacros={onAddToMacros}
onFilterClick={onFilterClick}
/>), [containerRef.current, onManageMacros, onFilterClick])}
/>), [containerRef.current, onManageMacros, onFilterClick, onAddToMacros])}
<Layout
getLayoutInstance={(obj)=>docker.current=obj}
defaultLayout={defaultLayout}

View File

@ -11,7 +11,7 @@ import React from 'react';
import SchemaView from '../../../../../../static/js/SchemaView';
import BaseUISchema from '../../../../../../static/js/SchemaView/base_schema.ui';
import gettext from 'sources/gettext';
import { QueryToolContext } from '../QueryToolComponent';
import { QueryToolContext, getRandomName } from '../QueryToolComponent';
import url_for from 'sources/url_for';
import _ from 'lodash';
import PropTypes from 'prop-types';
@ -23,17 +23,36 @@ class MacrosCollection extends BaseUISchema {
}
get idAttribute() {
return 'mid';
return 'id';
}
/* Returns the new data row for the schema based on defaults and input */
getNewData(current_macros, data={}) {
let newRow = {};
this.fields.forEach((field)=>{
newRow[field.id] = this.defaults[field.id];
});
newRow = {
...newRow,
...data,
};
if (current_macros){
// Extract an array of existing names from the 'macro' collection
const existingNames = current_macros.map(macro => macro.name);
const newName = getRandomName(existingNames);
newRow.name = newName;
}
return newRow;
}
get baseFields() {
let obj = this;
return [
{
id: 'id', label: gettext('Key'), cell: 'select', noEmpty: true,
id: 'mid', label: gettext('Key'), cell: 'select', noEmpty: false,
width: 100, options: obj.keyOptions, optionsReloadBasis: obj.keyOptions.length,
controlProps: {
allowClear: false,
allowClear: true,
}
},
{
@ -73,10 +92,14 @@ class MacrosSchema extends BaseUISchema {
}
validate(state, setError) {
let allKeys = state.macro.map((m)=>m.id.toString());
let allKeys = state.macro.map((m) => m.mid ? m.mid.toString() : null).filter(key => key !== null);
let allNames = state.macro.map((m) => m.name ? m.name.toLowerCase() : null);
if(allKeys.length != new Set(allKeys).size) {
setError('macro', gettext('Key must be unique.'));
return true;
} else if(allNames.length != new Set(allNames).size) {
setError('macro', gettext('Name must be unique.'));
return true;
}
return false;
}
@ -89,26 +112,28 @@ const useStyles = makeStyles((theme)=>({
},
}));
function getChangedMacros(macrosData, changeData) {
function getChangedMacros(userMacrosData, changeData) {
/* For backend, added, removed is changed. Convert all added, removed to changed. */
let changed = [];
for (const m of (changeData.macro.changed || [])) {
let newM = {...m};
if('id' in m) {
/* if key changed, clear prev and add new */
changed.push({id: m.mid, name: null, sql: null});
let em = _.find(macrosData, (d)=>d.mid==m.mid);
newM = {name: em.name, sql: em.sql, ...m};
let em = _.find(userMacrosData, (d)=>d.id==m.id);
newM = {name: m.name ? (m.name) : em.name , sql: m.sql ? m.sql : em.sql, mid: m.mid ? m.mid : em.mid, ...m};
} else {
newM.id = m.mid;
}
delete newM.mid;
changed.push(newM);
}
for (const m of (changeData.macro.deleted || [])) {
changed.push({id: m.id, name: null, sql: null});
}
for (const m of (changeData.macro.added || [])) {
if (m.id && m.id !== 0){
m.mid = m.id;
delete m.id;
}
changed.push(m);
}
return changed;
@ -118,30 +143,46 @@ export default function MacrosDialog({onClose, onSave}) {
const classes = useStyles();
const queryToolCtx = React.useContext(QueryToolContext);
const [macrosData, setMacrosData] = React.useState([]);
const [userMacrosData, setUserMacrosData] = React.useState([]);
const [macrosErr, setMacrosErr] = React.useState(null);
React.useEffect(async ()=>{
try {
let {data: respData} = await queryToolCtx.api.get(url_for('sqleditor.get_macros', {
'trans_id': queryToolCtx.params.trans_id,
}));
/* Copying id to mid to track key id changes */
setMacrosData(respData.macro.map((m)=>({...m, mid: m.id})));
// Fetch user macros data
let { data: userMacroRespData } = await queryToolCtx.api.get(url_for('sqleditor.get_user_macros'));
setUserMacrosData(userMacroRespData);
} catch (error) {
setMacrosErr(error);
}
}, []);
React.useEffect(async ()=>{
try {
// Fetch macros data
let {data: respData} = await queryToolCtx.api.get(url_for('sqleditor.get_macros', {
'trans_id': queryToolCtx.params.trans_id,
}));
/* Copying id to mid to track key id changes */
setMacrosData(respData.macro.map((m)=>({...m, mid: m.id})));
} catch (error) {
setMacrosErr(error);
}
}, []);
const onSaveClick = (_isNew, changeData)=>{
return new Promise((resolve, reject)=>{
const setMacros = async ()=>{
try {
let changed = getChangedMacros(macrosData, changeData);
let changed = getChangedMacros(userMacrosData, changeData);
let {data: respData} = await queryToolCtx.api.put(url_for('sqleditor.set_macros', {
'trans_id': queryToolCtx.params.trans_id,
}), {changed: changed});
resolve();
onSave(respData.macro?.filter((m)=>Boolean(m.name)));
onSave(respData.filter((m) => Boolean(m.name)));
onClose();
} catch (error) {
reject(error);
@ -159,6 +200,7 @@ export default function MacrosDialog({onClose, onSave}) {
if(keyOptions.length <= 0) {
return <></>;
}
return (
<SchemaView
formType={'dialog'}
@ -166,7 +208,7 @@ export default function MacrosDialog({onClose, onSave}) {
if(macrosErr) {
return Promise.reject(macrosErr);
}
return Promise.resolve({macro: macrosData.filter((m)=>Boolean(m.name))});
return Promise.resolve({macro: userMacrosData.filter((m)=>Boolean(m.name))});
}}
schema={new MacrosSchema(keyOptions)}
viewHelperProps={{

View File

@ -54,7 +54,7 @@ function autoCommitRollback(type, api, transId, value) {
return api.post(url, JSON.stringify(value));
}
export function MainToolBar({containerRef, onFilterClick, onManageMacros}) {
export function MainToolBar({containerRef, onFilterClick, onManageMacros, onAddToMacros}) {
const classes = useStyles();
const eventBus = useContext(QueryToolEventsContext);
const queryToolCtx = useContext(QueryToolContext);
@ -633,6 +633,7 @@ export function MainToolBar({containerRef, onFilterClick, onManageMacros}) {
label={gettext('Macros Menu')}
>
<PgMenuItem onClick={onManageMacros}>{gettext('Manage macros')}</PgMenuItem>
<PgMenuItem onClick={onAddToMacros}>{gettext('Add to macros')}</PgMenuItem>
<PgMenuDivider />
{queryToolCtx.params?.macros?.map((m)=>{
return (
@ -656,4 +657,5 @@ MainToolBar.propTypes = {
containerRef: CustomPropTypes.ref,
onFilterClick: PropTypes.func,
onManageMacros: PropTypes.func,
onAddToMacros: PropTypes.func
};

View File

@ -23,6 +23,7 @@ import { usePgAdmin } from '../../../../../../static/js/BrowserComponent';
import ConfirmPromotionContent from '../dialogs/ConfirmPromotionContent';
import usePreferences from '../../../../../../preferences/static/js/store';
import { getTitle } from '../../sqleditor_title';
import PropTypes from 'prop-types';
const useStyles = makeStyles(()=>({
@ -61,7 +62,7 @@ async function registerAutocomplete(editor, api, transId) {
});
}
export default function Query() {
export default function Query({onTextSelect}) {
const classes = useStyles();
const editor = React.useRef();
const eventBus = useContext(QueryToolEventsContext);
@ -390,7 +391,6 @@ export default function Query() {
const change = useCallback(()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.QUERY_CHANGED, editor.current.isDirty());
if(!queryToolCtx.params.is_query_tool && editor.current.isDirty()){
if(queryToolCtx.preferences.sqleditor.view_edit_promotion_warning){
checkViewEditDataPromotion();
@ -480,5 +480,11 @@ export default function Query() {
onChange={change}
autocomplete={true}
customKeyMap={shortcutOverrideKeys}
onTextSelect={onTextSelect}
/>;
}
Query.propTypes = {
onTextSelect: PropTypes.func,
};

View File

@ -32,15 +32,15 @@ class TestMacros(BaseTestGenerator):
operation='set',
data={
'changed': [
{'id': 1,
{'mid': 1,
'name': 'Test Macro 1',
'sql': 'SELECT 1;'
},
{'id': 2,
{'mid': 2,
'name': 'Test Macro 2',
'sql': 'SELECT 2;'
},
{'id': 3,
{'mid': 3,
'name': 'Test Macro 3',
'sql': 'SELECT 3;'
},
@ -129,10 +129,11 @@ class TestMacros(BaseTestGenerator):
self.assertEqual(response.status_code, 200)
for m in self.data['changed']:
if self.operation == 'set':
m['id'] = m['mid']
url = '/sqleditor/get_macros/{0}/{1}'.format(m['id'],
self.trans_id)
response = self.tester.get(url)
if self.operation == 'clear':
self.assertEqual(response.status_code, 410)
elif self.operation == 'set':

View File

@ -27,7 +27,7 @@ def get_macros(macro_id, json_resp):
:param json_resp: Set True to return json response
"""
if macro_id:
macro = UserMacros.query.filter_by(mid=macro_id,
macro = UserMacros.query.filter_by(id=macro_id,
uid=current_user.id).first()
if macro is None:
return make_json_response(
@ -37,7 +37,8 @@ def get_macros(macro_id, json_resp):
)
else:
return ajax_response(
response={'id': macro.mid,
response={'id': macro.id,
'mid':macro.mid,
'name': macro.name,
'sql': macro.sql},
status=200
@ -45,13 +46,12 @@ def get_macros(macro_id, json_resp):
else:
macros = db.session.query(Macros.id, Macros.alt, Macros.control,
Macros.key, Macros.key_code,
UserMacros.name, UserMacros.sql
).outerjoin(
UserMacros.name, UserMacros.sql,
UserMacros.id).outerjoin(
UserMacros, and_(Macros.id == UserMacros.mid,
UserMacros.uid == current_user.id)).all()
data = []
for m in macros:
key_label = 'Ctrl + ' + m[3] if m[2] is True else 'Alt + ' + m[3]
data.append({'id': m[0], 'alt': m[1],
@ -74,7 +74,8 @@ def get_user_macros():
This method is used to get all the user macros.
"""
macros = db.session.query(UserMacros.name,
macros = db.session.query(UserMacros.id,
UserMacros.name,
Macros.id,
Macros.alt, Macros.control,
Macros.key, Macros.key_code,
@ -86,11 +87,17 @@ def get_user_macros():
data = []
for m in macros:
key_label = 'Ctrl + ' + m[4] if m[3] is True else 'Alt + ' + m[4]
data.append({'name': m[0], 'id': m[1], 'key': m[4],
'key_label': key_label, 'alt': 1 if m[2] else 0,
'control': 1 if m[3] else 0, 'key_code': m[5],
'sql': m[6]})
key_label = (
'Ctrl + ' + str(m[5])
if m[4] is True
else 'Alt + ' + str(m[5])
if m[5] is not None
else ''
)
data.append({'id': m[0], 'name': m[1], 'mid': m[2], 'key': m[5],
'key_label': key_label, 'alt': 1 if m[3] else 0,
'control': 1 if m[4] else 0, 'key_code': m[6],
'sql': m[7]})
return data
@ -111,21 +118,21 @@ def set_macros():
)
for m in data['changed']:
if m['id']:
if m.get('id'):
macro = UserMacros.query.filter_by(
uid=current_user.id,
mid=m['id']).first()
id=m['id']).first()
if macro:
status, msg = update_macro(m, macro)
else:
status, msg = create_macro(m)
else:
status, msg = create_macro(m)
if not status:
return make_json_response(
status=410, success=0, errormsg=msg
)
return get_macros(None, True)
return get_user_macros()
def create_macro(macro):
@ -146,7 +153,7 @@ def create_macro(macro):
try:
new_macro = UserMacros(
uid=current_user.id,
mid=macro['id'],
mid=macro['mid'] if macro.get('mid') else None,
name=macro['name'],
sql=macro['sql']
)
@ -168,6 +175,7 @@ def update_macro(data, macro):
name = data.get('name', None)
sql = data.get('sql', None)
mid = data.get('mid', None)
if (name or sql) and macro.sql and 'name' in data and name is None:
return False, gettext(
@ -177,11 +185,12 @@ def update_macro(data, macro):
"Could not find the required parameter (sql).")
try:
if name or sql:
if name or sql or mid:
if name:
macro.name = name
if sql:
macro.sql = sql
macro.mid = mid if mid != 0 else None
else:
db.session.delete(macro)