public inbox for [email protected]
help / color / mirror / Atom feedFrom: Nikhil Mohite <[email protected]>
To: pgadmin-hackers <[email protected]>
Subject: [pgAdmin][RM-7149]: [React] Port preferences dialog to React.
Date: Mon, 7 Mar 2022 17:53:57 +0530
Message-ID: <CAOBg0AMfBEFesek3Uoet9zuNbHP5xA8OzWNGkP8JTt5CrGk9_w@mail.gmail.com> (raw)
Hi Hackers,
Please find attached the patch for RM-7149
<https://redmine.postgresql.org/issues/7149;: [React] Port preferences
dialog to React.
--
*Thanks & Regards,*
*Nikhil Mohite*
*Senior Software Engineer.*
*EDB Postgres* <https://www.enterprisedb.com/;
*Mob.No: +91-7798364578.*
Attachments:
[application/octet-stream] RM-7149.patch (149.0K, 3-RM-7149.patch)
download | inline diff:
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():
)
[email protected]("/<int:pid>", 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
+
+
[email protected]("/", 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 (
+ <SchemaView
+ formType={'dialog'}
+ getInitData={initData}
+ viewHelperProps={{ mode: 'edit' }}
+ schema={schema}
+ showFooter={false}
+ isTabView={false}
+ onDataChange={(isChanged, changedData) => {
+ 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: [
+ '<ul><li>',
+ gettext(note),
+ '</li></ul>',
+ ].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 (
+ <Box height={props.isFullScreen ? '100%' : '520px'}>
+ <Box className={classes.root}>
+ <Box className={clsx(classes.preferences)}>
+ <Box className={clsx(classes.treeContainer)}>
+ {prefTreeData &&
+ <PreferencesTree data={prefTreeData} getSelectedItem={(item) => {
+ 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));
+ }}
+ ></PreferencesTree>
+ }
+ </Box>
+ <Box className={clsx(classes.preferencesContainer)}>
+ {
+ prefSchema.current && loadTree > 0 ?
+ <RightPanel schema={prefSchema.current} initValues={initValues} onDataChange={(changedData) => {
+ Object.keys(changedData).length > 0 ? setDisableSave(false) : setDisableSave(true);
+ prefChangedData.current = changedData;
+ }}></RightPanel>
+ : <></>
+ }
+ </Box>
+ </Box>
+ <Box className={classes.footer}>
+ <Box className={classes.actionBtn} marginLeft="auto">
+ <DefaultButton className={classes.buttonMargin} onClick={() => { props.closeModal(); /*props.panel.close()*/ }} startIcon={<CloseSharpIcon onClick={() => { props.closeModal(); /*props.panel.close()*/ }} />}>
+ {gettext('Cancel')}
+ </DefaultButton>
+ <PrimaryButton className={classes.buttonMargin} startIcon={<SaveSharpIcon />} disabled={disableSave} onClick={() => { savePreferences(prefChangedData); }}>
+ {gettext('Save')}
+ </PrimaryButton>
+ </Box>
+ </Box>
+ {/* </Box> */}
+
+ </Box >
+ </Box>
+ );
+}
+
+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 (
+ <AutoSizer>
+ {({ width, height }) => (
+ <FileTree
+ model={model.current}
+ height={height}
+ width={width}
+ itemHeight={24}
+ onReady={(handel) => {
+ internalHandler.current = handel;
+ return true;
+ }
+ }>
+ {(props) => <FileTreeItem
+ events={events}
+ item={props.item}
+ itemType={props.itemType}
+ onClick={(ev, item, type) => {
+ 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)}
+ />}
+ </FileTree>
+ )}
+ </AutoSizer>
+
+ );
+}
+
+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 = $('<div class=\'preferences_dialog d-flex flex-row\'></div>');
-
-
- /*
- * 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(
- '<div class=\'pg-el-sm-3 preferences_tree aciTree\'></div>'
- ).append(
- '<div class=\'pg-el-sm-9 preferences_content\'>' +
- gettext('Category is not selected.') +
- '</div>'
- );
-
- // 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 <PreferencesComponent closeModal={closeModal} isFullScreen={isFullScreen}/>;}, {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 <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} type='int'/>;
+ return <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} type='int' />;
case 'numeric':
- return <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} type='numeric'/>;
+ return <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} type='numeric' />;
case 'tel':
- return <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} type='tel'/>;
+ return <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} type='tel' />;
case 'text':
- return <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props}/>;
+ return <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} />;
case 'multiline':
return <FormInputText name={name} value={value} onChange={onTextChange} className={className}
- inputRef={inputRef} controlProps={{multiline: true}} {...props}/>;
+ inputRef={inputRef} controlProps={{ multiline: true }} {...props} />;
case 'password':
- return <FormInputText name={name} value={value} onChange={onTextChange} className={className} type='password' inputRef={inputRef} {...props}/>;
+ return <FormInputText name={name} value={value} onChange={onTextChange} className={className} type='password' inputRef={inputRef} {...props} />;
case 'select':
return <FormInputSelect name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} />;
case 'select-refresh':
return <SelectRefresh name={name} value={value} onChange={onTextChange} className={className} {...props} />;
case 'switch':
return <FormInputSwitch name={name} value={value}
- onChange={(e)=>onTextChange(e.target.checked, e.target.name)} className={className}
+ onChange={(e) => onTextChange(e.target.checked, e.target.name)} className={className}
{...props} />;
case 'checkbox':
return <FormInputCheckbox name={name} value={value}
- onChange={(e)=>onTextChange(e.target.checked, e.target.name)} className={className}
+ onChange={(e) => onTextChange(e.target.checked, e.target.name)} className={className}
{...props} />;
case 'toggle':
return <FormInputToggle name={name} value={value}
@@ -76,9 +77,13 @@ function MappedFormControlBase({type, value, id, onChange, className, visible, i
case 'sql':
return <FormInputSQL name={name} value={value} onChange={onSqlChange} className={className} noLabel={noLabel} {...props} />;
case 'note':
- return <FormNote className={className} {...props}/>;
+ return <FormNote className={className} {...props} />;
case 'datetimepicker':
return <FormInputDateTimePicker name={name} value={value} onChange={onTextChange} className={className} {...props} />;
+ case 'keyboardShortcut':
+ return <FormInputKeyboardShortcut name={name} value={value} onChange={onTextChange} {...props}/>;
+ case 'threshold':
+ return <FormInputQueryThreshold name={name} value={value} onChange={onTextChange} {...props}/>;
default:
return <PlainString value={value} {...props} />;
}
@@ -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 <InputSelect name={name} value={value} onChange={onTextChange} optionsLoaded={optionsLoadedRerender} {...props}/>;
case 'switch':
return <InputSwitch name={name} value={value}
- onChange={(e)=>onTextChange(e.target.checked, e.target.name)} {...props} />;
+ onChange={(e)=>onTextChange(e.target.checked, e.target.name)} disabled={props.disabled} {...props} />;
case 'checkbox':
return <InputCheckbox name={name} value={value}
onChange={(e)=>onTextChange(e.target.checked, e.target.name)} {...props} />;
@@ -152,6 +157,10 @@ function MappedCellControlBase({cell, value, id, optionsLoaded, onCellChange, vi
return <InputDateTimePicker name={name} value={value} onChange={onTextChange} {...props}/>;
case 'sql':
return <InputSQL name={name} value={value} onChange={onSqlChange} {...props} />;
+ case 'file':
+ return <InputFileSelect name={name} value={value} onChange={onTextChange} inputRef={props.inputRef} {...props} />;
+ case 'keyCode':
+ return <InputText name={name} value={value} onChange={onTextChange} {...props} type='text' maxlength={1} />;
default:
return <PlainString value={value} {...props} />;
}
@@ -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 <MappedFormControlBase {..._.pick(newProps, _.union(ALLOWED_PROPS_FIELD_COMMON, ALLOWED_PROPS_FIELD_FORM))}/>;
+ return <MappedFormControlBase {..._.pick(newProps, _.union(ALLOWED_PROPS_FIELD_COMMON, ALLOWED_PROPS_FIELD_FORM))} />;
};
-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 <MappedCellControlBase {..._.pick(newProps, _.union(ALLOWED_PROPS_FIELD_COMMON, ALLOWED_PROPS_FIELD_CELL))}/>;
+ return <MappedCellControlBase {..._.pick(newProps, _.union(ALLOWED_PROPS_FIELD_COMMON, ALLOWED_PROPS_FIELD_CELL))} />;
};
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 (
<Grid container spacing={0} className={className}>
<Grid item lg={3} md={3} sm={3} xs={12}>
- <InputLabel htmlFor={cid} className={clsx(classes.formLabel, error?classes.formLabelError : null)} required={required}>
+ <InputLabel htmlFor={cid} className={clsx(classes.formLabel, error ? classes.formLabelError : null)} required={required}>
{label}
- <FormIcon type={MESSAGE_TYPE.ERROR} style={{marginLeft: 'auto', visibility: error ? 'unset' : 'hidden'}}/>
+ <FormIcon type={MESSAGE_TYPE.ERROR} style={{ marginLeft: 'auto', visibility: error ? 'unset' : 'hidden' }} />
</InputLabel>
</Grid>
<Grid item lg={9} md={9} sm={9} xs={12}>
<FormControl error={Boolean(error)} fullWidth>
- {React.cloneElement(children, {cid, helpid})}
+ {React.cloneElement(children, { cid, helpid })}
</FormControl>
<FormHelperText id={helpid} variant="outlined">{HTMLReactParse(helpMessage || '')}</FormHelperText>
</Grid>
@@ -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 (
<CodeMirror
- currEditor={(obj)=>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 <InputSQL value={value} {...props}/>;
+export function FormInputSQL({ hasError, required, label, className, helpMessage, testcid, value, controlProps, noLabel, ...props }) {
+ if (noLabel) {
+ return <InputSQL value={value} options={controlProps} {...props} />;
} else {
return (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid} >
- <InputSQL value={value} {...props}/>
+ <InputSQL value={value} options={controlProps} {...props} />
</FormInput>
);
}
@@ -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 (<InputText value={value ? DateFns.format(value, format) : value}
- readonly={readonly} controlProps={{placeholder: controlProps.placeholder}} {...props}/>);
+ 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 (
<MuiPickersUtilsProvider utils={DateFnsUtils}>
- <KeyboardDatePicker {...commonProps}/>
+ <KeyboardDatePicker {...commonProps} />
</MuiPickersUtilsProvider>
);
} else if (controlProps?.pickerType === 'Time') {
return (
<MuiPickersUtilsProvider utils={DateFnsUtils}>
- <KeyboardTimePicker {...commonProps}/>
+ <KeyboardTimePicker {...commonProps} />
</MuiPickersUtilsProvider>
);
}
return (
<MuiPickersUtilsProvider utils={DateFnsUtils}>
- <KeyboardDateTimePicker {...commonProps}/>
+ <KeyboardDateTimePicker {...commonProps} />
</MuiPickersUtilsProvider>
);
}
@@ -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 (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
- <InputDateTimePicker {...props}/>
+ <InputDateTimePicker {...props} />
</FormInput>
);
}
@@ -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 (
<OutlinedInput
ref={ref}
color="primary"
@@ -346,7 +356,7 @@ export const InputText = forwardRef(({
id: cid,
maxLength: controlProps?.multiline ? null : maxlength,
'aria-describedby': helpid,
- ...(type ? {pattern: !_.isUndefined(controlProps) && !_.isUndefined(controlProps.pattern) ? controlProps.pattern : patterns[type]} : {})
+ ...(type ? { pattern: !_.isUndefined(controlProps) && !_.isUndefined(controlProps.pattern) ? controlProps.pattern : patterns[type] } : {})
}}
readOnly={Boolean(readonly)}
disabled={Boolean(disabled)}
@@ -354,9 +364,12 @@ export const InputText = forwardRef(({
notched={false}
value={(_.isNull(finalValue) || _.isUndefined(finalValue)) ? '' : finalValue}
onChange={onChangeFinal}
+ {
+ ...(controlProps?.onKeyDown && { onKeyDown: controlProps.onKeyDown })
+ }
{...controlProps}
{...props}
- {...(['numeric', 'int'].indexOf(type) > -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 (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
- <InputText label={label} {...props}/>
+ <InputText label={label} {...props} />
</FormInput>
);
}
@@ -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 (
<InputText ref={inpRef} disabled={disabled} readonly={readonly} onChange={onChange} {...props} endAdornment={
- <IconButton onClick={()=>showFileDialog(controlProps, onFileSelect)}
- disabled={disabled||readonly} aria-label={gettext('Select a file')}><FolderOpenRoundedIcon /></IconButton>
+ <>
+ <IconButton onClick={() => showFileDialog(controlProps, onFileSelect)}
+ disabled={disabled || readonly} aria-label={gettext('Select a file')}><FolderOpenRoundedIcon /></IconButton>
+ {isvalidate &&
+ <PgIconButton title={gettext('Validate')} disabled={!props.value} onClick={() => { validate(props.value); }} icon={<AssignmentTurnedIn />}></PgIconButton>
+ }
+ </>
} />
);
}
@@ -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 (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
- <InputFileSelect required={required} label={label} {...props}/>
+ <InputFileSelect required={required} label={label} {...props} />
</FormInput>
);
}
@@ -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 (
<Switch color="primary"
checked={Boolean(value)}
onChange={
- readonly ? ()=>{/*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 (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
- <InputSwitch {...props}/>
+ <InputSwitch {...props} />
</FormInput>
);
}
@@ -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 (
<FormControlLabel
@@ -482,10 +503,10 @@ export function InputCheckbox({cid, helpid, value, onChange, controlProps, reado
<Checkbox
id={cid}
checked={Boolean(value)}
- onChange={readonly ? ()=>{/*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 (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
- <InputCheckbox {...props}/>
+ <InputCheckbox {...props} />
</FormInput>
);
}
@@ -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 (
<ToggleButtonGroup
id={cid}
value={value}
exclusive
- onChange={(e, val)=>{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 (
- <ToggleButton ref={i==0 ? ref : null} key={option.label} value={option.value} component={isSelected ? PrimaryButton : DefaultButton}
+ <ToggleButton ref={i == 0 ? ref : null} key={option.label} value={option.value} component={isSelected ? PrimaryButton : DefaultButton}
disabled={isDisabled} aria-label={option.label}>
- <CheckRoundedIcon style={{visibility: isSelected ? 'visible': 'hidden'}}/> {option.label}
+ <CheckRoundedIcon style={{ visibility: isSelected ? 'visible' : 'hidden' }} /> {option.label}
</ToggleButton>
);
})
@@ -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 (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
- <InputToggle ref={inputRef} {...props}/>
+ <InputToggle ref={inputRef} {...props} />
</FormInput>
);
}
@@ -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 <MuiSelect native {...props} variant="outlined">
- {(options||[]).map((o)=><option key={o.value} value={o.value}>{o.label}</option>)}
+ {(options || []).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</MuiSelect>;
}
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 (
- <Select ref={ref} {...commonProps}/>
+ <Select ref={ref} {...commonProps} />
);
} else {
return (
- <CreatableSelect ref={ref} {...commonProps}/>
+ <CreatableSelect ref={ref} {...commonProps} />
);
}
});
@@ -863,10 +884,10 @@ InputSelect.propTypes = {
export function FormInputSelect({
- hasError, required, className, label, helpMessage, testcid, ...props}) {
+ hasError, required, className, label, helpMessage, testcid, ...props }) {
return (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
- <InputSelect ref={props.inputRef} {...props}/>
+ <InputSelect ref={props.inputRef} {...props} />
</FormInput>
);
}
@@ -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 (
<PgIconButton ref={eleRef} title={gettext('Select the color')} className={classes.colorBtn} style={btnStyles} disabled={pickrOptions.disabled}
icon={(_.isUndefined(value) || _.isNull(value) || value === '') && <CloseIcon />}
@@ -1006,11 +1027,11 @@ InputColor.propTypes = {
};
export function FormInputColor({
- hasError, required, className, label, helpMessage, testcid, ...props}) {
+ hasError, required, className, label, helpMessage, testcid, ...props }) {
return (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
- <InputColor {...props}/>
+ <InputColor {...props} />
</FormInput>
);
}
@@ -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 <span>{finalValue}</span>;
@@ -1035,7 +1056,7 @@ PlainString.propTypes = {
value: PropTypes.any,
};
-export function FormNote({text, className}) {
+export function FormNote({ text, className }) {
const classes = useStyles();
return (
<Box className={className}>
@@ -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 (
+ <FormInput label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
+ <KeyboardShortcuts cid={cid} helpid={helpid} onChange={onChange} {...props} />
+ </FormInput>
+
+ );
+}
+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 (
+ <FormInput label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
+ <QueryThresholds cid={cid} helpid={helpid} onChange={onChange} {...props} />
+ </FormInput>
+
+ );
+}
+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 (
<Box className={clsx(classes.container, classes[`container${type}`])}>
- <FormIcon type={type} className={classes[`icon${type}`]}/>
+ <FormIcon type={type} className={classes[`icon${type}`]} />
<Box className={classes.message}>{message}</Box>
{closable && <IconButton className={clsx(classes.closeButton, classes[`icon${type}`])} onClick={onClose}>
- <FormIcon close={true}/>
+ <FormIcon close={true} />
</IconButton>}
</Box>
);
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 (
+ <FormGroup row={true} className={classes.formGroup}>
+ {fields.map(element => {
+ if (element.type == 'keyCode') {
+ return <FormControlLabel key={_.uniqueId('c')} labelPlacement="start" className={classes.formControlLabel} control={<InputText cid={keyCid} helpid={keyhelpid} type='text' value={value?.key?.char} controlProps={
+ {
+ onKeyDown: onKeyDown,
+ }
+ }></InputText>} label={element.label} />;
+ } else if (element.name == 'shift') {
+ return <FormControlLabel key={_.uniqueId('c')} labelPlacement="start" className={classes.formCheckboxControl} control={<InputCheckbox cid={shiftCid} helpid={shifthelpid} value={value?.shift} onChange={onShiftChange}></InputCheckbox>} label={element.label} />;
+ } else if (element.name == 'control') {
+ return <FormControlLabel key={_.uniqueId('c')} labelPlacement="start" className={classes.formCheckboxControl} control={<InputCheckbox cid={ctrlCid} helpid={ctrlhelpid} value={value?.ctrl} onChange={onCtrlChange} ></InputCheckbox>} label={element.label} />;
+ } else if (element.name == 'alt') {
+ return <FormControlLabel key={_.uniqueId('c')} labelPlacement="start" className={classes.formCheckboxControl} control={<InputCheckbox cid={altCid} helpid={althelpid} value={value?.alt} onChange={onAltChange}></InputCheckbox>} label={element.label} />;
+ }
+ })
+
+ }
+ </FormGroup>
+ );
+}
+
+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 (
+ <FormGroup row={true} className={classes.formGroup}>
+ <FormControlLabel key={_.uniqueId('c')} labelPlacement="start" className={classes.formControlLabel} control={<InputText cid={warningCid} helpid={warninghelpid} type='numeric' value={value?.warning}
+ onChange={onWarningChange}
+ ></InputText>} label={gettext('Warning')} />;
+ <FormControlLabel key={_.uniqueId('c')} labelPlacement="start" className={classes.formControlLabel} control={<InputText cid={alertCid} helpid={alerthelpid} type='numeric' value={value?.alert}
+ onChange={onAlertChange}></InputText>} label={gettext('Alert')} />;
+ </FormGroup>
+ );
+}
+
+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 (
<Box display="flex" flexDirection="column" height="100%">
- <Box flexGrow="1" p={2}>{typeof(text) == 'string' ? HTMLReactParser(text) : text}</Box>
+ <Box flexGrow="1" p={2}>{typeof (text) == 'string' ? HTMLReactParser(text) : text}</Box>
<Box className={classes.footer}>
{confirm &&
<DefaultButton startIcon={<CloseIcon />} onClick={onCancelClick} >{cancelLabel}</DefaultButton>
@@ -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 (
- <AlertContent text={text} confirm onOkClick={onOkClickClose} onCancelClick={onCancelClickClose} okLabel={okLabel} cancelLabel={cancelLabel}/>
+ <AlertContent text={text} confirm onOkClick={onOkClickClose} onCancelClick={onCancelClickClose} okLabel={okLabel} cancelLabel={cancelLabel} />
);
});
}
-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 (
<ModalContext.Provider value={modalContext}>
{children}
- {modals.map((modalOptions, i)=>(
- <ModalContainer key={i} {...modalOptions}/>
+ {modals.map((modalOptions, i) => (
+ <ModalContainer key={i} {...modalOptions} />
))}
</ModalContext.Provider>
);
@@ -133,28 +143,55 @@ ModalProvider.propTypes = {
function PaperComponent(props) {
return (
<Draggable cancel={'[class*="MuiDialogContent-root"]'}>
- <Paper {...props} style={{minWidth: '600px'}} />
+ <Paper {...props} style={{ minWidth: '600px' }} />
</Draggable>
);
}
-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 (
<Theme>
<Dialog
open={true}
onClose={closeModal}
PaperComponent={PaperComponent}
+ fullScreen={isfullScreen}
+ maxWidth={maxWidth}
+ fullWidth={isFullWidth}
disableBackdropClick
>
<DialogTitle>
- <Box marginRight="0.25rem">{title}</Box>
- <Box marginLeft="auto"><PgIconButton title={gettext('Close')} icon={<CloseIcon />} size="xs" noBorder onClick={closeModal}/></Box>
+ <Box className={classes.titleBar}>
+ <Box className={classes.title} marginRight="0.25rem" >{title}</Box>
+ {
+ showFullScreen && !isfullScreen &&
+ <Box marginLeft="auto"><PgIconButton title={gettext('Maximize')} icon={<FullscreenIcon />} size="xs" noBorder onClick={() => { setIsFullScreen(!isfullScreen); }} /></Box>
+ }
+ {
+ showFullScreen && isfullScreen &&
+ <Box marginLeft="auto"><PgIconButton title={gettext('Minimize')} icon={<FullscreenExitIcon />} size="xs" noBorder onClick={() => { setIsFullScreen(!isfullScreen); }} /></Box>
+ }
+
+ <Box marginLeft="auto"><PgIconButton title={gettext('Close')} icon={<CloseIcon />} size="xs" noBorder onClick={closeModal} /></Box>
+ </Box>
</DialogTitle>
- <DialogContent>
- {content(closeModal)}
+ <DialogContent height="100%">
+ {content(closeModal, isfullScreen)}
</DialogContent>
</Dialog>
</Theme>
@@ -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 (
+ <Box display="flex" flexDirection="column" height="100%">
+ <Box flexGrow="1" p={2}>{HTMLReactParser(text)}</Box>
+ <Box className={classes.footer}>
+ {confirm &&
+ <DefaultButton startIcon={<CloseIcon />} onClick={onCancelClick} >{cancelLabel}</DefaultButton>
+ }
+ <PrimaryButton className={classes.margin} startIcon={<CheckRoundedIcon />} onClick={onOkClick} autoFocus={true} >{okLabel}</PrimaryButton>
+ </Box>
+ </Box>
+ );
+}
+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<string[]>((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(
+ <FileTreeX model={pTreeModelX}
+ onReady={itemHandle} />
+ , 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(
+ <ThemedFormInputKeyboardShortcuts
+ testcid="inpCid"
+ helpMessage="some help message"
+ /* InputText */
+ readonly={false}
+ disabled={false}
+ maxlength={1}
+ value={defult_value}
+ fields={fields}
+ controlProps={{
+ extraprop: 'test'
+ }}
+ />);
+ });
+
+ 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(
+ <ThemedFormInputQueryThresholds
+ testcid="inpCid"
+ helpMessage="some help message"
+ /* InputText */
+ readonly={false}
+ disabled={false}
+ maxlength={1}
+ value={defult_value}
+ controlProps={{
+ extraprop: 'test'
+ }}
+ />);
+ });
+
+ 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'),
view thread (7+ messages) latest in thread
reply
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Reply to all the recipients using the --to and --cc options:
reply via email
To: [email protected]
Cc: [email protected]
Subject: Re: [pgAdmin][RM-7149]: [React] Port preferences dialog to React.
In-Reply-To: <CAOBg0AMfBEFesek3Uoet9zuNbHP5xA8OzWNGkP8JTt5CrGk9_w@mail.gmail.com>
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox