diff --git a/web/pgadmin/browser/register_browser_preferences.py b/web/pgadmin/browser/register_browser_preferences.py index af7dc4f2..0b0224b3 100644 --- a/web/pgadmin/browser/register_browser_preferences.py +++ b/web/pgadmin/browser/register_browser_preferences.py @@ -519,7 +519,7 @@ def register_browser_preferences(self): self.open_in_new_tab = self.preference.register( 'tab_settings', 'new_browser_tab_open', - gettext("Open in new browser tab"), 'select2', None, + gettext("Open in new browser tab"), 'select', None, category_label=PREF_LABEL_OPTIONS, options=ope_new_tab_options, help_str=gettext( @@ -527,7 +527,7 @@ def register_browser_preferences(self): 'or PSQL Tool from the drop-down to set ' 'open in new browser tab for that particular module.' ), - select2={ + control_props={ 'multiple': True, 'allowClear': False, 'tags': True, 'first_empty': False, 'selectOnClose': False, 'emptyOptions': True, diff --git a/web/pgadmin/browser/server_groups/servers/static/js/binary_path.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/binary_path.ui.js new file mode 100644 index 00000000..af31cedb --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/static/js/binary_path.ui.js @@ -0,0 +1,68 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2022, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import _ from 'lodash'; +import url_for from 'sources/url_for'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import getApiInstance from '../../../../../static/js/api_instance'; +import Notify from '../../../../../static/js/helpers/Notifier'; + +export function getBinaryPathSchema() { + + return new BinaryPathSchema(); +} + +export default class BinaryPathSchema extends BaseUISchema { + constructor() { + super({ + isDefault: false, + serverType: undefined, + binaryPath: null, + }); + } + + get baseFields() { + return [ + { + id: 'isDefault', label: gettext('Set as default'), type: 'switch', + cell: 'switch', width: 32, + disabled: (state) => { + return state.binaryPath ? false : true; + } + }, + { + id: 'serverType', + label: gettext('Database Server'), + type: 'text', cell: '', + width: 40, + }, + { + id: 'binaryPath', label: gettext('Binary Path'), cell: 'file', + isvalidate: true, controlProps: { dialogType: 'select_folder', supportedTypes: ['*', 'sql', 'backup'], dialogTitle: 'Select folder' }, + validate: (data) => { + const api = getApiInstance(); + if (_.isNull(data) || data.trim() === '') { + Notify.alert(gettext('Validate Path'), gettext('Path should not be empty.')); + } + + api.post(url_for('misc.validate_binary_path'), + JSON.stringify({ 'utility_path': data })) + .then(function (res) { + Notify.alert(gettext('Validate binary path'), gettext(res.data.data)); + }) + .catch(function (error) { + Notify.pgNotifier(error, gettext('Failed to validate binary path.')); + }); + return true; + } + }, + ]; + } +} diff --git a/web/pgadmin/misc/__init__.py b/web/pgadmin/misc/__init__.py index fb436d70..6cd83404 100644 --- a/web/pgadmin/misc/__init__.py +++ b/web/pgadmin/misc/__init__.py @@ -67,7 +67,10 @@ class MiscModule(PgAdminModule): 'user_language', 'user_language', gettext("User language"), 'options', 'en', category_label=gettext('User language'), - options=lang_options + options=lang_options, + control_props={ + 'allowClear': False, + } ) theme_options = [] @@ -90,6 +93,9 @@ class MiscModule(PgAdminModule): gettext("Theme"), 'options', 'standard', category_label=gettext('Themes'), options=theme_options, + control_props={ + 'allowClear': False, + }, help_str=gettext( 'A refresh is required to apply the theme. Below is the ' 'preview of the theme' diff --git a/web/pgadmin/misc/file_manager/__init__.py b/web/pgadmin/misc/file_manager/__init__.py index d132e46e..ca69e609 100644 --- a/web/pgadmin/misc/file_manager/__init__.py +++ b/web/pgadmin/misc/file_manager/__init__.py @@ -167,10 +167,14 @@ class FileManagerModule(PgAdminModule): ) self.file_dialog_view = self.preference.register( 'options', 'file_dialog_view', - gettext("File dialog view"), 'options', 'list', + gettext("File dialog view"), 'select', 'list', category_label=PREF_LABEL_OPTIONS, options=[{'label': gettext('List'), 'value': 'list'}, - {'label': gettext('Grid'), 'value': 'grid'}] + {'label': gettext('Grid'), 'value': 'grid'}], + control_props={ + 'allowClear': False, + 'tags': False + }, ) self.show_hidden_files = self.preference.register( 'options', 'show_hidden_files', @@ -236,7 +240,7 @@ def file_manager_config(trans_id): """render the required json""" data = Filemanager.get_trasaction_selection(trans_id) pref = Preferences.module('file_manager') - file_dialog_view = pref.preference('file_dialog_view').get() + file_dialog_view = pref.preference('file_dialog_view').get()[0] show_hidden_files = pref.preference('show_hidden_files').get() return Response(response=render_template( diff --git a/web/pgadmin/preferences/__init__.py b/web/pgadmin/preferences/__init__.py index 9872a672..3f754ada 100644 --- a/web/pgadmin/preferences/__init__.py +++ b/web/pgadmin/preferences/__init__.py @@ -37,26 +37,23 @@ class PreferencesModule(PgAdminModule): """ def get_own_javascripts(self): - return [{ - 'name': 'pgadmin.preferences', - 'path': url_for('preferences.index') + 'preferences', - 'when': None - }] + scripts = list() + for name, script in [ + ['pgadmin.preferences', 'js/preferences'] + ]: + scripts.append({ + 'name': name, + 'path': url_for('preferences.index') + script, + 'when': None + }) + + return scripts def get_own_stylesheets(self): return [] def get_own_menuitems(self): - return { - 'file_items': [ - MenuItem(name='mnu_preferences', - priority=997, - module="pgAdmin.Preferences", - callback='show', - icon='fa fa-cog', - label=gettext('Preferences')) - ] - } + return {} def get_exposed_url_endpoints(self): """ @@ -149,7 +146,8 @@ def _iterate_categories(pref_d, label, res): "label": gettext(pref_d['label']), "inode": True, "open": True, - "branch": [] + "children": [], + "value": gettext(pref_d['label']), } for c in pref_d['categories']: @@ -162,13 +160,15 @@ def _iterate_categories(pref_d, label, res): "id": c['id'], "mid": pref_d['id'], "label": gettext(c['label']), + "value": '{0}{1}'.format(c['id'], gettext(c['label'])), "inode": False, "open": False, - "preferences": sorted(c['preferences'], key=label) + "preferences": sorted(c['preferences'], key=label), + "showCheckbox": False } - (om['branch']).append(oc) - om['branch'] = sorted(om['branch'], key=label) + (om['children']).append(oc) + om['children'] = sorted(om['children'], key=label) res.append(om) @@ -194,53 +194,85 @@ def preferences_s(): ) -@blueprint.route("/", methods=["PUT"], endpoint="update") +def get_data(): + pref_data = request.form if request.form else json.loads( + request.data.decode()) + + if not pref_data: + raise ValueError("Please provide the valid preferences data to save.") + + return pref_data + + +@blueprint.route("/", methods=["PUT"], endpoint="update") @login_required -def save(pid): +def save(): """ Save a specific preference. """ - data = request.form if request.form else json.loads(request.data.decode()) + pref_data = get_data() - if data['name'] in ['vw_edt_tab_title_placeholder', - 'qt_tab_title_placeholder', - 'debugger_tab_title_placeholder'] \ - and data['value'].isspace(): - data['value'] = '' + for data in pref_data: + if data['name'] in ['vw_edt_tab_title_placeholder', + 'qt_tab_title_placeholder', + 'debugger_tab_title_placeholder'] \ + and data['value'].isspace(): + data['value'] = '' - res, msg = Preferences.save( - data['mid'], data['category_id'], data['id'], data['value']) - sgm.get_nodes(sgm) + if data['name'] == 'pg_bin_dir': + check_binary_path_data(data) - if not res: - return internal_server_error(errormsg=msg) + res, msg = Preferences.save( + data['mid'], data['category_id'], data['id'], data['value']) + sgm.get_nodes(sgm) - response = success_return() + if not res: + return internal_server_error(errormsg=msg) - # Set cookie & session for language settings. - # This will execute every time as could not find the better way to know - # that which preference is getting updated. + response = success_return() - misc_preference = Preferences.module('misc') - user_languages = misc_preference.preference( - 'user_language' - ) + # Set cookie & session for language settings. + # This will execute every time as could not find the better way to know + # that which preference is getting updated. + + misc_preference = Preferences.module('misc') + user_languages = misc_preference.preference( + 'user_language' + ) - language = 'en' - if user_languages: - language = user_languages.get() or language + language = 'en' + if user_languages: + language = user_languages.get() or language - domain = dict() - if config.COOKIE_DEFAULT_DOMAIN and\ - config.COOKIE_DEFAULT_DOMAIN != 'localhost': - domain['domain'] = config.COOKIE_DEFAULT_DOMAIN + domain = dict() + if config.COOKIE_DEFAULT_DOMAIN and \ + config.COOKIE_DEFAULT_DOMAIN != 'localhost': + domain['domain'] = config.COOKIE_DEFAULT_DOMAIN - setattr(session, 'PGADMIN_LANGUAGE', language) - response.set_cookie("PGADMIN_LANGUAGE", value=language, - path=config.COOKIE_DEFAULT_PATH, - secure=config.SESSION_COOKIE_SECURE, - httponly=config.SESSION_COOKIE_HTTPONLY, - samesite=config.SESSION_COOKIE_SAMESITE, - **domain) + setattr(session, 'PGADMIN_LANGUAGE', language) + response.set_cookie("PGADMIN_LANGUAGE", value=language, + path=config.COOKIE_DEFAULT_PATH, + secure=config.SESSION_COOKIE_SECURE, + httponly=config.SESSION_COOKIE_HTTPONLY, + samesite=config.SESSION_COOKIE_SAMESITE, + **domain) return response + + +def check_binary_path_data(data): + pref_val = json.loads(data['value']) + pref_data = Preferences.preferences() + _data = [el for el in pref_data if el['id'] == data['mid']] + if _data: + categories = _data[0]['categories'] + pref = [pref for ct in categories for pref in ct['preferences'] + if + ct['id'] == data['category_id'] and pref['id'] == data[ + 'id']] + if pref: + values = json.loads(pref[0]['value']) + pref = [pval if pval['version'] == val['version'] else val + for val in values for pval in pref_val] + data['value'] = json.dumps(pref) + return data diff --git a/web/pgadmin/preferences/static/js/components/PreferencesComponent.jsx b/web/pgadmin/preferences/static/js/components/PreferencesComponent.jsx new file mode 100644 index 00000000..a433a835 --- /dev/null +++ b/web/pgadmin/preferences/static/js/components/PreferencesComponent.jsx @@ -0,0 +1,511 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2022, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import url_for from 'sources/url_for'; +import React, { useEffect } from 'react'; +import { Box } from '@material-ui/core'; +import PropTypes from 'prop-types'; +import { makeStyles } from '@material-ui/core/styles'; +import SchemaView from '../../../../static/js/SchemaView'; +import getApiInstance from '../../../../static/js/api_instance'; +import CloseSharpIcon from '@material-ui/icons/CloseSharp'; +import SaveSharpIcon from '@material-ui/icons/SaveSharp'; +import clsx from 'clsx'; +import Notify from '../../../../static/js/helpers/Notifier'; +import pgAdmin from 'sources/pgadmin'; +import { DefaultButton, PrimaryButton } from '../../../../static/js/components/Buttons'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { getBinaryPathSchema } from '../../../../browser/server_groups/servers/static/js/binary_path.ui'; +import PreferencesTree from './PreferencesTree'; +import { _set_dynamic_tab} from '../../../../tools/datagrid/static/js/show_query_tool'; + +class PreferencesSchema extends BaseUISchema { + constructor(initValues = {}, schemaFields = []) { + super({ + ...initValues + }); + this.schemaFields = schemaFields; + this.category = ''; + } + + get idAttribute() { + return 'id'; + } + + setSelectedCategory(category) { + this.category = category; + } + + get baseFields() { + return this.schemaFields; + } +} + +const useStyles = makeStyles((theme) => + ({ + root: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + height: '100%', + backgroundColor: theme.palette.background.default, + minHeight: 520, + minWidth: 700, + overflow: 'hidden', + '&$disabled': { + color: '#ddd', + } + }, + body: { + display: 'flex', + flexDirection: 'column', + height: '100%', + }, + preferences: { + borderColor: theme.otherVars.borderColor, + display: 'flex', + flexGrow: 1, + height: '100%', + minHeight: 0, + overflow: 'hidden' + + }, + treeContainer: { + flexBasis: '25%', + alignItems: 'flex-start', + paddingLeft: '5px', + minHeight: 0, + }, + preferencesContainer: { + flexBasis: '75%', + padding: '5px', + borderColor: theme.otherVars.borderColor + '!important', + borderLeft: '1px solid', + position: 'relative', + height: '100%', + paddingTop: '5px', + overflow: 'auto' + }, + actionBtn: { + alignItems: 'flex-start', + }, + buttonMargin: { + marginLeft: '0.5em' + }, + footer: { + borderTop: '1px solid #dde0e6 !important', + padding: '0.5rem', + display: 'flex', + width: '100%', + background: theme.otherVars.headerBg, + zIndex: 999, + }, + customTreeClass: { + '& .react-checkbox-tree': { + height: '100% !important', + border: 'none !important', + }, + }, + preferencesTree: { + height: 'calc(100% - 50px)', + minHeight: 0 + } + }), +); + + +function RightPanel({ schema, ...props }) { + let initData = () => new Promise((resolve, reject) => { + try { + resolve(props.initValues); + } catch (error) { + reject(error); + } + }); + + return ( + { + props.onDataChange(changedData); + }} + /> + ); +} + +RightPanel.propTypes = { + schema: PropTypes.object, + initValues: PropTypes.object, + onDataChange: PropTypes.func +}; + + +export default function PreferencesComponent({ ...props }) { + const classes = useStyles(); + const [disableSave, setDisableSave] = React.useState(true); + const prefSchema = React.useRef(new PreferencesSchema({}, [])); + const prefChangedData = React.useRef({}); + const [prefTreeData, setPrefTreeData] = React.useState(null); + const [initValues, setInitValues] = React.useState({}); + const [loadTree, setLoadTree] = React.useState(0); + const api = getApiInstance(); + + useEffect(() => { + const pref_url = url_for('preferences.index'); + api({ + url: pref_url, + method: 'GET', + }).then((res) => { + let preferencesData = []; + let preferencesTreeData = []; + let preferencesValues = {}; + res.data.forEach(node => { + let id = Math.floor(Math.random() * 1000); + let tdata = { + 'id': id.toString(), + 'label': node.label, + '_label': node.label, + 'name': node.label, + 'icon': '', + 'inode': true, + 'type': 2, + '_type': node.label.toLowerCase(), + '_id': id, + '_pid': null, + 'childrenNodes': [], + 'expanded': true, + }; + + node.children.forEach(subNode => { + let sid = Math.floor(Math.random() * 1000); + let nodeData = { + 'id': sid.toString(), + 'label': subNode.label, + '_label': subNode.label, + 'name': subNode.label, + 'icon': '', + 'inode': false, + '_type': subNode.label.toLowerCase(), + '_id': sid, + '_pid': node.id, + 'type': 1, + 'expanded': false, + }; + subNode.preferences.forEach((element) => { + let addNote = false; + let note = ''; + let type = getControlMappedForType(element.type); + + if (type === 'file') { + addNote = true; + note = gettext('Enter the directory in which the psql, pg_dump, pg_dumpall, and pg_restore utilities can be found for the corresponding database server version. The default path will be used for server versions that do not have a path specified.'); + element.type = 'collection'; + element.schema = getBinaryPathSchema(); + element.canAdd = false; + element.canDelete = false; + element.canEdit = false; + element.editable = false; + preferencesValues[element.id] = JSON.parse(element.value); + } + else if (type == 'select') { + if (element.control_props !== undefined) { + element.controlProps = element.control_props; + } else { + element.controlProps = {}; + } + preferencesValues[element.id] = element.value; + element.type = type; + } + else if (type === 'keyboardShortcut') { + element.type = 'keyboardShortcut'; + element.canAdd = false; + element.canDelete = false; + element.canEdit = false; + element.editable = false; + if (pgAdmin.Browser.get_preference(node.label.toLowerCase(), element.name)?.value) { + let temp = pgAdmin.Browser.get_preference(node.label.toLowerCase(), element.name).value; + preferencesValues[element.id] = temp; + } else { + preferencesValues[element.id] = element.value; + } + delete element.value; + } else if(type === 'threshold') { + element.type = 'threshold'; + + let _val = element.value.split('|'); + preferencesValues[element.id] = {'warning': _val[0], 'alert': _val[1]}; + } else { + element.type = type; + preferencesValues[element.id] = element.value; + } + + delete element.value; + element.visible = false; + element.helpMessage = element?.help_str ? element.help_str : null; + preferencesData.push(element); + + if (addNote) { + preferencesData.push( + { + id: 'note_' + element.id, + type: 'note', text: [ + '', + ].join(''), + visible: false, + 'parentId': nodeData['id'] + }, + ); + } + element.parentId = nodeData['id']; + }); + tdata['childrenNodes'].push(nodeData); + }); + + // set Preferences Tree data + preferencesTreeData.push(tdata); + + }); + setPrefTreeData(preferencesTreeData); + setInitValues(preferencesValues); + // set Preferences schema + prefSchema.current = new PreferencesSchema(preferencesValues, preferencesData); + }).catch((err) => { + Notify.alert(err.response.data); + }); + }, []); + + function getControlMappedForType(type) { + switch (type) { + case 'text': + return 'text'; + case 'input': + return 'text'; + case 'boolean': + return 'switch'; + case 'node': + return 'switch'; + case 'integer': + return 'numeric'; + case 'numeric': + return 'numeric'; + case 'date': + return 'datetimepicker'; + case 'datetime': + return 'datetimepicker'; + case 'options': + return 'select'; + case 'select': + return 'select'; + case 'select2': + return 'select'; + case 'multiline': + return 'multiline'; + case 'switch': + return 'switch'; + case 'keyboardshortcut': + return 'keyboardShortcut'; + case 'radioModern': + return 'toggle'; + case 'selectFile': + return 'file'; + case 'threshold': + return 'threshold'; + default: + if (console && console.warn) { + // Warning for developer only. + console.warn( + 'Hmm.. We don\'t know how to render this type - \'\'' + type + '\' of control.' + ); + } + return 'input'; + } + } + + function getCollectionValue(_metadata, value) { + let val = value; + if (typeof (value) == 'object') { + if(_metadata[0].type == 'collection' && _metadata[0].schema) { + if('binaryPath' in value.changed[0]) { + val = JSON.stringify(value.changed); + }else { + let key_val = { + 'char': value.changed[0]['key'], + 'key_code': value.changed[0]['code'], + }; + value.changed[0]['key'] = key_val; + val = value.changed[0]; + } + } else if('warning' in value) { + val = value['warning'] + '|' + value['alert']; + } else if(value?.changed && value.changed.length > 0) { + val = JSON.stringify(value.changed); + } + } + return val; + } + + function savePreferences(data) { + let _data = []; + for (const [key, value] of Object.entries(data.current)) { + let _metadata = prefSchema.current.schemaFields.filter((el) => { return el.id == key; }); + if (_metadata.length > 0) { + let val = getCollectionValue(_metadata, value); + _data.push({ + 'category_id': _metadata[0]['cid'], + 'id': parseInt(key), + 'mid': _metadata[0]['mid'], + 'name': _metadata[0]['name'], + 'value': val, + }); + } + } + + if (_data.length > 0) { + save(_data, data); + } + + } + + function checkRefreshRequired(pref, requires_refresh) { + if (pref.name == 'theme') { + requires_refresh = true; + } + + if (pref.name == 'user_language') { + requires_refresh = true; + } + + return requires_refresh; + } + + function save(save_data, data) { + api({ + url: url_for('preferences.index'), + method: 'PUT', + data: save_data, + }).then(() => { + let requires_refresh = false; + /* Find the modules changed */ + let modulesChanged = {}; + for (const [key] of Object.entries(data.current)) { + let pref = pgAdmin.Browser.get_preference_for_id(Number(key)); + + if (pref['name'] == 'dynamic_tabs') { + _set_dynamic_tab(pgAdmin.Browser, !pref['value']); + } + + if (!modulesChanged[pref.module]) { + modulesChanged[pref.module] = true; + } + + requires_refresh = checkRefreshRequired(pref, requires_refresh); + + if (pref.name == 'hide_shared_server') { + Notify.confirm( + gettext('Browser tree refresh required'), + gettext('A browser tree refresh is required. Do you wish to refresh the tree?'), + function () { + pgAdmin.Browser.tree.destroy({ + success: function () { + pgAdmin.Browser.initializeBrowserTree(pgAdmin.Browser); + return true; + }, + }); + }, + function () { + return true; + }, + gettext('Refresh'), + gettext('Later') + ); + } + } + + if (requires_refresh) { + Notify.confirm( + gettext('Refresh required'), + gettext('A page refresh is required to apply the theme. Do you wish to refresh the page now?'), + function () { + /* If user clicks Yes */ + location.reload(); + return true; + }, + function () { props.closeModal(); /*props.panel.close()*/ }, + gettext('Refresh'), + gettext('Later') + ); + } + // Refresh preferences cache + pgAdmin.Browser.cache_preferences(modulesChanged); + props.closeModal(); /*props.panel.close()*/ + }).catch((err) => { + Notify.alert(err.response.data); + }); + } + + return ( + + + + + {prefTreeData && + { + if (item.type == 1) { + prefSchema.current.schemaFields.forEach((field) => { + field.visible = field.parentId.toString() === item._metadata.data.id.toString(); + }); + } + setLoadTree(Math.floor(Math.random() * 1000)); + }} + > + } + + + { + prefSchema.current && loadTree > 0 ? + { + Object.keys(changedData).length > 0 ? setDisableSave(false) : setDisableSave(true); + prefChangedData.current = changedData; + }}> + : <> + } + + + + + { props.closeModal(); /*props.panel.close()*/ }} startIcon={ { props.closeModal(); /*props.panel.close()*/ }} />}> + {gettext('Cancel')} + + } disabled={disableSave} onClick={() => { savePreferences(prefChangedData); }}> + {gettext('Save')} + + + + {/* */} + + + + ); +} + +PreferencesComponent.propTypes = { + schema: PropTypes.array, + initValues: PropTypes.object, + closeModal: PropTypes.func, + isFullScreen: PropTypes.bool, + +}; diff --git a/web/pgadmin/preferences/static/js/components/PreferencesTree.jsx b/web/pgadmin/preferences/static/js/components/PreferencesTree.jsx new file mode 100644 index 00000000..a502323b --- /dev/null +++ b/web/pgadmin/preferences/static/js/components/PreferencesTree.jsx @@ -0,0 +1,135 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2022, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import _ from 'lodash'; +import React, { useRef } from 'react'; +import PropTypes from 'prop-types'; +import { FileTreeItem } from 'pgadmin4-tree/src/FileTreeItem/'; +import { ManagePreferenceTreeNodes } from '../../../../static/js/tree/preference_nodes'; +import { TreeModel, FileTree } from 'react-aspen'; +import { DecorationsManager, Decoration, TargetMatchMode } from 'aspen-decorations'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { Notificar } from 'notificar'; + +export class TreeModelX extends TreeModel { + decorations = null; + constructor(host, mountPath) { + super(host, mountPath); + this.decorations = new DecorationsManager(this.root); + this.activeFileDec = new Decoration('active'); + this.decorations.addDecoration(this.activeFileDec); + } +} + +function getModel(data) { + const MOUNT_POINT = '/preferences'; + + // Setup host + let ptree = new ManagePreferenceTreeNodes(data); + + // Init Tree with the Tree Parent node '/preferences' + ptree.init(MOUNT_POINT); + const host = { + pathStyle: 'unix', + getItems: async (path) => { + return ptree.readNode(path); + }, + }; + + return new TreeModelX(host, MOUNT_POINT); +} + +function setActiveFile(model, item, handler, events, activeFile) { + let fileH = item; + if (fileH === model.root) { return; } + if (_.isUndefined(activeFile.current)) { + activeFile.current = fileH; + } else { + model.activeFileDec.removeTarget(activeFile.current); + activeFile.current = fileH; + } + + if (fileH) { + model.activeFileDec.addTarget(fileH, TargetMatchMode.SelfAndChildren); + } +} + +function toggleDirectory(dir, handler) { + if (dir.type === 2) { + if ((dir).expanded) { + handler.current.closeDirectory(dir); + } else { + dir._children = null; + dir.flattenedBranch = null; + const ref = FileTreeItem.itemIdToRefMap.get(dir.id); + if (ref) { + ref.style.background = 'none'; + const label$ = ref.querySelector('i.directory-toggle'); + label$.classList.add('loading'); + } + handler.current.openDirectory(dir); + + if (ref) { + ref.style.background = 'none'; + const label$ = ref.querySelector('i.directory-toggle'); + if (label$) label$.classList.remove('loading'); + } + } + } +} + +export default function PreferencesTree({ data, getSelectedItem }) { + const model = useRef(getModel(data)); + const internalHandler = useRef(); + const events = new Notificar(); + const activeFile = useRef(); + + return ( + + {({ width, height }) => ( + { + internalHandler.current = handel; + return true; + } + }> + {(props) => { + if (type === 1) { + getSelectedItem(item); + setActiveFile(model.current, item, internalHandler, events, activeFile); + } + if (type === 2) { + toggleDirectory(item, internalHandler); + } + }} + onDoubleClick={() => {/*This is intentional (SonarQube)*/}} + changeDirectoryCount={() => {/*This is intentional (SonarQube)*/}} + decorations={model.current.decorations.getDecorations(props.item)} + />} + + )} + + + ); +} + +PreferencesTree.propTypes = { + data: PropTypes.array, + getSelectedItem: PropTypes.func, + item: PropTypes.object, + itemType: PropTypes.number +}; diff --git a/web/pgadmin/preferences/static/js/index.js b/web/pgadmin/preferences/static/js/index.js new file mode 100644 index 00000000..2764633f --- /dev/null +++ b/web/pgadmin/preferences/static/js/index.js @@ -0,0 +1,21 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2022, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// +import pgAdmin from 'sources/pgadmin'; +import pgBrowser from 'top/browser/static/js/browser'; +import Preferences from './preferences'; + +if(!pgAdmin.Preferences) { + pgAdmin.Preferences = {}; +} + +pgAdmin.Preferences = Preferences.getInstance(pgAdmin, pgBrowser); + +module.exports = { + Preferences: Preferences, +}; diff --git a/web/pgadmin/preferences/static/js/preferences.js b/web/pgadmin/preferences/static/js/preferences.js index ebef31ca..dac6e35e 100644 --- a/web/pgadmin/preferences/static/js/preferences.js +++ b/web/pgadmin/preferences/static/js/preferences.js @@ -7,624 +7,48 @@ // ////////////////////////////////////////////////////////////// +import React from 'react'; +import gettext from 'sources/gettext'; +import PreferencesComponent from './components/PreferencesComponent'; import Notify from '../../../static/js/helpers/Notifier'; -define('pgadmin.preferences', [ - 'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'backbone', - 'pgadmin.alertifyjs', 'sources/pgadmin', 'pgadmin.backform', - 'pgadmin.browser', 'sources/modify_animation', - 'tools/datagrid/static/js/show_query_tool', - 'sources/tree/pgadmin_tree_save_state', -], function( - gettext, url_for, $, _, Backbone, Alertify, pgAdmin, Backform, pgBrowser, - modifyAnimation, showQueryTool -) { - // This defines the Preference/Options Dialog for pgAdmin IV. - - /* - * Hmm... this module is already been initialized, we can refer to the old - * object from here. - */ - if (pgAdmin.Preferences) - return pgAdmin.Preferences; - - pgAdmin.Preferences = { - init: function() { - if (this.initialized) - return; - - this.initialized = true; - - // Declare the Preferences dialog - Alertify.dialog('preferencesDlg', function() { - - var jTree, // Variable to create the aci-tree - controls = [], // Keep tracking of all the backform controls - // created by the dialog. - // Dialog containter - $container = $('
'); - - - /* - * Preference Model - * - * This model will be used to keep tracking of the changes done for - * an individual option. - */ - var PreferenceModel = Backbone.Model.extend({ - idAttribute: 'id', - defaults: { - id: undefined, - value: undefined, - }, - }); - - /* - * Preferences Collection object. - * - * We will use only one collection object to keep track of all the - * preferences. - */ - var changed = {}, - preferences = this.preferences = new(Backbone.Collection.extend({ - model: PreferenceModel, - url: url_for('preferences.index'), - updateAll: function() { - // We will send only the modified data to the server. - for (var key in changed) { - this.get(key).save(); - } - - return true; - }, - }))(null); - - preferences.on('reset', function() { - // Reset the changed variables - changed = {}; - }); - - preferences.on('change', function(m) { - var id = m.get('id'), - dependents = m.get('dependents'); - if (!(id in changed)) { - // Keep track of the original value - changed[id] = m._previousAttributes.value; - } else if (_.isEqual(m.get('value'), changed[id])) { - // Remove unchanged models. - delete changed[id]; - } - - // Check dependents exist or not. If exists then call dependentsFound function. - if (!_.isNull(dependents) && Array.isArray(dependents) && dependents.length > 0) { - dependentsFound(m.get('name'), m.get('value'), dependents); - } - }); - - /* - * Function: dependentsFound - * - * This method will be used to iterate through all the controls and - * dependents. If found then perform the appropriate action. - */ - var dependentsFound = function(pref_name, pref_val, dependents) { - // Iterate through all the controls and check the dependents - _.each(controls, function(c) { - let ctrl_name = c.model.get('name'); - _.each(dependents, function(deps) { - if (ctrl_name === deps) { - // Create methods to take appropriate actions and call here. - enableDisableMaxWidth(pref_name, pref_val, c); - } - }); - }); - }; - - /* - * Function: enableDisableMaxWidth - * - * This method will be used to enable and disable Maximum Width control - */ - var enableDisableMaxWidth = function(pref_name, pref_val, control) { - if (pref_name === 'column_data_auto_resize' && pref_val === 'by_name') { - control.$el.find('input').prop('disabled', true); - control.$el.find('input').val(0); - } else if (pref_name === 'column_data_auto_resize' && pref_val === 'by_data') { - control.$el.find('input').prop('disabled', false); - } - }; - - /* - * Function: renderPreferencePanel - * - * Renders the preference panel in the content div based on the given - * preferences. - */ - var renderPreferencePanel = function(prefs) { - /* - * Clear the existing html in the preferences content - */ - var content = $container.find('.preferences_content'); - - /* - * We should clean up the existing controls. - */ - if (controls) { - _.each(controls, function(c) { - if ('$sel' in c) { - if (c.$sel.data('select2').isOpen()) c.$sel.data('select2').close(); - } - c.remove(); - }); - } - content.empty(); - controls = []; - - /* - * We will create new set of controls and render it based on the - * list of preferences using the Backform Field, Control. - */ - _.each(prefs, function(p) { - - var m = preferences.get(p.id); - m.errorModel = new Backbone.Model(); - var f = new Backform.Field( - _.extend({}, p, { - id: 'value', - name: 'value', - }) - ), - cntr = new(f.get('control'))({ - field: f, - model: m, - }); - content.append(cntr.render().$el); - - // We will keep track of all the controls rendered at the - // moment. - controls.push(cntr); - }); - - /* Iterate through all preferences and check if dependents found. - * If found then call the dependentsFound method - */ - _.each(prefs, function(p) { - let m = preferences.get(p.id); - let dependents = m.get('dependents'); - if (!_.isNull(dependents) && Array.isArray(dependents) && dependents.length > 0) { - dependentsFound(m.get('name'), m.get('value'), dependents); - } - }); - }; - - /* - * Function: dialogContentCleanup - * - * Do the dialog container cleanup on openning. - */ - - var dialogContentCleanup = function() { - // Remove the existing preferences - if (!jTree) - return; - - /* - * Remove the aci-tree (mainly to remove the jquery object of - * aciTree from the system for this container). - */ - try { - jTree.aciTree('destroy'); - } catch (ex) { - // Sometimes - it fails to destroy the tree properly and throws - // exception. - console.warn(ex.stack || ex); - } - jTree.off('acitree', treeEventHandler); - - // We need to reset the data from the preferences too - preferences.reset(); - - /* - * Clean up the existing controls. - */ - if (controls) { - _.each(controls, function(c) { - c.remove(); - }); - } - controls = []; - - // Remove all the objects now. - $container.empty(); - }, - /* - * Function: selectFirstCategory - * - * Whenever a user select a module instead of a category, we should - * select the first categroy of it. - */ - selectFirstCategory = function(api, item) { - var data = item ? api.itemData(item) : null; - - if (data && data.preferences) { - api.select(item); - return; - } - item = api.first(item); - selectFirstCategory(api, item); - }, - /* - * A map on how to create controls for each datatype in preferences - * dialog. - */ - getControlMappedForType = function(p) { - switch (p.type) { - case 'text': - return 'input'; - case 'boolean': - p.options = { - onText: gettext('True'), - offText: gettext('False'), - onColor: 'success', - offColor: 'ternary', - size: 'mini', - }; - return 'switch'; - case 'node': - p.options = { - onText: gettext('Show'), - offText: gettext('Hide'), - onColor: 'success', - offColor: 'ternary', - size: 'mini', - width: '56', - }; - return 'switch'; - case 'integer': - return 'numeric'; - case 'numeric': - return 'numeric'; - case 'date': - return 'datepicker'; - case 'datetime': - return 'datetimepicker'; - case 'options': - var opts = [], - has_value = false; - // Convert the array to SelectControl understandable options. - _.each(p.options, function(o) { - if ('label' in o && 'value' in o) { - let push_var = { - 'label': o.label, - 'value': o.value, - }; - push_var['label'] = o.label; - push_var['value'] = o.value; - - if('preview_src' in o) { - push_var['preview_src'] = o.preview_src; - } - opts.push(push_var); - if (o.value == p.value) - has_value = true; - } else { - opts.push({ - 'label': o, - 'value': o, - }); - if (o == p.value) - has_value = true; - } - }); - if (p.select2 && p.select2.tags == true && p.value && has_value == false) { - opts.push({ - 'label': p.value, - 'value': p.value, - }); - } - p.options = opts; - return 'select2'; - case 'select2': - var select_opts = []; - _.each(p.options, function(o) { - if ('label' in o && 'value' in o) { - let push_var = { - 'label': o.label, - 'value': o.value, - }; - push_var['label'] = o.label; - push_var['value'] = o.value; - - if('preview_src' in o) { - push_var['preview_src'] = o.preview_src; - } - select_opts.push(push_var); - } else { - select_opts.push({ - 'label': o, - 'value': o, - }); - } - }); - - p.options = select_opts; - return 'select2'; - - case 'multiline': - return 'textarea'; - case 'switch': - return 'switch'; - case 'keyboardshortcut': - return 'keyboardShortcut'; - case 'radioModern': - return 'radioModern'; - case 'selectFile': - return 'binary-paths-grid'; - case 'threshold': - p.warning_label = gettext('Warning'); - p.alert_label = gettext('Alert'); - p.unit = gettext('(in minutes)'); - return 'threshold'; - default: - if (console && console.warn) { - // Warning for developer only. - console.warn( - 'Hmm.. We don\'t know how to render this type - \'\'' + p.type + '\' of control.' - ); - } - return 'input'; - } - }, - /* - * function: treeEventHandler - * - * It is basically a callback, which listens to aci-tree events, - * and act accordingly. - * - * + Selection of the node will existance of the preferences for - * the selected tree-node, if not pass on to select the first - * category under a module, else pass on to the render function. - * - * + When a new node is added in the tree, it will add the relavent - * preferences in the preferences model collection, which will be - * called during initialization itself. - * - * - */ - treeEventHandler = function(event, api, item, eventName) { - // Look for selected item (if none supplied)! - item = item || api.selected(); - - // Event tree item has itemData - var d = item ? api.itemData(item) : null; - - /* - * boolean (switch/checkbox), string, enum (combobox - enumvals), - * integer (min-max), font, color - */ - switch (eventName) { - case 'selected': - if (!d) - break; - - if (d.preferences) { - /* - * Clear the existing html in the preferences content - */ - $container.find('.preferences_content'); - - renderPreferencePanel(d.preferences); - - break; - } else { - selectFirstCategory(api, item); - } - break; - case 'added': - if (!d) - break; - - // We will add the preferences in to the preferences data - // collection. - if (d.preferences && _.isArray(d.preferences)) { - _.each(d.preferences, function(p) { - preferences.add({ - 'id': p.id, - 'value': p.value, - 'category_id': d.id, - 'mid': d.mid, - 'name': p.name, - 'dependents': p.dependents, - }); - /* - * We don't know until now, how to render the control for - * this preference. - */ - if (!p.control) { - p.control = getControlMappedForType(p); - } - if (p.help_str) { - p.helpMessage = p.help_str; - } - }); - } - d.sortable = false; - break; - case 'loaded': - // Let's select the first category from the prefrences. - // We need to wait for sometime before all item gets loaded - // properly. - setTimeout( - function() { - selectFirstCategory(api, null); - }, 300); - break; - } - return true; - }; - - // Dialog property - return { - main: function() { - - // Remove the existing content first. - dialogContentCleanup(); - - $container.append( - '
' - ).append( - '
' + - gettext('Category is not selected.') + - '
' - ); - - // Create the aci-tree for listing the modules and categories of - // it. - jTree = $container.find('.preferences_tree'); - jTree.on('acitree', treeEventHandler); - - jTree.aciTree({ - selectable: true, - expand: true, - fullRow: true, - ajax: { - url: url_for('preferences.index'), - }, - animateRoot: true, - unanimated: false, - show: {duration: 75}, - hide: {duration: 75}, - view: {duration: 75}, - }); - - if (jTree.aciTree('api')) modifyAnimation.modifyAcitreeAnimation(pgBrowser, jTree.aciTree('api')); - - this.show(); - }, - setup: function() { - return { - buttons: [{ - text: '', - key: 112, - className: 'btn btn-primary-icon pull-left fa fa-question pg-alertify-icon-button', - attrs: { - name: 'dialog_help', - type: 'button', - label: gettext('Preferences'), - 'aria-label': gettext('Help'), - url: url_for( - 'help.static', { - 'filename': 'preferences.html', - } - ), - }, - }, { - text: gettext('Cancel'), - key: 27, - className: 'btn btn-secondary fa fa-lg fa-times pg-alertify-button', - }, { - text: gettext('Save'), - key: 13, - className: 'btn btn-primary fa fa-lg fa-save pg-alertify-button', - }], - focus: { - element: 0, - }, - options: { - padding: !1, - overflow: !1, - title: gettext('Preferences'), - closableByDimmer: false, - modal: true, - pinnable: false, - }, - }; - }, - callback: function(e) { - if (e.button.element.name == 'dialog_help') { - e.cancel = true; - pgBrowser.showHelp(e.button.element.name, e.button.element.getAttribute('url'), - null, null); - return; - } - - if (e.button.text == gettext('Save')) { - let requires_refresh = false; - preferences.updateAll(); - - /* Find the modules changed */ - let modulesChanged = {}; - _.each(changed, (val, key)=> { - let pref = pgBrowser.get_preference_for_id(Number(key)); - - if(pref['name'] == 'dynamic_tabs') { - showQueryTool._set_dynamic_tab(pgBrowser, !pref['value']); - } - - if(!modulesChanged[pref.module]) { - modulesChanged[pref.module] = true; - } - - if(pref.name == 'theme') { - requires_refresh = true; - } - - if(pref.name == 'hide_shared_server') { - Notify.confirm( - gettext('Browser tree refresh required'), - gettext('A browser tree refresh is required. Do you wish to refresh the tree?'), - function() { - pgAdmin.Browser.tree.destroy({ - success: function() { - pgAdmin.Browser.initializeBrowserTree(pgAdmin.Browser); - return true; - }, - }); - }, - function() { - preferences.reset(); - changed = {}; - return true; - }, - gettext('Refresh'), - gettext('Later') - ); - } - }); - - if(requires_refresh) { - Notify.confirm( - gettext('Refresh required'), - gettext('A page refresh is required to apply the theme. Do you wish to refresh the page now?'), - function() { - /* If user clicks Yes */ - location.reload(); - return true; - }, - function() {/* If user clicks No */ return true;}, - gettext('Refresh'), - gettext('Later') - ); - } - // Refresh preferences cache - pgBrowser.cache_preferences(modulesChanged); - } - }, - build: function() { - this.elements.content.appendChild($container.get(0)); - Alertify.pgDialogBuild.apply(this); - }, - hooks: { - onshow: function() {/* This is intentional (SonarQube) */}, - }, - }; - }); - - }, - show: function() { - Alertify.preferencesDlg(true).resizeTo(pgAdmin.Browser.stdW.calc(pgAdmin.Browser.stdW.lg),pgAdmin.Browser.stdH.calc(pgAdmin.Browser.stdH.lg)); - }, - }; - - return pgAdmin.Preferences; -}); +export default class Preferences { + static instance; + + static getInstance(...args) { + if (!Preferences.instance) { + Preferences.instance = new Preferences(...args); + } + return Preferences.instance; + } + + constructor(pgAdmin, pgBrowser) { + this.pgAdmin = pgAdmin; + this.pgBrowser = pgBrowser; + } + + init() { + if (this.initialized) + return; + this.initialized = true; + // Add Preferences in to file menu + var menus = [{ + name: 'mnu_preferences', + module: this, + applies: ['file'], + callback: 'show', + enable: true, + priority: 3, + label: gettext('Preferences'), + icon: 'fa fa-cog', + }]; + + this.pgBrowser.add_menus(menus); + } + + // This is a callback function to show preferences. + show() { + // Render Preferences component + Notify.showModal(gettext('Preferences'), (closeModal, isFullScreen)=> {return ;}, {isFullScreen: false, showFullScreen: true, maxWidth: 'md', isFullWidth: true}); + } +} diff --git a/web/pgadmin/preferences/tests/__init__.py b/web/pgadmin/preferences/tests/__init__.py new file mode 100644 index 00000000..6e04daf6 --- /dev/null +++ b/web/pgadmin/preferences/tests/__init__.py @@ -0,0 +1,8 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2022, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## diff --git a/web/pgadmin/preferences/tests/preferences_test_data.json b/web/pgadmin/preferences/tests/preferences_test_data.json new file mode 100644 index 00000000..a4327dd4 --- /dev/null +++ b/web/pgadmin/preferences/tests/preferences_test_data.json @@ -0,0 +1,27 @@ +{ + "get_preferences": [ + { + "name": "Get the all Preferences", + "url": "/preferences/", + "is_positive_test": true, + "mocking_required": false, + "mock_data": { + }, + "expected_data": { + "status_code": 200 + } + } + ], + "update_preferences": [ + { + "name": "Update the Preferences", + "url": "/preferences/", + "is_positive_test": true, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200 + } + } + ] +} diff --git a/web/pgadmin/preferences/tests/test_preferences_get.py b/web/pgadmin/preferences/tests/test_preferences_get.py new file mode 100644 index 00000000..b7863888 --- /dev/null +++ b/web/pgadmin/preferences/tests/test_preferences_get.py @@ -0,0 +1,39 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2022, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import os +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from regression.test_setup import config_data +import json +import config + +test_user_details = None +if config.SERVER_MODE: + test_user_details = config_data['pgAdmin4_test_non_admin_credentials'] + +CURRENT_PATH = os.path.dirname(os.path.realpath(__file__)) +with open(CURRENT_PATH + "/preferences_test_data.json") as data_file: + test_cases = json.load(data_file) + + +class GetPreferencesTest(BaseTestGenerator): + """ + This class will fetch all Preferences + """ + + scenarios = utils.generate_scenarios('get_preferences', test_cases) + + def runTest(self): + self.get_preferences() + + def get_preferences(self): + response = self.tester.get(self.url, + content_type='html/json') + self.assertTrue(response.status_code, 200) diff --git a/web/pgadmin/preferences/tests/test_preferences_update.py b/web/pgadmin/preferences/tests/test_preferences_update.py new file mode 100644 index 00000000..6b9eccc0 --- /dev/null +++ b/web/pgadmin/preferences/tests/test_preferences_update.py @@ -0,0 +1,60 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2022, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +import os +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from regression.test_setup import config_data +from regression import parent_node_dict +import json +import config + +test_user_details = None +if config.SERVER_MODE: + test_user_details = config_data['pgAdmin4_test_non_admin_credentials'] + +CURRENT_PATH = os.path.dirname(os.path.realpath(__file__)) +with open(CURRENT_PATH + "/preferences_test_data.json") as data_file: + test_cases = json.load(data_file) + + +class GetPreferencesTest(BaseTestGenerator): + """ + This class will fetch all Preferences + """ + + scenarios = utils.generate_scenarios('update_preferences', test_cases) + + def setUp(self): + response = self.tester.get(self.url, + content_type='html/json') + self.assertTrue(response.status_code, 200) + parent_node_dict['preferences'] = response.data + + def runTest(self): + self.update_preferences() + + def update_preferences(self): + if 'preferences' in parent_node_dict: + data = \ + json.loads(parent_node_dict['preferences'])[0]['children'][0][ + 'preferences'][0] + updated_data = [{ + 'id': data['id'], + 'category_id': data['cid'], + 'mid': data['mid'], + 'name': data['name'], + 'value': not data['value'] + }] + response = self.tester.put(self.url, + data=json.dumps(updated_data), + content_type='html/json') + self.assertTrue(response.status_code, 200) + else: + self.fail('Preferences not found') diff --git a/web/pgadmin/static/js/SchemaView/MappedControl.jsx b/web/pgadmin/static/js/SchemaView/MappedControl.jsx index a361b828..8018799a 100644 --- a/web/pgadmin/static/js/SchemaView/MappedControl.jsx +++ b/web/pgadmin/static/js/SchemaView/MappedControl.jsx @@ -9,18 +9,19 @@ import React, { useCallback } from 'react'; import _ from 'lodash'; - -import { FormInputText, FormInputSelect, FormInputSwitch, FormInputCheckbox, FormInputColor, - FormInputFileSelect, FormInputToggle, InputSwitch, FormInputSQL, FormNote, FormInputDateTimePicker, PlainString, InputSQL, - InputSelect, InputText, InputCheckbox, InputDateTimePicker } from '../components/FormComponents'; +import { + FormInputText, FormInputSelect, FormInputSwitch, FormInputCheckbox, FormInputColor, + FormInputFileSelect, FormInputToggle, InputSwitch, FormInputSQL, InputSQL, FormNote, FormInputDateTimePicker, PlainString, + InputSelect, InputText, InputCheckbox, InputDateTimePicker, InputFileSelect, FormInputKeyboardShortcut, FormInputQueryThreshold +} from '../components/FormComponents'; import Privilege from '../components/Privilege'; import { evalFunc } from 'sources/utils'; import PropTypes from 'prop-types'; import CustomPropTypes from '../custom_prop_types'; -import { SelectRefresh} from '../components/SelectRefresh'; +import { SelectRefresh } from '../components/SelectRefresh'; /* Control mapping for form view */ -function MappedFormControlBase({type, value, id, onChange, className, visible, inputRef, noLabel, ...props}) { +function MappedFormControlBase({ type, value, id, onChange, className, visible, inputRef, noLabel, ...props }) { const name = id; const onTextChange = useCallback((e) => { let val = e; @@ -34,36 +35,36 @@ function MappedFormControlBase({type, value, id, onChange, className, visible, i onChange && onChange(changedValue); }, []); - if(!visible) { + if (!visible) { return <>; } /* The mapping uses Form* components as it comes with labels */ switch (type) { case 'int': - return ; + return ; case 'numeric': - return ; + return ; case 'tel': - return ; + return ; case 'text': - return ; + return ; case 'multiline': return ; + inputRef={inputRef} controlProps={{ multiline: true }} {...props} />; case 'password': - return ; + return ; case 'select': return ; case 'select-refresh': return ; case 'switch': return onTextChange(e.target.checked, e.target.name)} className={className} + onChange={(e) => onTextChange(e.target.checked, e.target.name)} className={className} {...props} />; case 'checkbox': return onTextChange(e.target.checked, e.target.name)} className={className} + onChange={(e) => onTextChange(e.target.checked, e.target.name)} className={className} {...props} />; case 'toggle': return ; case 'note': - return ; + return ; case 'datetimepicker': return ; + case 'keyboardShortcut': + return ; + case 'threshold': + return ; default: return ; } @@ -100,11 +105,11 @@ MappedFormControlBase.propTypes = { }; /* Control mapping for grid cell view */ -function MappedCellControlBase({cell, value, id, optionsLoaded, onCellChange, visible, reRenderRow,...props}) { +function MappedCellControlBase({ cell, value, id, optionsLoaded, onCellChange, visible, reRenderRow, ...props }) { const name = id; const onTextChange = useCallback((e) => { let val = e; - if(e && e.target) { + if (e && e.target) { val = e.target.value; } @@ -118,13 +123,13 @@ function MappedCellControlBase({cell, value, id, optionsLoaded, onCellChange, vi /* Some grid cells are based on options selected in other cells. * lets trigger a re-render for the row if optionsLoaded */ - const optionsLoadedRerender = useCallback((res)=>{ + const optionsLoadedRerender = useCallback((res) => { /* optionsLoaded is called when select options are fetched */ optionsLoaded && optionsLoaded(res); reRenderRow && reRenderRow(); }, []); - if(!visible) { + if (!visible) { return <>; } @@ -142,7 +147,7 @@ function MappedCellControlBase({cell, value, id, optionsLoaded, onCellChange, vi return ; case 'switch': return onTextChange(e.target.checked, e.target.name)} {...props} />; + onChange={(e)=>onTextChange(e.target.checked, e.target.name)} disabled={props.disabled} {...props} />; case 'checkbox': return onTextChange(e.target.checked, e.target.name)} {...props} />; @@ -152,6 +157,10 @@ function MappedCellControlBase({cell, value, id, optionsLoaded, onCellChange, vi return ; case 'sql': return ; + case 'file': + return ; + case 'keyCode': + return ; default: return ; } @@ -167,14 +176,16 @@ MappedCellControlBase.propTypes = { reRenderRow: PropTypes.func, optionsLoaded: PropTypes.func, onCellChange: PropTypes.func, - visible: PropTypes.bool + visible: PropTypes.bool, + disabled: PropTypes.bool, + inputRef: CustomPropTypes.ref, }; const ALLOWED_PROPS_FIELD_COMMON = [ 'mode', 'value', 'readonly', 'disabled', 'hasError', 'id', 'label', 'options', 'optionsLoaded', 'controlProps', 'schema', 'inputRef', 'visible', 'autoFocus', 'helpMessage', 'className', 'optionsReloadBasis', - 'orientation' + 'orientation', 'isvalidate', 'fields' ]; const ALLOWED_PROPS_FIELD_FORM = [ @@ -182,14 +193,14 @@ const ALLOWED_PROPS_FIELD_FORM = [ ]; const ALLOWED_PROPS_FIELD_CELL = [ - 'cell', 'onCellChange', 'row', 'reRenderRow', + 'cell', 'onCellChange', 'row', 'reRenderRow', 'validate', 'disabled', 'readonly' ]; -export const MappedFormControl = (props)=>{ - let newProps = {...props}; +export const MappedFormControl = (props) => { + let newProps = { ...props }; let typeProps = evalFunc(null, newProps.type, newProps.state); - if(typeof(typeProps) === 'object') { + if (typeof (typeProps) === 'object') { newProps = { ...newProps, ...typeProps, @@ -199,13 +210,13 @@ export const MappedFormControl = (props)=>{ } /* Filter out garbage props if any using ALLOWED_PROPS_FIELD */ - return ; + return ; }; -export const MappedCellControl = (props)=>{ - let newProps = {...props}; +export const MappedCellControl = (props) => { + let newProps = { ...props }; let cellProps = evalFunc(null, newProps.cell, newProps.row); - if(typeof(cellProps) === 'object') { + if (typeof (cellProps) === 'object') { newProps = { ...newProps, ...cellProps, @@ -215,5 +226,5 @@ export const MappedCellControl = (props)=>{ } /* Filter out garbage props if any using ALLOWED_PROPS_FIELD */ - return ; + return ; }; diff --git a/web/pgadmin/static/js/SchemaView/index.jsx b/web/pgadmin/static/js/SchemaView/index.jsx index 8b20d5f5..3febb103 100644 --- a/web/pgadmin/static/js/SchemaView/index.jsx +++ b/web/pgadmin/static/js/SchemaView/index.jsx @@ -108,7 +108,7 @@ function getChangedData(topSchema, viewHelperProps, sessData, stringify=false, i /* The comparator and setter */ const attrChanged = (id, change, force=false)=>{ - if(isValueEqual(_.get(origVal, id), _.get(sessVal, id)) && !force) { + if(isValueEqual(_.get(origVal, id), _.get(sessVal, id)) && !force && (_.isObject(_.get(origVal, id)) && _.isEqual(_.get(origVal, id), _.get(sessData, id)))) { return; } else { change = change || _.get(sessVal, id); diff --git a/web/pgadmin/static/js/components/FormComponents.jsx b/web/pgadmin/static/js/components/FormComponents.jsx index cdcfab64..7f69bcf4 100644 --- a/web/pgadmin/static/js/components/FormComponents.jsx +++ b/web/pgadmin/static/js/components/FormComponents.jsx @@ -10,8 +10,10 @@ import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; import { makeStyles } from '@material-ui/core/styles'; -import { Box, FormControl, OutlinedInput, FormHelperText, - Grid, IconButton, FormControlLabel, Switch, Checkbox, useTheme, InputLabel, Paper, Select as MuiSelect } from '@material-ui/core'; +import { + Box, FormControl, OutlinedInput, FormHelperText, + Grid, IconButton, FormControlLabel, Switch, Checkbox, useTheme, InputLabel, Paper, Select as MuiSelect +} from '@material-ui/core'; import { ToggleButton, ToggleButtonGroup } from '@material-ui/lab'; import ErrorRoundedIcon from '@material-ui/icons/ErrorOutlineRounded'; import InfoRoundedIcon from '@material-ui/icons/InfoRounded'; @@ -20,13 +22,14 @@ import CheckRoundedIcon from '@material-ui/icons/CheckRounded'; import WarningRoundedIcon from '@material-ui/icons/WarningRounded'; import FolderOpenRoundedIcon from '@material-ui/icons/FolderOpenRounded'; import DescriptionIcon from '@material-ui/icons/Description'; -import Select, {components as RSComponents} from 'react-select'; +import AssignmentTurnedIn from '@material-ui/icons/AssignmentTurnedIn'; +import Select, { components as RSComponents } from 'react-select'; import CreatableSelect from 'react-select/creatable'; import Pickr from '@simonwep/pickr'; import clsx from 'clsx'; import PropTypes from 'prop-types'; import HTMLReactParse from 'html-react-parser'; -import { KeyboardDateTimePicker, KeyboardDatePicker, KeyboardTimePicker, MuiPickersUtilsProvider} from '@material-ui/pickers'; +import { KeyboardDateTimePicker, KeyboardDatePicker, KeyboardTimePicker, MuiPickersUtilsProvider } from '@material-ui/pickers'; import DateFnsUtils from '@date-io/date-fns'; import * as DateFns from 'date-fns'; @@ -36,6 +39,8 @@ import { showFileDialog } from '../helpers/legacyConnector'; import _ from 'lodash'; import { DefaultButton, PrimaryButton, PgIconButton } from './Buttons'; import CustomPropTypes from '../custom_prop_types'; +import KeyboardShortcuts from './KeyboardShortcuts'; +import QueryThresholds from './QueryThresholds'; const useStyles = makeStyles((theme) => ({ @@ -55,7 +60,7 @@ const useStyles = makeStyles((theme) => ({ margin: theme.spacing(0.75, 0.75, 0.75, 0.75), display: 'flex', }, - formLabelError: { + formLabelError: { color: theme.palette.error.main, }, sql: { @@ -95,17 +100,17 @@ export const MESSAGE_TYPE = { }; /* Icon based on MESSAGE_TYPE */ -function FormIcon({type, close=false, ...props}) { +function FormIcon({ type, close = false, ...props }) { let TheIcon = null; - if(close) { + if (close) { TheIcon = CloseIcon; - } else if(type === MESSAGE_TYPE.SUCCESS) { + } else if (type === MESSAGE_TYPE.SUCCESS) { TheIcon = CheckRoundedIcon; - } else if(type === MESSAGE_TYPE.ERROR) { + } else if (type === MESSAGE_TYPE.ERROR) { TheIcon = ErrorRoundedIcon; - } else if(type === MESSAGE_TYPE.INFO) { + } else if (type === MESSAGE_TYPE.INFO) { TheIcon = InfoRoundedIcon; - } else if(type === MESSAGE_TYPE.WARNING) { + } else if (type === MESSAGE_TYPE.WARNING) { TheIcon = WarningRoundedIcon; } @@ -117,21 +122,21 @@ FormIcon.propTypes = { }; /* Wrapper on any form component to add label, error indicator and help message */ -export function FormInput({children, error, className, label, helpMessage, required, testcid}) { +export function FormInput({ children, error, className, label, helpMessage, required, testcid }) { const classes = useStyles(); const cid = testcid || _.uniqueId('c'); const helpid = `h${cid}`; return ( - + {label} - + - {React.cloneElement(children, {cid, helpid})} + {React.cloneElement(children, { cid, helpid })} {HTMLReactParse(helpMessage || '')} @@ -148,17 +153,22 @@ FormInput.propTypes = { testcid: PropTypes.any, }; -export function InputSQL({value, onChange, className, controlProps, ...props}) { +export function InputSQL({ value, options, onChange, className, controlProps, ...props }) { const classes = useStyles(); const editor = useRef(); return ( editor.current=obj} - value={value||''} + currEditor={(obj) => editor.current = obj} + value={value || ''} + options={{ + lineNumbers: true, + mode: 'text/x-pgsql', + ...options, + }} className={clsx(classes.sql, className)} events={{ - change: (cm)=>{ + change: (cm) => { onChange && onChange(cm.getValue()); }, }} @@ -176,13 +186,13 @@ InputSQL.propTypes = { controlProps: PropTypes.object, }; -export function FormInputSQL({hasError, required, label, className, helpMessage, testcid, value, noLabel, ...props}) { - if(noLabel) { - return ; +export function FormInputSQL({ hasError, required, label, className, helpMessage, testcid, value, controlProps, noLabel, ...props }) { + if (noLabel) { + return ; } else { return ( - + ); } @@ -208,7 +218,7 @@ const DATE_TIME_FORMAT = { TIME_24: 'HH:mm:ss', }; -export function InputDateTimePicker({value, onChange, readonly, controlProps, ...props}) { +export function InputDateTimePicker({ value, onChange, readonly, controlProps, ...props }) { let format = ''; let placeholder = ''; if (controlProps?.pickerType === 'Date') { @@ -222,15 +232,15 @@ export function InputDateTimePicker({value, onChange, readonly, controlProps, .. placeholder = controlProps.placeholder || 'YYYY-MM-DD HH:mm:ss Z'; } - const handleChange = (dateVal, stringVal)=> { + const handleChange = (dateVal, stringVal) => { onChange(stringVal); }; /* Value should be a date object instead of string */ value = _.isUndefined(value) ? null : value; - if(!_.isNull(value)) { + if (!_.isNull(value)) { let parseValue = DateFns.parse(value, format, new Date()); - if(!DateFns.isValid(parseValue)) { + if (!DateFns.isValid(parseValue)) { parseValue = DateFns.parseISO(value); } value = !DateFns.isValid(parseValue) ? value : parseValue; @@ -238,7 +248,7 @@ export function InputDateTimePicker({value, onChange, readonly, controlProps, .. if (readonly) { return (); + readonly={readonly} controlProps={{ placeholder: controlProps.placeholder }} {...props} />); } let commonProps = { @@ -262,20 +272,20 @@ export function InputDateTimePicker({value, onChange, readonly, controlProps, .. if (controlProps?.pickerType === 'Date') { return ( - + ); } else if (controlProps?.pickerType === 'Time') { return ( - + ); } return ( - + ); } @@ -287,10 +297,10 @@ InputDateTimePicker.propTypes = { controlProps: PropTypes.object, }; -export function FormInputDateTimePicker({hasError, required, label, className, helpMessage, testcid, ...props}) { +export function FormInputDateTimePicker({ hasError, required, label, className, helpMessage, testcid, ...props }) { return ( - + ); } @@ -308,23 +318,23 @@ FormInputDateTimePicker.propTypes = { /* Use forwardRef to pass ref prop to OutlinedInput */ export const InputText = forwardRef(({ - cid, helpid, readonly, disabled, maxlength=255, value, onChange, controlProps, type, ...props}, ref)=>{ + cid, helpid, readonly, disabled, maxlength = 255, value, onChange, controlProps, type, ...props }, ref) => { const classes = useStyles(); const patterns = { 'numeric': '^-?[0-9]\\d*\\.?\\d*$', 'int': '^-?[0-9]\\d*$', }; - let onChangeFinal = (e)=>{ + let onChangeFinal = (e) => { let changeVal = e.target.value; /* For type number, we set type as tel with number regex to get validity.*/ - if(['numeric', 'int', 'tel'].indexOf(type) > -1) { - if(!e.target.validity.valid && changeVal !== '' && changeVal !== '-') { + if (['numeric', 'int', 'tel'].indexOf(type) > -1) { + if (!e.target.validity.valid && changeVal !== '' && changeVal !== '-') { return; } } - if(controlProps?.formatter) { + if (controlProps?.formatter) { changeVal = controlProps.formatter.toRaw(changeVal); } onChange && onChange(changeVal); @@ -332,11 +342,11 @@ export const InputText = forwardRef(({ let finalValue = (_.isNull(value) || _.isUndefined(value)) ? '' : value; - if(controlProps?.formatter) { + if (controlProps?.formatter) { finalValue = controlProps.formatter.fromRaw(finalValue); } - return( + return ( -1 ? {type: 'tel'} : {type: type})} + {...(['numeric', 'int'].indexOf(type) > -1 ? { type: 'tel' } : { type: type })} /> ); }); @@ -374,10 +387,10 @@ InputText.propTypes = { type: PropTypes.string, }; -export function FormInputText({hasError, required, label, className, helpMessage, testcid, ...props}) { +export function FormInputText({ hasError, required, label, className, helpMessage, testcid, ...props }) { return ( - + ); } @@ -391,16 +404,21 @@ FormInputText.propTypes = { }; /* Using the existing file dialog functions using showFileDialog */ -export function InputFileSelect({controlProps, onChange, disabled, readonly, ...props}) { +export function InputFileSelect({ controlProps, onChange, disabled, readonly, isvalidate = false, validate, ...props }) { const inpRef = useRef(); - const onFileSelect = (value)=>{ + const onFileSelect = (value) => { onChange && onChange(decodeURI(value)); inpRef.current.focus(); }; return ( showFileDialog(controlProps, onFileSelect)} - disabled={disabled||readonly} aria-label={gettext('Select a file')}> + <> + showFileDialog(controlProps, onFileSelect)} + disabled={disabled || readonly} aria-label={gettext('Select a file')}> + {isvalidate && + { validate(props.value); }} icon={}> + } + } /> ); } @@ -409,14 +427,17 @@ InputFileSelect.propTypes = { onChange: PropTypes.func, disabled: PropTypes.bool, readonly: PropTypes.bool, + isvalidate: PropTypes.bool, + validate: PropTypes.func, + value: PropTypes.string }; export function FormInputFileSelect({ - hasError, required, label, className, helpMessage, testcid, ...props}) { + hasError, required, label, className, helpMessage, testcid, ...props }) { return ( - + ); } @@ -429,13 +450,13 @@ FormInputFileSelect.propTypes = { testcid: PropTypes.string, }; -export function InputSwitch({cid, helpid, value, onChange, readonly, controlProps, ...props}) { +export function InputSwitch({ cid, helpid, value, onChange, readonly, controlProps, ...props }) { const classes = useStyles(); return ( {/*This is intentional (SonarQube)*/} : onChange + readonly ? () => {/*This is intentional (SonarQube)*/ } : onChange } id={cid} inputProps={{ @@ -457,11 +478,11 @@ InputSwitch.propTypes = { controlProps: PropTypes.object, }; -export function FormInputSwitch({hasError, required, label, className, helpMessage, testcid, ...props}) { +export function FormInputSwitch({ hasError, required, label, className, helpMessage, testcid, ...props }) { return ( - + ); } @@ -474,7 +495,7 @@ FormInputSwitch.propTypes = { testcid: PropTypes.string, }; -export function InputCheckbox({cid, helpid, value, onChange, controlProps, readonly, ...props}) { +export function InputCheckbox({ cid, helpid, value, onChange, controlProps, readonly, ...props }) { controlProps = controlProps || {}; return ( {/*This is intentional (SonarQube)*/} : onChange} + onChange={readonly ? () => {/*This is intentional (SonarQube)*/ } : onChange} color="primary" - inputProps={{'aria-describedby': helpid}} - {...props}/> + inputProps={{ 'aria-describedby': helpid }} + {...props} /> } label={controlProps.label} /> @@ -500,12 +521,12 @@ InputCheckbox.propTypes = { readonly: PropTypes.bool, }; -export function FormInputCheckbox({hasError, required, label, - className, helpMessage, testcid, ...props}) { +export function FormInputCheckbox({ hasError, required, label, + className, helpMessage, testcid, ...props }) { return ( - + ); } @@ -519,23 +540,23 @@ FormInputCheckbox.propTypes = { }; -export const InputToggle = forwardRef(({cid, value, onChange, options, disabled, readonly, ...props}, ref) => { +export const InputToggle = forwardRef(({ cid, value, onChange, options, disabled, readonly, ...props }, ref) => { return ( {val!==null && onChange(val);}} + onChange={(e, val) => { val !== null && onChange(val); }} {...props} > { - (options||[]).map((option, i)=>{ + (options || []).map((option, i) => { const isSelected = option.value === value; const isDisabled = disabled || option.disabled || (readonly && !isSelected); return ( - -  {option.label} +  {option.label} ); }) @@ -554,11 +575,11 @@ InputToggle.propTypes = { readonly: PropTypes.bool, }; -export function FormInputToggle({hasError, required, label, - className, helpMessage, testcid, inputRef, ...props}) { +export function FormInputToggle({ hasError, required, label, + className, helpMessage, testcid, inputRef, ...props }) { return ( - + ); } @@ -575,9 +596,9 @@ FormInputToggle.propTypes = { /* react-select package is used for select input * Customizing the select styles to fit existing theme */ -const customReactSelectStyles = (theme, readonly)=>({ +const customReactSelectStyles = (theme, readonly) => ({ input: (provided) => { - return {...provided, padding: 0, margin: 0, color: 'inherit'}; + return { ...provided, padding: 0, margin: 0, color: 'inherit' }; }, singleValue: (provided) => { return { @@ -593,35 +614,35 @@ const customReactSelectStyles = (theme, readonly)=>({ borderColor: theme.otherVars.inputBorderColor, ...(state.isFocused ? { borderColor: theme.palette.primary.main, - boxShadow: 'inset 0 0 0 1px '+theme.palette.primary.main, + boxShadow: 'inset 0 0 0 1px ' + theme.palette.primary.main, '&:hover': { borderColor: theme.palette.primary.main, } } : {}), }), - dropdownIndicator: (provided)=>({ + dropdownIndicator: (provided) => ({ ...provided, padding: '0rem 0.25rem', }), - indicatorsContainer: (provided)=>({ + indicatorsContainer: (provided) => ({ ...provided, - ...(readonly ? {display: 'none'} : {}) + ...(readonly ? { display: 'none' } : {}) }), - clearIndicator: (provided)=>({ + clearIndicator: (provided) => ({ ...provided, padding: '0rem 0.25rem', }), - valueContainer: (provided)=>({ + valueContainer: (provided) => ({ ...provided, padding: theme.otherVars.reactSelect.padding, }), - groupHeading: (provided)=>({ + groupHeading: (provided) => ({ ...provided, color: 'inherit', fontSize: '0.85em', textTransform: 'none', }), - menu: (provided)=>({ + menu: (provided) => ({ ...provided, backgroundColor: theme.palette.background.default, color: theme.palette.text.primary, @@ -629,12 +650,12 @@ const customReactSelectStyles = (theme, readonly)=>({ border: '1px solid ' + theme.otherVars.inputBorderColor, marginTop: '2px', }), - menuPortal: (provided)=>({ + menuPortal: (provided) => ({ ...provided, zIndex: 9999, backgroundColor: 'inherit', color: 'inherit', }), - option: (provided, state)=>{ + option: (provided, state) => { let bgColor = 'inherit'; if (state.isFocused) { bgColor = theme.palette.grey[400]; @@ -648,27 +669,27 @@ const customReactSelectStyles = (theme, readonly)=>({ backgroundColor: bgColor, }; }, - multiValue: (provided)=>({ + multiValue: (provided) => ({ ...provided, backgroundColor: theme.palette.grey[400], }), - multiValueLabel: (provided)=>({ + multiValueLabel: (provided) => ({ ...provided, fontSize: '1em', zIndex: 99, color: theme.palette.text.primary }), - multiValueRemove: (provided)=>({ + multiValueRemove: (provided) => ({ ...provided, '&:hover': { backgroundColor: 'unset', color: theme.palette.error.main, }, - ...(readonly ? {display: 'none'} : {}) + ...(readonly ? { display: 'none' } : {}) }), }); -function OptionView({image, label}) { +function OptionView({ image, label }) { const classes = useStyles(); return ( <> @@ -705,8 +726,8 @@ CustomSelectSingleValue.propTypes = { }; export function flattenSelectOptions(options) { - return _.flatMap(options, (option)=>{ - if(option.options) { + return _.flatMap(options, (option) => { + if (option.options) { return option.options; } else { return option; @@ -716,28 +737,28 @@ export function flattenSelectOptions(options) { function getRealValue(options, value, creatable, formatter) { let realValue = null; - if(_.isArray(value)) { + if (_.isArray(value)) { realValue = [...value]; /* If multi select options need to be in some format by UI, use formatter */ - if(formatter) { + if (formatter) { realValue = formatter.fromRaw(realValue, options); } else { - if(creatable) { - realValue = realValue.map((val)=>({label:val, value: val})); + if (creatable) { + realValue = realValue.map((val) => ({ label: val, value: val })); } else { - realValue = realValue.map((val)=>(_.find(options, (option)=>_.isEqual(option.value, val)))); + realValue = realValue.map((val) => (_.find(options, (option) => _.isEqual(option.value, val)))); } } } else { let flatOptions = flattenSelectOptions(options); - realValue = _.find(flatOptions, (option)=>option.value==value) || - (creatable && !_.isUndefined(value) && !_.isNull(value) ? {label:value, value: value} : null); + realValue = _.find(flatOptions, (option) => option.value == value) || + (creatable && !_.isUndefined(value) && !_.isNull(value) ? { label: value, value: value } : null); } return realValue; } -export function InputSelectNonSearch({options, ...props}) { +export function InputSelectNonSearch({ options, ...props }) { return - {(options||[]).map((o)=>)} + {(options || []).map((o) => )} ; } InputSelectNonSearch.propTypes = { @@ -748,7 +769,7 @@ InputSelectNonSearch.propTypes = { }; export const InputSelect = forwardRef(({ - cid, onChange, options, readonly=false, value, controlProps={}, optionsLoaded, optionsReloadBasis, disabled, ...props}, ref) => { + cid, onChange, options, readonly = false, value, controlProps = {}, optionsLoaded, optionsReloadBasis, disabled, ...props }, ref) => { const [[finalOptions, isLoading], setFinalOptions] = useState([[], true]); const theme = useTheme(); @@ -757,33 +778,33 @@ export const InputSelect = forwardRef(({ loading the options. optionsReloadBasis is helpful to avoid repeated options load. If optionsReloadBasis value changes, then options will be loaded again. */ - useEffect(()=>{ - let optPromise = options, umounted=false; - if(typeof options === 'function') { + useEffect(() => { + let optPromise = options, umounted = false; + if (typeof options === 'function') { optPromise = options(); } setFinalOptions([[], true]); Promise.resolve(optPromise) - .then((res)=>{ + .then((res) => { /* If component unmounted, dont update state */ - if(!umounted) { + if (!umounted) { optionsLoaded && optionsLoaded(res, value); /* Auto select if any option has key as selected */ const flatRes = flattenSelectOptions(res || []); let selectedVal; - if(controlProps.multiple) { - selectedVal = _.filter(flatRes, (o)=>o.selected)?.map((o)=>o.value); + if (controlProps.multiple) { + selectedVal = _.filter(flatRes, (o) => o.selected)?.map((o) => o.value); } else { - selectedVal = _.find(flatRes, (o)=>o.selected)?.value; + selectedVal = _.find(flatRes, (o) => o.selected)?.value; } - if((!_.isUndefined(selectedVal) && !_.isArray(selectedVal)) || (_.isArray(selectedVal) && selectedVal.length != 0)) { + if ((!_.isUndefined(selectedVal) && !_.isArray(selectedVal)) || (_.isArray(selectedVal) && selectedVal.length != 0)) { onChange && onChange(selectedVal); } setFinalOptions([res || [], false]); } }); - return ()=>umounted=true; + return () => umounted = true; }, [optionsReloadBasis]); @@ -791,7 +812,7 @@ export const InputSelect = forwardRef(({ const filteredOptions = (controlProps.filter && controlProps.filter(finalOptions)) || finalOptions; const flatFiltered = flattenSelectOptions(filteredOptions); let realValue = getRealValue(flatFiltered, value, controlProps.creatable, controlProps.formatter); - if(realValue && _.isPlainObject(realValue) && _.isUndefined(realValue.value)) { + if (realValue && _.isPlainObject(realValue) && _.isUndefined(realValue.value)) { console.error('Undefined option value not allowed', realValue, filteredOptions); } const otherProps = { @@ -802,17 +823,17 @@ export const InputSelect = forwardRef(({ const styles = customReactSelectStyles(theme, readonly || disabled); - const onChangeOption = useCallback((selectVal)=>{ - if(_.isArray(selectVal)) { + const onChangeOption = useCallback((selectVal) => { + if (_.isArray(selectVal)) { // Check if select all option is selected if (!_.isUndefined(selectVal.find(x => x.label === 'Select All'))) { selectVal = filteredOptions; } /* If multi select options need to be in some format by UI, use formatter */ - if(controlProps.formatter) { + if (controlProps.formatter) { selectVal = controlProps.formatter.toRaw(selectVal, filteredOptions); } else { - selectVal = selectVal.map((option)=>option.value); + selectVal = selectVal.map((option) => option.value); } onChange && onChange(selectVal); } else { @@ -838,13 +859,13 @@ export const InputSelect = forwardRef(({ ...otherProps, ...props, }; - if(!controlProps.creatable) { + if (!controlProps.creatable) { return ( - ); } else { return ( - + ); } }); @@ -863,10 +884,10 @@ InputSelect.propTypes = { export function FormInputSelect({ - hasError, required, className, label, helpMessage, testcid, ...props}) { + hasError, required, className, label, helpMessage, testcid, ...props }) { return ( - + ); } @@ -881,7 +902,7 @@ FormInputSelect.propTypes = { }; /* React wrapper on color pickr */ -export function InputColor({value, controlProps, disabled, onChange, currObj}) { +export function InputColor({ value, controlProps, disabled, onChange, currObj }) { const pickrOptions = { showPalette: true, allowEmpty: true, @@ -896,19 +917,19 @@ export function InputColor({value, controlProps, disabled, onChange, currObj}) { const pickrObj = useRef(); const classes = useStyles(); - const setColor = (newVal)=>{ + const setColor = (newVal) => { pickrObj.current && pickrObj.current.setColor((_.isUndefined(newVal) || newVal == '') ? pickrOptions.defaultColor : newVal); }; - const destroyPickr = ()=>{ - if(pickrObj.current) { + const destroyPickr = () => { + if (pickrObj.current) { pickrObj.current.destroy(); pickrObj.current = null; } }; - const initPickr = ()=>{ + const initPickr = () => { /* pickr does not have way to update options, need to destroy and recreate pickr to reflect options */ destroyPickr(); @@ -920,7 +941,7 @@ export function InputColor({value, controlProps, disabled, onChange, currObj}) { swatches: [ '#000', '#666', '#ccc', '#fff', '#f90', '#ff0', '#0f0', '#f0f', '#f4cccc', '#fce5cd', '#d0e0e3', '#cfe2f3', '#ead1dc', '#ea9999', - '#b6d7a8', '#a2c4c9', '#d5a6bd', '#e06666','#93c47d', '#76a5af', '#c27ba0', + '#b6d7a8', '#a2c4c9', '#d5a6bd', '#e06666', '#93c47d', '#76a5af', '#c27ba0', '#f1c232', '#6aa84f', '#45818e', '#a64d79', '#bf9000', '#0c343d', '#4c1130', ], position: pickrOptions.position, @@ -941,20 +962,20 @@ export function InputColor({value, controlProps, disabled, onChange, currObj}) { setColor(value); disabled && instance.disable(); - const {lastColor} = instance.getRoot().preview; - const {clear} = instance.getRoot().interaction; + const { lastColor } = instance.getRoot().preview; + const { clear } = instance.getRoot().interaction; /* Cycle the keyboard navigation within the color picker */ - clear.addEventListener('keydown', (e)=>{ - if(e.keyCode === 9) { + clear.addEventListener('keydown', (e) => { + if (e.keyCode === 9) { e.preventDefault(); e.stopPropagation(); lastColor.focus(); } }); - lastColor.addEventListener('keydown', (e)=>{ - if(e.keyCode === 9 && e.shiftKey) { + lastColor.addEventListener('keydown', (e) => { + if (e.keyCode === 9 && e.shiftKey) { e.preventDefault(); e.stopPropagation(); clear.focus(); @@ -965,32 +986,32 @@ export function InputColor({value, controlProps, disabled, onChange, currObj}) { }).on('change', (color) => { onChange && onChange(color.toHEXA().toString()); }).on('show', (color, instance) => { - const {palette} = instance.getRoot().palette; + const { palette } = instance.getRoot().palette; palette.focus(); }).on('hide', (instance) => { const button = instance.getRoot().button; button.focus(); }); - if(currObj) { + if (currObj) { currObj(pickrObj.current); } }; - useEffect(()=>{ + useEffect(() => { initPickr(); - return ()=>{ + return () => { destroyPickr(); }; }, [...Object.values(pickrOptions)]); - useEffect(()=>{ - if(pickrObj.current) { + useEffect(() => { + if (pickrObj.current) { setColor(value); } }, [value]); - let btnStyles = {backgroundColor: value}; + let btnStyles = { backgroundColor: value }; return ( } @@ -1006,11 +1027,11 @@ InputColor.propTypes = { }; export function FormInputColor({ - hasError, required, className, label, helpMessage, testcid, ...props}) { + hasError, required, className, label, helpMessage, testcid, ...props }) { return ( - + ); } @@ -1023,9 +1044,9 @@ FormInputColor.propTypes = { testcid: PropTypes.string, }; -export function PlainString({controlProps, value}) { +export function PlainString({ controlProps, value }) { let finalValue = value; - if(controlProps?.formatter) { + if (controlProps?.formatter) { finalValue = controlProps.formatter.fromRaw(finalValue); } return {finalValue}; @@ -1035,7 +1056,7 @@ PlainString.propTypes = { value: PropTypes.any, }; -export function FormNote({text, className}) { +export function FormNote({ text, className }) { const classes = useStyles(); return ( @@ -1051,7 +1072,7 @@ FormNote.propTypes = { className: CustomPropTypes.className, }; -const useStylesFormFooter = makeStyles((theme)=>({ +const useStylesFormFooter = makeStyles((theme) => ({ root: { padding: theme.spacing(0.5), position: 'absolute', @@ -1108,7 +1129,7 @@ const useStylesFormFooter = makeStyles((theme)=>({ export function FormFooterMessage(props) { const classes = useStylesFormFooter(); - if(!props.message) { + if (!props.message) { return <>; } return ( @@ -1122,15 +1143,54 @@ FormFooterMessage.propTypes = { message: PropTypes.string, }; -export function NotifierMessage({type=MESSAGE_TYPE.SUCCESS, message, closable=true, onClose=()=>{/*This is intentional (SonarQube)*/}}) { +export function FormInputKeyboardShortcut({ hasError, label, className, helpMessage, testcid, onChange, ...props }) { + const cid = _.uniqueId('c'); + const helpid = `h${cid}`; + return ( + + + + + ); +} +FormInputKeyboardShortcut.propTypes = { + hasError: PropTypes.bool, + label: PropTypes.string, + className: CustomPropTypes.className, + helpMessage: PropTypes.string, + testcid: PropTypes.string, + onChange: PropTypes.func +}; + +export function FormInputQueryThreshold({ hasError, label, className, helpMessage, testcid, onChange, ...props }) { + const cid = _.uniqueId('c'); + const helpid = `h${cid}`; + return ( + + + + + ); +} +FormInputQueryThreshold.propTypes = { + hasError: PropTypes.bool, + label: PropTypes.string, + className: CustomPropTypes.className, + helpMessage: PropTypes.string, + testcid: PropTypes.string, + onChange: PropTypes.func +}; + + +export function NotifierMessage({ type = MESSAGE_TYPE.SUCCESS, message, closable = true, onClose = () => {/*This is intentional (SonarQube)*/ } }) { const classes = useStylesFormFooter(); return ( - + {message} {closable && - + } ); diff --git a/web/pgadmin/static/js/components/KeyboardShortcuts.jsx b/web/pgadmin/static/js/components/KeyboardShortcuts.jsx new file mode 100644 index 00000000..76aed7cb --- /dev/null +++ b/web/pgadmin/static/js/components/KeyboardShortcuts.jsx @@ -0,0 +1,103 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2022, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import _ from 'lodash'; +import { FormGroup, FormControlLabel, makeStyles } from '@material-ui/core'; +import React from 'react'; +import { InputCheckbox, InputText } from './FormComponents'; +import PropTypes from 'prop-types'; + +const useStyles = makeStyles(() => ({ + formControlLabel: { + padding: '3px', + }, + formInput: { + marginLeft: '5px' + }, + formCheckboxControl: { + padding: '3px', + border: '1px solid', + borderRadius: '0.25rem', + }, + formGroup: { + padding: '5px' + } +})); + +export default function KeyboardShortcuts({ value, onChange, fields }) { + const classes = useStyles(); + const keyCid = _.uniqueId('c'); + const keyhelpid = `h${keyCid}`; + const shiftCid = _.uniqueId('c'); + const shifthelpid = `h${shiftCid}`; + const ctrlCid = _.uniqueId('c'); + const ctrlhelpid = `h${ctrlCid}`; + const altCid = _.uniqueId('c'); + const althelpid = `h${altCid}`; + + const onKeyDown = (e) => { + let newVal = { ...value }; + let _val = e.key; + if (e.keyCode == 32) { + _val = 'Space'; + } + newVal.key = { + char: _val, + key_code: e.keyCode + }; + onChange(newVal); + }; + + const onShiftChange = (e) => { + let newVal = { ...value }; + newVal.shift = e.target.checked; + onChange(newVal); + }; + + const onCtrlChange = (e) => { + let newVal = { ...value }; + newVal.ctrl = e.target.checked; + onChange(newVal); + }; + + const onAltChange = (e) => { + let newVal = { ...value }; + newVal.alt = e.target.checked; + onChange(newVal); + }; + + return ( + + {fields.map(element => { + if (element.type == 'keyCode') { + return } label={element.label} />; + } else if (element.name == 'shift') { + return } label={element.label} />; + } else if (element.name == 'control') { + return } label={element.label} />; + } else if (element.name == 'alt') { + return } label={element.label} />; + } + }) + + } + + ); +} + +KeyboardShortcuts.propTypes = { + value: PropTypes.object, + onChange: PropTypes.func, + controlProps: PropTypes.object, + fields: PropTypes.array +}; diff --git a/web/pgadmin/static/js/components/QueryThresholds.jsx b/web/pgadmin/static/js/components/QueryThresholds.jsx new file mode 100644 index 00000000..16f5ca76 --- /dev/null +++ b/web/pgadmin/static/js/components/QueryThresholds.jsx @@ -0,0 +1,67 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2022, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import _ from 'lodash'; +import { FormGroup, FormControlLabel, makeStyles } from '@material-ui/core'; +import React from 'react'; +import { InputText } from './FormComponents'; +import PropTypes from 'prop-types'; + +const useStyles = makeStyles(() => ({ + formControlLabel: { + padding: '3px', + }, + formInput: { + marginLeft: '5px' + }, + formCheckboxControl: { + padding: '3px', + border: '1px solid', + borderRadius: '0.25rem', + }, + formGroup: { + padding: '5px' + } +})); + +export default function QueryThresholds({ value, onChange }) { + const classes = useStyles(); + const warningCid = _.uniqueId('c'); + const warninghelpid = `h${warningCid}`; + const alertCid = _.uniqueId('c'); + const alerthelpid = `h${alertCid}`; + + const onWarningChange = (val) => { + let new_val = {...value}; + new_val['warning'] = val; + onChange(new_val); + }; + + const onAlertChange = (val) => { + let new_val = {...value}; + new_val['alert'] = val; + onChange(new_val); + }; + + return ( + + } label={gettext('Warning')} />; + } label={gettext('Alert')} />; + + ); +} + +QueryThresholds.propTypes = { + value: PropTypes.object, + onChange: PropTypes.func, +}; diff --git a/web/pgadmin/static/js/helpers/ModalProvider.jsx b/web/pgadmin/static/js/helpers/ModalProvider.jsx index 7e4fe526..d7b0b261 100644 --- a/web/pgadmin/static/js/helpers/ModalProvider.jsx +++ b/web/pgadmin/static/js/helpers/ModalProvider.jsx @@ -8,11 +8,13 @@ ////////////////////////////////////////////////////////////// import { Box, Dialog, DialogContent, DialogTitle, makeStyles, Paper } from '@material-ui/core'; -import React from 'react'; -import {getEpoch} from 'sources/utils'; +import React, { useState } from 'react'; +import { getEpoch } from 'sources/utils'; import { DefaultButton, PgIconButton, PrimaryButton } from '../components/Buttons'; import Draggable from 'react-draggable'; import CloseIcon from '@material-ui/icons/CloseRounded'; +import FullscreenExitIcon from '@material-ui/icons/FullscreenExit'; +import FullscreenIcon from '@material-ui/icons/Fullscreen'; import CustomPropTypes from '../custom_prop_types'; import PropTypes from 'prop-types'; import gettext from 'sources/gettext'; @@ -25,7 +27,7 @@ const ModalContext = React.createContext({}); export function useModal() { return React.useContext(ModalContext); } -const useAlertStyles = makeStyles((theme)=>({ +const useAlertStyles = makeStyles((theme) => ({ footer: { display: 'flex', justifyContent: 'flex-end', @@ -37,11 +39,11 @@ const useAlertStyles = makeStyles((theme)=>({ } })); -function AlertContent({text, confirm, okLabel=gettext('OK'), cancelLabel=gettext('Cancel'), onOkClick, onCancelClick}) { +function AlertContent({ text, confirm, okLabel = gettext('OK'), cancelLabel = gettext('Cancel'), onOkClick, onCancelClick }) { const classes = useAlertStyles(); return ( - {typeof(text) == 'string' ? HTMLReactParser(text) : text} + {typeof (text) == 'string' ? HTMLReactParser(text) : text} {confirm && } onClick={onCancelClick} >{cancelLabel} @@ -60,10 +62,10 @@ AlertContent.propTypes = { cancelLabel: PropTypes.string, }; -function alert(title, text, onOkClick, okLabel=gettext('OK')){ +function alert(title, text, onOkClick, okLabel = gettext('OK')) { // bind the modal provider before calling - this.showModal(title, (closeModal)=>{ - const onOkClickClose = ()=>{ + this.showModal(title, (closeModal) => { + const onOkClickClose = () => { onOkClick && onOkClick(); closeModal(); }; @@ -73,45 +75,53 @@ function alert(title, text, onOkClick, okLabel=gettext('OK')){ }); } -function confirm(title, text, onOkClick, onCancelClick, okLabel=gettext('Yes'), cancelLabel=gettext('No')) { +function confirm(title, text, onOkClick, onCancelClick, okLabel = gettext('Yes'), cancelLabel = gettext('No')) { // bind the modal provider before calling - this.showModal(title, (closeModal)=>{ - const onCancelClickClose = ()=>{ + this.showModal(title, (closeModal) => { + const onCancelClickClose = () => { onCancelClick && onCancelClick(); closeModal(); }; - const onOkClickClose = ()=>{ + const onOkClickClose = () => { onOkClick && onOkClick(); closeModal(); }; return ( - + ); }); } -export default function ModalProvider({children}) { +export default function ModalProvider({ children }) { const [modals, setModals] = React.useState([]); - const showModal = (title, content, modalOptions)=>{ + const showModal = (title, content, modalOptions) => { let id = getEpoch().toString() + Math.random(); - setModals((prev)=>[...prev, { + setModals((prev) => [...prev, { id: id, title: title, content: content, ...modalOptions, }]); }; - const closeModal = (id)=>{ - setModals((prev)=>{ - return prev.filter((o)=>o.id!=id); + const closeModal = (id) => { + setModals((prev) => { + return prev.filter((o) => o.id != id); }); }; + + const fullScreenModal = (fullScreen) => { + setModals((prev) => [...prev, { + fullScreen: fullScreen, + }]); + }; + const modalContextBase = { showModal: showModal, closeModal: closeModal, + fullScreenModal: fullScreenModal }; - const modalContext = React.useMemo(()=>({ + const modalContext = React.useMemo(() => ({ ...modalContextBase, confirm: confirm.bind(modalContextBase), alert: alert.bind(modalContextBase) @@ -119,8 +129,8 @@ export default function ModalProvider({children}) { return ( {children} - {modals.map((modalOptions, i)=>( - + {modals.map((modalOptions, i) => ( + ))} ); @@ -133,28 +143,55 @@ ModalProvider.propTypes = { function PaperComponent(props) { return ( - + ); } -function ModalContainer({id, title, content}) { +const useModalStyles = makeStyles(() => ({ + titleBar: { + display: 'flex', + flexGrow: 1 + }, + title: { + flexGrow: 1 + }, +})); + +function ModalContainer({ id, title, content, fullScreen = false, maxWidth = 'md', isFullWidth = false, showFullScreen = false }) { let useModalRef = useModal(); - let closeModal = ()=>useModalRef.closeModal(id); + const classes = useModalStyles(); + let closeModal = () => useModalRef.closeModal(id); + const [isfullScreen, setIsFullScreen] = useState(fullScreen); + return ( - {title} - } size="xs" noBorder onClick={closeModal}/> + + {title} + { + showFullScreen && !isfullScreen && + } size="xs" noBorder onClick={() => { setIsFullScreen(!isfullScreen); }} /> + } + { + showFullScreen && isfullScreen && + } size="xs" noBorder onClick={() => { setIsFullScreen(!isfullScreen); }} /> + } + + } size="xs" noBorder onClick={closeModal} /> + - - {content(closeModal)} + + {content(closeModal, isfullScreen)} @@ -164,4 +201,8 @@ ModalContainer.propTypes = { id: PropTypes.string, title: CustomPropTypes.children, content: PropTypes.func, + fullScreen: PropTypes.bool, + maxWidth: PropTypes.string, + isFullWidth: PropTypes.bool, + showFullScreen: PropTypes.bool }; diff --git a/web/pgadmin/static/js/helpers/Notifier.jsx b/web/pgadmin/static/js/helpers/Notifier.jsx index d67faa38..af11762f 100644 --- a/web/pgadmin/static/js/helpers/Notifier.jsx +++ b/web/pgadmin/static/js/helpers/Notifier.jsx @@ -8,6 +8,13 @@ ////////////////////////////////////////////////////////////// import { useSnackbar, SnackbarProvider, SnackbarContent } from 'notistack'; +import { makeStyles } from '@material-ui/core/styles'; +import {Box} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/CloseRounded'; +import { DefaultButton, PrimaryButton } from '../components/Buttons'; +import HTMLReactParser from 'html-react-parser'; +import CheckRoundedIcon from '@material-ui/icons/CheckRounded'; +import PropTypes from 'prop-types'; import React from 'react'; import ReactDOM from 'react-dom'; import Theme from 'sources/Theme'; @@ -76,6 +83,41 @@ FinalNotifyContent.propTypes = { children: CustomPropTypes.children, }; +const useModalStyles = makeStyles((theme)=>({ + footer: { + display: 'flex', + justifyContent: 'flex-end', + padding: '0.5rem', + ...theme.mixins.panelBorder.top, + }, + margin: { + marginLeft: '0.25rem', + } +})); +function AlertContent({text, confirm, okLabel=gettext('OK'), cancelLabel=gettext('Cancel'), onOkClick, onCancelClick}) { + const classes = useModalStyles(); + return ( + + {HTMLReactParser(text)} + + {confirm && + } onClick={onCancelClick} >{cancelLabel} + } + } onClick={onOkClick} autoFocus={true} >{okLabel} + + + ); +} +AlertContent.propTypes = { + text: PropTypes.string, + confirm: PropTypes.bool, + onOkClick: PropTypes.func, + onCancelClick: PropTypes.func, + okLabel: PropTypes.string, + cancelLabel: PropTypes.string, +}; + + var Notifier = { success(msg, autoHideDuration = AUTO_HIDE_DURATION) { this._callNotify(msg, MESSAGE_TYPE.SUCCESS, autoHideDuration); @@ -195,11 +237,11 @@ var Notifier = { } modalRef.confirm(title, text, onOkClick, onCancelClick, okLabel, cancelLabel); }, - showModal(title, content) { + showModal: (title, content, modalOptions) => { if(!modalInitialized) { initializeModalProvider(); } - modalRef.showModal(title, content); + modalRef.showModal(title, content, modalOptions); } }; diff --git a/web/pgadmin/static/js/tree/preference_nodes.ts b/web/pgadmin/static/js/tree/preference_nodes.ts new file mode 100644 index 00000000..605f4a03 --- /dev/null +++ b/web/pgadmin/static/js/tree/preference_nodes.ts @@ -0,0 +1,253 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import * as BrowserFS from 'browserfs' +import pgAdmin from 'sources/pgadmin'; +import _ from 'underscore'; +import { FileType } from 'react-aspen' +import { findInTree } from './tree'; + +import { unix } from 'path-fx'; + +export class ManagePreferenceTreeNodes { + constructor(data) { + this.tree = {} + this.tempTree = new TreeNode(undefined, {}); + this.treeData = data; + } + + public init = (_root: string) => new Promise((res, rej) => { + let node = { parent: null, children: [], data: null }; + this.tree = {}; + this.tree[_root] = { name: 'root', type: FileType.Directory, metadata: node }; + res(); + }) + + public updateNode = (_path, _data) => new Promise((res, rej) => { + const item = this.findNode(_path); + if (item) { + item.name = _data.label; + item.metadata.data = _data; + } + res(true); + }) + + public removeNode = async (_path, _removeOnlyChild) => { + const item = this.findNode(_path); + + if (item && item.parentNode) { + item.children = []; + item.parentNode.children.splice(item.parentNode.children.indexOf(item), 1); + } + return true; + }; + + findNode(path) { + if (path === null || path === undefined || path.length === 0 || path == '/preferences') { + return this.tempTree; + } + console.log(path) + return findInTree(this.tempTree, path); + } + + public addNode = (_parent: string, _path: string, _data: []) => new Promise((res, rej) => { + _data.type = _data.inode ? FileType.Directory : FileType.File; + _data._label = _data.label; + _data.label = _.escape(_data.label); + + _data.is_collection = isCollectionNode(_data._type); + let nodeData = { parent: _parent, children: _data?.children ? _data.children : [], data: _data }; + + let tmpParentNode = this.findNode(_parent); + let treeNode = new TreeNode(_data.id, _data, {}, tmpParentNode, nodeData, _data.type); + + if (tmpParentNode !== null && tmpParentNode !== undefined) tmpParentNode.children.push(treeNode); + + res(treeNode); + }) + + public readNode = (_path: string) => new Promise((res, rej) => { + let temp_tree_path = _path, + node = this.findNode(_path), + base_url = '/preferences/'; + node.children = []; + + if (node && node.children.length > 0) { + if (!node.type === FileType.File) { + rej("It's a leaf node") + } + else { + if (node?.children.length != 0) res(node.children) + } + } + + var self = this; + + async function loadData() { + const Path = BrowserFS.BFSRequire('path') + const fill = async (tree) => { + //remove this is code clenup + for (let idx in tree) { + const _node = tree[idx] + const _pathl = Path.join(_path, _node.id) + await self.addNode(temp_tree_path, _pathl, _node); + } + } + + if (node && !_.isUndefined(node.id)) { + let _data = self.treeData.find((el) => el.id == node.id); + // console.log('Inside sub nodes') + + let subNodes = []; + + _data.childrenNodes.forEach(element => { + subNodes.push(element) + }); + + await fill(subNodes); + } else { + await fill(self.treeData); + } + + if (node?.children.length > 0) return res(node.children); + else return res(null); + + } + loadData(); + }) + +} + + + +export class TreeNode { + constructor(id, data, domNode, parent, metadata, type) { + this.id = id; + this.data = data; + this.setParent(parent); + this.children = []; + this.domNode = domNode; + this.metadata = metadata; + this.name = metadata ? metadata.data.label : ""; + this.type = type ? type : undefined; + } + + hasParent() { + return this.parentNode !== null && this.parentNode !== undefined; + } + + parent() { + return this.parentNode; + } + + setParent(parent) { + this.parentNode = parent; + this.path = this.id; + if (this.id) + if (parent !== null && parent !== undefined && parent.path !== undefined) { + this.path = parent.path + '/' + this.id; + } else { + this.path = '/preferences/' + this.id; + } + } + + getData() { + if (this.data === undefined) { + return undefined; + } else if (this.data === null) { + return null; + } + return Object.assign({}, this.data); + } + + getHtmlIdentifier() { + return this.domNode; + } + + /* + * Find the ancestor with matches this condition + */ + ancestorNode(condition) { + let node = this; + + while (node.hasParent()) { + node = node.parent(); + if (condition(node)) { + return node; + } + } + + return null; + } + + /** + * Given a condition returns true if the current node + * or any of the parent nodes condition result is true + */ + anyFamilyMember(condition) { + if (condition(this)) { + return true; + } + + return this.ancestorNode(condition) !== null; + } + anyParent(condition) { + return this.ancestorNode(condition) !== null; + } + + reload(tree) { + return new Promise((resolve) => { + this.unload(tree) + .then(() => { + tree.setInode(this.domNode); + tree.deselect(this.domNode); + setTimeout(() => { + tree.selectNode(this.domNode); + }, 0); + resolve(); + }); + }); + } + + unload(tree) { + return new Promise((resolve, reject) => { + this.children = []; + tree.unload(this.domNode) + .then( + () => { + resolve(true); + }, + () => { + reject(); + }); + }); + } + + + open(tree, suppressNoDom) { + return new Promise((resolve, reject) => { + if (suppressNoDom && (this.domNode == null || typeof (this.domNode) === 'undefined')) { + resolve(true); + } else if (tree.isOpen(this.domNode)) { + resolve(true); + } else { + tree.open(this.domNode).then(val => resolve(true), err => reject(true)); + } + }); + } + +} + +export function isCollectionNode(node) { + if (pgAdmin.Browser.Nodes && node in pgAdmin.Browser.Nodes) { + if (pgAdmin.Browser.Nodes[node].is_collection !== undefined) return pgAdmin.Browser.Nodes[node].is_collection; + else return false; + } + return false; +} diff --git a/web/pgadmin/static/js/tree/preferences_tree.tsx b/web/pgadmin/static/js/tree/preferences_tree.tsx new file mode 100644 index 00000000..f19db9de --- /dev/null +++ b/web/pgadmin/static/js/tree/preferences_tree.tsx @@ -0,0 +1,53 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import * as React from 'react'; +import { render } from 'react-dom'; +import { FileTreeX, TreeModelX } from 'pgadmin4-tree'; +import {Tree} from './tree'; + +import { IBasicFileSystemHost } from 'react-aspen'; +import { ManagePreferenceTreeNodes } from './preference_nodes'; + +var initPreferencesTree = async (pgBrowser, container, data) => { + const MOUNT_POINT = '/preferences' + + // Setup host + let ptree = new ManagePreferenceTreeNodes(data); + + // Init Tree with the Tree Parent node '/browser' + ptree.init(MOUNT_POINT); + const host: IBasicFileSystemHost = { + pathStyle: 'unix', + getItems: async (path) => { + return ptree.readNode(path); + }, + } + + const pTreeModelX = new TreeModelX(host, MOUNT_POINT) + + const itemHandle = function onReady(handler) { + // Initialize pgBrowser Tree + pgBrowser.ptree = new Tree(handler, ptree, pgBrowser, false); + return true; + } + + await pTreeModelX.root.ensureLoaded() + + // Render Browser Tree + await render( + + , container); +} + +module.exports = { + initPreferencesTree: initPreferencesTree, +}; + diff --git a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py index 46a326da..b18145e0 100644 --- a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py +++ b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py @@ -197,7 +197,7 @@ def register_query_tool_preferences(self): options=[{'label': gettext('None'), 'value': 'none'}, {'label': gettext('All'), 'value': 'all'}, {'label': gettext('Strings'), 'value': 'strings'}], - select2={ + control_props={ 'allowClear': False, 'tags': False } @@ -209,9 +209,9 @@ def register_query_tool_preferences(self): category_label=PREF_LABEL_CSV_TXT, options=[{'label': '"', 'value': '"'}, {'label': '\'', 'value': '\''}], - select2={ + control_props={ 'allowClear': False, - 'tags': True + 'tags': False } ) @@ -223,9 +223,9 @@ def register_query_tool_preferences(self): {'label': ',', 'value': ','}, {'label': '|', 'value': '|'}, {'label': gettext('Tab'), 'value': '\t'}], - select2={ + control_props={ 'allowClear': False, - 'tags': True + 'tags': False } ) @@ -247,7 +247,7 @@ def register_query_tool_preferences(self): options=[{'label': gettext('None'), 'value': 'none'}, {'label': gettext('All'), 'value': 'all'}, {'label': gettext('Strings'), 'value': 'strings'}], - select2={ + control_props={ 'allowClear': False, 'tags': False } @@ -259,9 +259,9 @@ def register_query_tool_preferences(self): category_label=PREF_LABEL_RESULTS_GRID, options=[{'label': '"', 'value': '"'}, {'label': '\'', 'value': '\''}], - select2={ + control_props={ 'allowClear': False, - 'tags': True + 'tags': False } ) @@ -273,9 +273,9 @@ def register_query_tool_preferences(self): {'label': ',', 'value': ','}, {'label': '|', 'value': '|'}, {'label': gettext('Tab'), 'value': '\t'}], - select2={ + control_props={ 'allowClear': False, - 'tags': True + 'tags': False } ) diff --git a/web/pgadmin/utils/preferences.py b/web/pgadmin/utils/preferences.py index 09011804..e07424cb 100644 --- a/web/pgadmin/utils/preferences.py +++ b/web/pgadmin/utils/preferences.py @@ -66,10 +66,11 @@ class _Preference(object): self.label = label self._type = _type self.help_str = kwargs.get('help_str', None) + self.control_props = kwargs.get('control_props', None) self.min_val = kwargs.get('min_val', None) self.max_val = kwargs.get('max_val', None) self.options = kwargs.get('options', None) - self.select2 = kwargs.get('select2', None) + self.select = kwargs.get('select', None) self.fields = kwargs.get('fields', None) self.allow_blanks = kwargs.get('allow_blanks', None) self.disabled = kwargs.get('disabled', False) @@ -146,10 +147,10 @@ class _Preference(object): for opt in self.options: if 'value' in opt and opt['value'] == res.value: return True, res.value - if self.select2 and self.select2['tags']: + if self.select and self.select['tags']: return True, res.value return True, self.default - if self._type == 'select2': + if self._type == 'select': if res.value: res.value = res.value.replace('[', '') res.value = res.value.replace(']', '') @@ -190,7 +191,7 @@ class _Preference(object): has_value = next((True for opt in self.options if 'value' in opt and opt['value'] == value), False) - assert (has_value or (self.select2 and self.select2['tags'])) + assert (has_value or (self.select and self.select['tags'])) elif self._type == 'date': value = parser_map[self._type](value).date() else: @@ -248,10 +249,11 @@ class _Preference(object): 'label': self.label or self.name, 'type': self._type, 'help_str': self.help_str, + 'control_props': self.control_props, 'min_val': self.min_val, 'max_val': self.max_val, 'options': self.options, - 'select2': self.select2, + 'select': self.select, 'value': self.get(), 'fields': self.fields, 'disabled': self.disabled, @@ -393,7 +395,7 @@ class Preferences(object): return res def register( - self, category, name, label, _type, default, **kwargs + self, category, name, label, _type, default, **kwargs ): """ register @@ -414,7 +416,7 @@ class Preferences(object): :param options: :param help_str: :param category_label: - :param select2: select2 control extra options + :param select: select control extra options :param fields: field schema (if preference has more than one field to take input from user e.g. keyboardshortcut preference) :param allow_blanks: Flag specify whether to allow blank value. @@ -424,8 +426,9 @@ class Preferences(object): max_val = kwargs.get('max_val', None) options = kwargs.get('options', None) help_str = kwargs.get('help_str', None) + control_props = kwargs.get('control_props', {}) category_label = kwargs.get('category_label', None) - select2 = kwargs.get('select2', None) + select = kwargs.get('select', None) fields = kwargs.get('fields', None) allow_blanks = kwargs.get('allow_blanks', None) disabled = kwargs.get('disabled', False) @@ -440,14 +443,15 @@ class Preferences(object): assert _type in ( 'boolean', 'integer', 'numeric', 'date', 'datetime', 'options', 'multiline', 'switch', 'node', 'text', 'radioModern', - 'keyboardshortcut', 'select2', 'selectFile', 'threshold' + 'keyboardshortcut', 'select', 'selectFile', 'threshold' ), "Type cannot be found in the defined list!" (cat['preferences'])[name] = res = _Preference( cat['id'], name, label, _type, default, help_str=help_str, min_val=min_val, max_val=max_val, options=options, - select2=select2, fields=fields, allow_blanks=allow_blanks, - disabled=disabled, dependents=dependents + select=select, fields=fields, allow_blanks=allow_blanks, + disabled=disabled, dependents=dependents, + control_props=control_props ) return res @@ -483,7 +487,7 @@ class Preferences(object): @classmethod def register_preference( - cls, module, category, name, label, _type, **kwargs + cls, module, category, name, label, _type, **kwargs ): """ register @@ -503,6 +507,7 @@ class Preferences(object): max_val = kwargs.get('max_val', None) options = kwargs.get('options', None) help_str = kwargs.get('help_str', None) + control_props = kwargs.get('control_props', None) module_label = kwargs.get('module_label', None) category_label = kwargs.get('category_label', None) @@ -516,6 +521,7 @@ class Preferences(object): return m.register( category, name, label, _type, default, min_val=min_val, max_val=max_val, options=options, help_str=help_str, + control_props=control_props, category_label=category_label ) diff --git a/web/regression/javascript/components/KeyboardShortcuts.spec.js b/web/regression/javascript/components/KeyboardShortcuts.spec.js new file mode 100644 index 00000000..1a92ff88 --- /dev/null +++ b/web/regression/javascript/components/KeyboardShortcuts.spec.js @@ -0,0 +1,100 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2022, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import jasmineEnzyme from 'jasmine-enzyme'; +import React from 'react'; +import '../helper/enzyme.helper'; +import { withTheme } from '../fake_theme'; +import { createMount } from '@material-ui/core/test-utils'; +import { + OutlinedInput, +} from '@material-ui/core'; +import KeyboardShortcuts from '../../../pgadmin/static/js/components/KeyboardShortcuts'; + +/* MUI Components need to be wrapped in Theme for theme vars */ +describe('KeyboardShortcuts', () => { + let mount; + let defult_value = { + 'ctrl': true, + 'alt': true, + 'key': { + 'char': 'a', + 'key_code': 97 + } + }; + let fields = [{ + type: 'keyCode', + label: 'Key' + }, { + name: 'shift', + label: 'Shift', + type: 'checkbox' + }, + { + name: 'control', + label: 'Control', + type: 'checkbox' + }, + { + name: 'alt', + label: 'Alt/Option', + type: 'checkbox' + }]; + + /* Use createMount so that material ui components gets the required context */ + /* https://material-ui.com/guides/testing/#api */ + beforeAll(() => { + mount = createMount(); + }); + + afterAll(() => { + mount.cleanUp(); + }); + + beforeEach(() => { + jasmineEnzyme(); + }); + + describe('KeyboardShortcuts', () => { + let ThemedFormInputKeyboardShortcuts = withTheme(KeyboardShortcuts), ctrl; + + beforeEach(() => { + ctrl = mount( + ); + }); + + it('init', () => { + expect(ctrl.find(OutlinedInput).prop('value')).toBe('a'); + }); + + it('Key change', () => { + let onChange = () => {/*This is intentional (SonarQube)*/ }; + ctrl.setProps({ + controlProps: { + onKeyDown: onChange + } + }); + + expect(ctrl.find(OutlinedInput).prop('value')).toBe('a'); + }); + }); + +}); diff --git a/web/regression/javascript/components/QueryThreshold.spec.js b/web/regression/javascript/components/QueryThreshold.spec.js new file mode 100644 index 00000000..fa259ae0 --- /dev/null +++ b/web/regression/javascript/components/QueryThreshold.spec.js @@ -0,0 +1,86 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2022, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import jasmineEnzyme from 'jasmine-enzyme'; +import React from 'react'; +import '../helper/enzyme.helper'; +import { withTheme } from '../fake_theme'; +import { createMount } from '@material-ui/core/test-utils'; +import { + OutlinedInput, +} from '@material-ui/core'; +import QueryThresholds from '../../../pgadmin/static/js/components/QueryThresholds'; + +/* MUI Components need to be wrapped in Theme for theme vars */ +describe('QueryThresholds', () => { + let mount; + let defult_value = { + 'warning': 5, + 'alert': 6 + }; + + /* Use createMount so that material ui components gets the required context */ + /* https://material-ui.com/guides/testing/#api */ + beforeAll(() => { + mount = createMount(); + }); + + afterAll(() => { + mount.cleanUp(); + }); + + beforeEach(() => { + jasmineEnzyme(); + }); + + describe('QueryThresholds', () => { + let ThemedFormInputQueryThresholds = withTheme(QueryThresholds), ctrl; + + beforeEach(() => { + ctrl = mount( + ); + }); + + it('init Warning', () => { + expect(ctrl.find(OutlinedInput).at(0).prop('value')).toBe(5); + }); + + it('init Alert', () => { + expect(ctrl.find(OutlinedInput).at(1).prop('value')).toBe(6); + }); + + it('warning change', () => { + let onChange = () => {/*This is intentional (SonarQube)*/ }; + ctrl.setProps({ + onChange: onChange + }); + expect(ctrl.find(OutlinedInput).at(0).prop('value')).toBe(5); + }); + + it('Alert change', () => { + let onChange = () => {/*This is intentional (SonarQube)*/ }; + ctrl.setProps({ + onChange: onChange + }); + expect(ctrl.find(OutlinedInput).at(1).prop('value')).toBe(6); + }); + }); + +}); diff --git a/web/regression/javascript/schema_ui_files/binary_path.ui.spec.js b/web/regression/javascript/schema_ui_files/binary_path.ui.spec.js new file mode 100644 index 00000000..f51dfefc --- /dev/null +++ b/web/regression/javascript/schema_ui_files/binary_path.ui.spec.js @@ -0,0 +1,44 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2022, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import '../helper/enzyme.helper'; +import { createMount } from '@material-ui/core/test-utils'; +import {genericBeforeEach, getPropertiesView} from '../genericFunctions'; +import {getBinaryPathSchema} from '../../../pgadmin/browser/server_groups/servers/static/js/binary_path.ui'; + +describe('BinaryPathschema', ()=>{ + let mount; + let schemaObj = getBinaryPathSchema(); + let getInitData = ()=>Promise.resolve({}); + + /* Use createMount so that material ui components gets the required context */ + /* https://material-ui.com/guides/testing/#api */ + beforeAll(()=>{ + mount = createMount(); + }); + + afterAll(() => { + mount.cleanUp(); + }); + + beforeEach(()=>{ + genericBeforeEach(); + }); + + it('edit', ()=>{ + mount(getPropertiesView(schemaObj, getInitData)); + }); + + it('validate path', ()=>{ + let validate = _.find(schemaObj.fields, (f)=>f.id=='binaryPath').validate; + let status = validate('/test/'); + expect(status).toBe(true); + }); + +}); diff --git a/web/webpack.shim.js b/web/webpack.shim.js index f45efde8..a2e8f28e 100644 --- a/web/webpack.shim.js +++ b/web/webpack.shim.js @@ -282,7 +282,7 @@ var webpackShimConfig = { 'pgadmin.node.user_mapping': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/foreign_data_wrappers/foreign_servers/user_mappings/static/js/user_mapping'), 'pgadmin.node.view': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/views/static/js/view'), 'pgadmin.node.row_security_policy': path.join(__dirname, './pgadmin/browser/server_groups/servers/databases/schemas/tables/row_security_policies/static/js/row_security_policy'), - 'pgadmin.preferences': path.join(__dirname, './pgadmin/preferences/static/js/preferences'), + 'pgadmin.preferences': path.join(__dirname, './pgadmin/preferences/static/js/'), 'pgadmin.settings': path.join(__dirname, './pgadmin/settings/static/js/settings'), 'pgadmin.server.supported_servers': '/browser/server/supported_servers', 'pgadmin.sqleditor': path.join(__dirname, './pgadmin/tools/sqleditor/static/js/sqleditor'),