diff --git a/web/package.json b/web/package.json index d30f0238c..97a31badc 100644 --- a/web/package.json +++ b/web/package.json @@ -13,8 +13,10 @@ "@babel/plugin-proposal-object-rest-spread": "^7.9.6", "@babel/preset-env": "^7.10.2", "@emotion/core": "^10.0.14", + "@emotion/memoize": "^0.7.5", "@emotion/react": "^11.1.5", "@emotion/styled": "^10.0.14", + "@emotion/utils": "^1.0.0", "@wojtekmaj/enzyme-adapter-react-17": "^0.4.1", "autoprefixer": "^10.2.4", "axios-mock-adapter": "^1.17.0", @@ -48,7 +50,7 @@ "karma-sourcemap-loader": "^0.3.7", "karma-webpack": "^5.0.0", "mini-css-extract-plugin": "^1.3.5", - "popper.js": "^1.14.7", + "popper.js": "^1.16.1", "postcss-loader": "^5.0.0", "process": "^0.11.10", "prop-types": "^15.7.2", @@ -68,7 +70,12 @@ "dependencies": { "@babel/plugin-proposal-class-properties": "^7.10.4", "@babel/preset-react": "^7.12.13", + "@emotion/sheet": "^1.0.1", "@fortawesome/fontawesome-free": "^5.14.0", + "@material-ui/core": "^4.11.4", + "@material-ui/icons": "^4.11.2", + "@material-ui/lab": "^4.0.0-alpha.58", + "@material-ui/pickers": "^3.2.10", "@projectstorm/react-diagrams": "^6.4.2", "@simonwep/pickr": "^1.5.1", "@tippyjs/react": "^4.2.0", @@ -90,6 +97,7 @@ "css-loader": "^5.0.1", "cssnano": "^5.0.2", "dagre": "^0.8.4", + "diff-arrays-of-objects": "^1.1.8", "dropzone": "^5.7.4", "html2canvas": "^1.0.0-rc.7", "immutability-helper": "^3.0.0", @@ -113,12 +121,15 @@ "raf": "^3.4.1", "react": "^17.0.1", "react-dom": "^17.0.1", + "react-select": "^4.2.1", + "react-table": "^7.6.3", "select2": "^4.0.13", "shim-loader": "^1.0.1", "slickgrid": "git+https://github.com/6pac/SlickGrid.git#2.3.16", "snapsvg-cjs": "^0.0.6", "socket.io-client": "^4.0.0", "split.js": "^1.5.10", + "styled-components": "^5.2.1", "tablesorter": "^2.31.2", "tempusdominus-bootstrap-4": "^5.1.2", "tempusdominus-core": "^5.0.3", diff --git a/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js b/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js index eb20cac5f..7b4ee0a98 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js +++ b/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js @@ -7,6 +7,11 @@ // ////////////////////////////////////////////////////////////// +import { getNodeAjaxOptions, getNodeListByName } from '../../../../../static/js/node_ajax'; +import { getNodePrivilegeRoleSchema } from '../../../static/js/privilege.ui'; +import { getNodeVariableSchema } from '../../../static/js/variable.ui'; +import DatabaseSchema from './database.ui'; + define('pgadmin.node.database', [ 'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'sources/utils', 'sources/pgadmin', 'pgadmin.browser.utils', @@ -306,30 +311,56 @@ define('pgadmin.node.database', [ pgBrowser.Node.callbacks.refresh.apply(this, arguments); }, }, + getSchema: function(treeNodeInfo, itemNodeData) { + return new DatabaseSchema( + ()=>getNodeVariableSchema(this, treeNodeInfo, itemNodeData, false, true), + (privileges)=>getNodePrivilegeRoleSchema(this, treeNodeInfo, itemNodeData, privileges), + { + role: ()=>getNodeListByName('role', treeNodeInfo, itemNodeData), + encoding: + ()=>getNodeAjaxOptions('get_encodings', this, treeNodeInfo, itemNodeData, { + cacheLevel: 'server', + }), + template: + ()=>getNodeAjaxOptions('get_databases', this, treeNodeInfo, itemNodeData, { + cacheLevel: 'server', + }, (data)=>{ + let res = []; + if (data && _.isArray(data)) { + _.each(data, function(d) { + res.push({label: d, value: d, + image: 'pg-icon-database'}); + }); + } + return res; + }), + spcname: + ()=>getNodeListByName('tablespace', treeNodeInfo, itemNodeData, (m)=>{ + return (m.label != 'pg_global'); + }), + datcollate: + ()=>getNodeAjaxOptions('get_ctypes', this, treeNodeInfo, itemNodeData, { + cacheLevel: 'server', + }), + datctype: + ()=>getNodeAjaxOptions('get_ctypes', this, treeNodeInfo, itemNodeData, { + cacheLevel: 'server', + }), + }, + { + datowner: pgBrowser.serverInfo[treeNodeInfo.server._id].user.name, + } + ); + }, + /* Few fields are kept since the properties tab for collection is not + yet migrated to new react schema. Once the properties for collection + is removed, remove this model */ model: pgBrowser.Node.Model.extend({ idAttribute: 'did', defaults: { name: undefined, owner: undefined, - is_sys_obj: undefined, comment: undefined, - encoding: 'UTF8', - template: undefined, - tablespace: undefined, - collation: undefined, - char_type: undefined, - datconnlimit: -1, - datallowconn: undefined, - variables: [], - privileges: [], - securities: [], - datacl: [], - deftblacl: [], - deffuncacl: [], - defseqacl: [], - is_template: false, - deftypeacl: [], - schema_res:'', }, // Default values! @@ -354,187 +385,11 @@ define('pgadmin.node.database', [ id: 'datowner', label: gettext('Owner'), editable: false, type: 'text', node: 'role', control: Backform.NodeListByNameControl, select2: { allowClear: false }, - },{ - id: 'acl', label: gettext('Privileges'), type: 'text', - group: gettext('Security'), mode: ['properties'], - },{ - id: 'tblacl', label: gettext('Default TABLE privileges'), type: 'text', - group: gettext('Security'), mode: ['properties'], - },{ - id: 'seqacl', label: gettext('Default SEQUENCE privileges'), type: 'text', - group: gettext('Security'), mode: ['properties'], - },{ - id: 'funcacl', label: gettext('Default FUNCTION privileges'), type: 'text', - group: gettext('Security'), mode: ['properties'], - },{ - id: 'typeacl', label: gettext('Default TYPE privileges'), type: 'text', - group: gettext('Security'), mode: ['properties'], min_version: 90200, - },{ - id: 'is_sys_obj', label: gettext('System database?'), - cell:'boolean', type: 'switch', mode: ['properties'], },{ id: 'comments', label: gettext('Comment'), editable: false, type: 'multiline', - },{ - id: 'encoding', label: gettext('Encoding'), - editable: false, type: 'text', group: gettext('Definition'), - readonly: function(m) { return !m.isNew(); }, url: 'get_encodings', - control: 'node-ajax-options', cache_level: 'server', - },{ - id: 'template', label: gettext('Template'), - editable: false, type: 'text', group: gettext('Definition'), - readonly: function(m) { return !m.isNew(); }, - control: 'node-list-by-name', url: 'get_databases', cache_level: 'server', - select2: { allowClear: false }, mode: ['create'], - transform: function(data, cell) { - var res = [], - control = cell || this, - label = control.model.get('name'); - - if (!control.model.isNew()) { - res.push({label: label, value: label}); - } - else { - if (data && _.isArray(data)) { - _.each(data, function(d) { - res.push({label: d, value: d, - image: 'pg-icon-database'}); - }); - } - } - return res; - }, - },{ - id: 'spcname', label: gettext('Tablespace'), - editable: false, type: 'text', group: gettext('Definition'), - control: 'node-list-by-name', node: 'tablespace', - select2: { allowClear: false }, - filter: function(m) { - return (m.label != 'pg_global'); - }, - },{ - id: 'datcollate', label: gettext('Collation'), - editable: false, type: 'text', group: gettext('Definition'), - readonly: function(m) { return !m.isNew(); }, url: 'get_ctypes', - control: 'node-ajax-options', cache_level: 'server', - },{ - id: 'datctype', label: gettext('Character type'), - editable: false, type: 'text', group: gettext('Definition'), - readonly: function(m) { return !m.isNew(); }, url: 'get_ctypes', - control: 'node-ajax-options', cache_level: 'server', - },{ - id: 'datconnlimit', label: gettext('Connection limit'), - editable: false, type: 'int', group: gettext('Definition'), min: -1, - },{ - id: 'is_template', label: gettext('Template?'), - editable: false, type: 'switch', group: gettext('Definition'), - readonly: true, mode: ['properties', 'edit'], - },{ - id: 'datallowconn', label: gettext('Allow connections?'), - editable: false, type: 'switch', group: gettext('Definition'), - mode: ['properties'], - },{ - id: 'datacl', label: gettext('Privileges'), type: 'collection', - model: pgBrowser.Node.PrivilegeRoleModel.extend({ - privileges: ['C', 'T', 'c'], - }), uniqueCol : ['grantee', 'grantor'], editable: false, - group: gettext('Security'), mode: ['edit', 'create'], - canAdd: true, canDelete: true, control: 'unique-col-collection', - },{ - id: 'variables', label: '', type: 'collection', - model: pgBrowser.Node.VariableModel.extend({keys:['name', 'role']}), editable: false, - group: gettext('Parameters'), mode: ['edit', 'create'], - canAdd: true, canEdit: false, canDelete: true, hasRole: true, - control: Backform.VariableCollectionControl, node: 'role', - },{ - id: 'seclabels', label: gettext('Security labels'), - model: pgBrowser.SecLabelModel, - editable: false, type: 'collection', canEdit: false, - group: gettext('Security'), canDelete: true, - mode: ['edit', 'create'], canAdd: true, - control: 'unique-col-collection', uniqueCol : ['provider'], - min_version: 90200, - },{ - type: 'nested', control: 'tab', group: gettext('Default Privileges'), - mode: ['edit'], - schema:[{ - id: 'deftblacl', model: pgBrowser.Node.PrivilegeRoleModel.extend( - {privileges: ['a', 'r', 'w', 'd', 'D', 'x', 't']}), label: '', - editable: false, type: 'collection', group: gettext('Tables'), - mode: ['edit', 'create'], control: 'unique-col-collection', - canAdd: true, canDelete: true, uniqueCol : ['grantee', 'grantor'], - },{ - id: 'defseqacl', model: pgBrowser.Node.PrivilegeRoleModel.extend( - {privileges: ['r', 'w', 'U']}), label: '', - editable: false, type: 'collection', group: gettext('Sequences'), - mode: ['edit', 'create'], control: 'unique-col-collection', - canAdd: true, canDelete: true, uniqueCol : ['grantee', 'grantor'], - },{ - id: 'deffuncacl', model: pgBrowser.Node.PrivilegeRoleModel.extend( - {privileges: ['X']}), label: '', - editable: false, type: 'collection', group: gettext('Functions'), - mode: ['edit', 'create'], control: 'unique-col-collection', - canAdd: true, canDelete: true, uniqueCol : ['grantee', 'grantor'], - },{ - id: 'deftypeacl', model: pgBrowser.Node.PrivilegeRoleModel.extend( - {privileges: ['U']}), label: '', - editable: false, type: 'collection', group: 'deftypesacl_group', - mode: ['edit', 'create'], control: 'unique-col-collection', - canAdd: true, canDelete: true, uniqueCol : ['grantee', 'grantor'], - min_version: 90200, - },{ - id: 'deftypesacl_group', type: 'group', label: gettext('Types'), - mode: ['edit', 'create'], min_version: 90200, - }, - ], - },{ - type: 'collection', group: gettext('Advanced'), - }, - { - id: 'schema_res', label: gettext('Schema restriction'), - type: 'select2', group: gettext('Advanced'), - mode: ['properties', 'edit', 'create'], - helpMessage: gettext('Note: Changes to the schema restriction will require the Schemas node in the browser to be refreshed before they will be shown.'), - select2: { - multiple: true, allowClear: false, tags: true, - tokenSeparators: [','], first_empty: false, - selectOnClose: true, emptyOptions: true, - }, - control: Backform.Select2Control.extend({ - onChange: function() { - Backform.Select2Control.prototype.onChange.apply(this, arguments); - if (!this.model || !( - this.model.changed && - this.model.get('oid') !== undefined - )) { - this.model.inform_text = undefined; - return; - } - - if(this.model.origSessAttrs.schema_res != this.model.changed.schema_res) - { - this.model.inform_text = gettext( - 'Please refresh the Schemas node to make changes to the schema restriction take effect.' - ); - } else { - this.model.inform_text = undefined; - } - }, - }), }, ], - validate: function() { - var name = this.get('name'); - if (_.isUndefined(name) || _.isNull(name) || - String(name).replace(/^\s+|\s+$/g, '') == '') { - var msg = gettext('Name cannot be empty.'); - this.errorModel.set('name', msg); - return msg; - } else { - this.errorModel.unset('name'); - } - return null; - }, }), }); diff --git a/web/pgadmin/browser/server_groups/servers/databases/static/js/database.ui.js b/web/pgadmin/browser/server_groups/servers/databases/static/js/database.ui.js new file mode 100644 index 000000000..ae97a8f68 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/databases/static/js/database.ui.js @@ -0,0 +1,207 @@ +import gettext from 'sources/gettext'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import SecLabelSchema from '../../../static/js/sec_label.ui'; + +export class DefaultPrivSchema extends BaseUISchema { + constructor(getPrivilegeRoleSchema) { + super(); + this.getPrivilegeRoleSchema = getPrivilegeRoleSchema; + } + + get fields() { + return [ + { + id: 'deftblacl', type: 'collection', group: gettext('Tables'), + schema: this.getPrivilegeRoleSchema(['a', 'r', 'w', 'd', 'D', 'x', 't']), + mode: ['edit', 'create'], + canAdd: true, canDelete: true, + uniqueCol : ['grantee', 'grantor'], + },{ + id: 'defseqacl', type: 'collection', group: gettext('Sequences'), + schema: this.getPrivilegeRoleSchema(['r', 'w', 'U']), + mode: ['edit', 'create'], + canAdd: true, canDelete: true, + uniqueCol : ['grantee', 'grantor'], + },{ + id: 'deffuncacl', type: 'collection', group: gettext('Functions'), + schema: this.getPrivilegeRoleSchema(['X']), + mode: ['edit', 'create'], + canAdd: true, canDelete: true, uniqueCol : ['grantee', 'grantor'], + },{ + id: 'deftypeacl', type: 'collection', group: gettext('Types'), + schema: this.getPrivilegeRoleSchema(['U']), min_version: 90200, + mode: ['edit', 'create'], + canAdd: true, canDelete: true, uniqueCol : ['grantee', 'grantor'], + }, + ]; + } +} + +export default class DatabaseSchema extends BaseUISchema { + constructor(getVariableSchema, getPrivilegeRoleSchema, fieldOptions={}, initValues) { + super({ + name: undefined, + owner: undefined, + is_sys_obj: undefined, + comment: undefined, + encoding: 'UTF8', + template: undefined, + tablespace: undefined, + collation: undefined, + char_type: undefined, + datconnlimit: -1, + datallowconn: undefined, + variables: [], + privileges: [], + securities: [], + datacl: [], + deftblacl: [], + deffuncacl: [], + defseqacl: [], + is_template: false, + deftypeacl: [], + schema_res:'', + ...initValues, + }); + this.getVariableSchema = getVariableSchema; + this.getPrivilegeRoleSchema = getPrivilegeRoleSchema; + this.fieldOptions = { + role: [], + encoding: [], + template: [], + spcname: [], + datcollate: [], + datctype: [], + ...fieldOptions, + }; + } + + get idAttribute() { + return 'did'; + } + + get fields() { + let obj = this; + return [ + { + id: 'name', label: gettext('Database'), cell: 'text', + editable: false, type: 'text', noEmpty: true, + },{ + id: 'did', label: gettext('OID'), cell: 'text', mode: ['properties'], + editable: false, type: 'text', + },{ + id: 'datowner', label: gettext('Owner'), + editable: false, type: 'select', options: this.fieldOptions.role, + controlProps: { allowClear: false }, + },{ + id: 'is_sys_obj', label: gettext('System database?'), + cell: 'switch', type: 'switch', mode: ['properties'], + },{ + id: 'comments', label: gettext('Comment'), + editable: false, type: 'multiline', + },{ + id: 'encoding', label: gettext('Encoding'), + editable: false, type: 'select', group: gettext('Definition'), + readonly: function(state) {return !obj.isNew(state); }, + options: this.fieldOptions.encoding, + },{ + id: 'template', label: gettext('Template'), + editable: false, type: 'select', group: gettext('Definition'), + readonly: function(state) {return !obj.isNew(state); }, + options: this.fieldOptions.template, + controlProps: { allowClear: false }, mode: ['create'], + },{ + id: 'spcname', label: gettext('Tablespace'), + editable: false, type: 'select', group: gettext('Definition'), + options: this.fieldOptions.spcname, + controlProps: { allowClear: false }, + },{ + id: 'datcollate', label: gettext('Collation'), + editable: false, type: 'select', group: gettext('Definition'), + readonly: function(state) {return !obj.isNew(state); }, + options: this.fieldOptions.datcollate, + },{ + id: 'datctype', label: gettext('Character type'), + editable: false, type: 'select', group: gettext('Definition'), + readonly: function(state) {return !obj.isNew(state); }, + options: this.fieldOptions.datctype, + },{ + id: 'datconnlimit', label: gettext('Connection limit'), + editable: false, type: 'int', group: gettext('Definition'), + min: -1, + },{ + id: 'is_template', label: gettext('Template?'), + editable: false, type: 'switch', group: gettext('Definition'), + readonly: true, mode: ['properties', 'edit'], + },{ + id: 'datallowconn', label: gettext('Allow connections?'), + editable: false, type: 'switch', group: gettext('Definition'), + mode: ['properties'], + },{ + id: 'acl', label: gettext('Privileges'), type: 'text', + group: gettext('Security'), mode: ['properties'], + },{ + id: 'tblacl', label: gettext('Default TABLE privileges'), type: 'text', + group: gettext('Security'), mode: ['properties'], + },{ + id: 'seqacl', label: gettext('Default SEQUENCE privileges'), type: 'text', + group: gettext('Security'), mode: ['properties'], + },{ + id: 'funcacl', label: gettext('Default FUNCTION privileges'), type: 'text', + group: gettext('Security'), mode: ['properties'], + },{ + id: 'typeacl', label: gettext('Default TYPE privileges'), type: 'text', + group: gettext('Security'), mode: ['properties'], min_version: 90200, + }, + { + id: 'datacl', label: gettext('Privileges'), type: 'collection', + schema: this.getPrivilegeRoleSchema(['C', 'T', 'c']), + uniqueCol : ['grantee', 'grantor'], + editable: false, + group: gettext('Security'), mode: ['edit', 'create'], + canAdd: true, canDelete: true, + }, + { + id: 'variables', label: '', type: 'collection', + schema: this.getVariableSchema(), + editable: false, + group: gettext('Parameters'), mode: ['edit', 'create'], + canAdd: true, canEdit: false, canDelete: true, hasRole: true, + node: 'role', + },{ + id: 'seclabels', label: gettext('Security labels'), type: 'collection', + schema: new SecLabelSchema(), + editable: false, group: gettext('Security'), + mode: ['edit', 'create'], + canAdd: true, canEdit: false, canDelete: true, + uniqueCol : ['provider'], + min_version: 90200, + },{ + type: 'nested-tab', group: gettext('Default Privileges'), + mode: ['edit'], + schema: new DefaultPrivSchema(this.getPrivilegeRoleSchema), + }, + { + id: 'schema_res', label: gettext('Schema restriction'), + type: 'select', group: gettext('Advanced'), + mode: ['properties', 'edit', 'create'], + helpMessage: gettext('Note: Changes to the schema restriction will require the Schemas node in the browser to be refreshed before they will be shown.'), + controlProps: { + multiple: true, allowClear: false, creatable: true, + }, depChange: (state)=>{ + if(!_.isUndefined(state.oid)) { + obj.informText = undefined; + } + + if(obj.origData.schema_res != state.schema_res) { + obj.informText = gettext( + 'Please refresh the Schemas node to make changes to the schema restriction take effect.' + ); + } else { + obj.informText = undefined; + } + }, + }, + ]; + } +} diff --git a/web/pgadmin/browser/server_groups/servers/static/js/privilege.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/privilege.ui.js new file mode 100644 index 000000000..0c0bd814e --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/static/js/privilege.ui.js @@ -0,0 +1,73 @@ +import gettext from 'sources/gettext'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { getNodeListByName } from '../../../../static/js/node_ajax'; + +export function getNodePrivilegeRoleSchema(nodeObj, treeNodeInfo, itemNodeData, privileges) { + let keys = ['grantee', 'privileges', 'grantor']; + return new PrivilegeRoleSchema( + ()=>getNodeListByName('role', treeNodeInfo, itemNodeData, ()=>true, (res)=>{ + res.unshift({label: 'PUBLIC', value: 'PUBLIC'}); + return res; + }), + ()=>getNodeListByName('role', treeNodeInfo, itemNodeData), + keys, + treeNodeInfo, + privileges + ); +} + +export default class PrivilegeRoleSchema extends BaseUISchema { + constructor(granteeOptions, grantorOptions, keys, nodeInfo, supportedPrivs) { + super({ + grantee: undefined, + grantor: nodeInfo?.server?.user?.name, + privileges: undefined, + }); + this.granteeOptions = granteeOptions; + this.grantorOptions = grantorOptions; + this.nodeInfo = nodeInfo; + this.supportedPrivs = supportedPrivs || []; + this.keys = keys; + } + + get baseFields() { + let obj = this; + + return [{ + id: 'grantee', label: gettext('Grantee'), type:'text', + editable: true, + cell: ()=>({ + cell: 'select', options: this.granteeOptions, + controlProps: { + allowClear: false, + } + }), + noEmpty: true, + }, + { + id: 'privileges', label: gettext('Privileges'), + type: 'text', group: null, + cell: ()=>({cell: 'privilege', controlProps: { + supportedPrivs: this.supportedPrivs, + }}), minWidth: 280, + disabled : function(state) { + return !( + obj.nodeInfo && + obj.nodeInfo.server.user.name == state['grantor'] + ); + }, + }, + { + id: 'grantor', label: gettext('Grantor'), type: 'text', readonly: true, + cell: ()=>({cell: 'select', options: obj.grantorOptions}), + }]; + } + + validate(state, setError) { + if((state.privileges || []).length <= 0) { + setError('privileges', gettext('At least one privilege should be selected.')); + return true; + } + return false; + } +} diff --git a/web/pgadmin/browser/server_groups/servers/static/js/sec_label.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/sec_label.ui.js new file mode 100644 index 000000000..4a29ab32a --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/static/js/sec_label.ui.js @@ -0,0 +1,23 @@ +import gettext from 'sources/gettext'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; + +export default class SecLabelSchema extends BaseUISchema { + constructor() { + super({ + provider: undefined, + label: undefined, + }); + this.keys = ['provider', 'label']; + } + + get baseFields() { + return [{ + id: 'provider', label: gettext('Provider'), + type: 'text', editable: true, cell: 'text', + }, + { + id: 'label', label: gettext('Security label'), + type: 'text', editable: true, cell: 'text', noEmpty: true, + }]; + } +} diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.js b/web/pgadmin/browser/server_groups/servers/static/js/server.js index ffd715f8e..8086dd408 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.js @@ -7,23 +7,22 @@ // ////////////////////////////////////////////////////////////// +import { getNodeListById } from '../../../../static/js/node_ajax'; +import ServerSchema from './server.ui'; + define('pgadmin.node.server', [ 'sources/gettext', 'sources/url_for', 'jquery', 'underscore', 'backbone', 'sources/pgadmin', 'pgadmin.browser', - 'pgadmin.server.supported_servers', 'pgadmin.user_management.current_user', + 'pgadmin.user_management.current_user', 'pgadmin.alertifyjs', 'pgadmin.backform', - 'sources/browser/server_groups/servers/model_validation', 'pgadmin.authenticate.kerberos', 'pgadmin.browser.server.privilege', ], function( gettext, url_for, $, _, Backbone, pgAdmin, pgBrowser, - supported_servers, current_user, Alertify, Backform, - modelValidation, Kerberos, + current_user, Alertify, Backform, Kerberos, ) { if (!pgBrowser.Nodes['server']) { - var SSL_MODES = ['prefer', 'require', 'verify-ca', 'verify-full']; - pgBrowser.SecLabelModel = pgBrowser.Node.Model.extend({ defaults: { provider: undefined, @@ -738,469 +737,15 @@ define('pgadmin.node.server', [ pgBrowser.psql.psql_tool(d, i, true); } }, - model: pgAdmin.Browser.Node.Model.extend({ - defaults: { - gid: undefined, - id: undefined, - name: '', - sslmode: 'prefer', - host: '', - hostaddr: '', - port: 5432, - db: 'postgres', - username: current_user.name, - role: null, - connect_now: true, - password: undefined, - save_password: false, - db_res: '', - passfile: undefined, - sslcompression: false, - sslcert: undefined, - sslkey: undefined, - sslrootcert: undefined, - sslcrl: undefined, - service: undefined, - use_ssh_tunnel: 0, - tunnel_host: undefined, - tunnel_port: 22, - tunnel_username: undefined, - tunnel_identity_file: undefined, - tunnel_password: undefined, - tunnel_authentication: 0, - save_tunnel_password: false, - connect_timeout: 10, - }, - // Default values! - initialize: function(attrs, args) { - var isNew = (_.size(attrs) === 0); - - if (isNew) { - this.set({'gid': args.node_info['server_group']._id}); - } - pgAdmin.Browser.Node.Model.prototype.initialize.apply(this, arguments); - }, - schema: [{ - id: 'id', label: gettext('ID'), type: 'int', mode: ['properties'], - visible: function(model){ - if (model.attributes.user_id != current_user.id && pgAdmin.server_mode == 'True') - return false; - return true; - }, - },{ - id: 'name', label: gettext('Name'), type: 'text', - mode: ['properties', 'edit', 'create'], disabled: 'isShared', - }, - { - id: 'gid', label: gettext('Server group'), type: 'int', - control: 'node-list-by-id', node: 'server_group', - mode: ['create', 'edit'], select2: {allowClear: false}, disabled: 'isShared', - }, - { - id: 'server_owner', label: gettext('Shared Server Owner'), type: 'text', mode: ['properties'], - visible:function(model){ - var serverOwner = model.attributes.user_id; - if (model.attributes.shared && serverOwner != current_user.id && pgAdmin.server_mode == 'True'){ - return true; - } - return false; - - }, - }, - { - id: 'server_type', label: gettext('Server type'), type: 'options', - mode: ['properties'], visible: 'isConnected', - 'options': supported_servers, - },{ - id: 'connected', label: gettext('Connected?'), type: 'switch', - mode: ['properties'], group: gettext('Connection'), 'options': { - 'onText': gettext('True'), 'offText': gettext('False'), 'size': 'mini', - }, - },{ - id: 'version', label: gettext('Version'), type: 'text', group: null, - mode: ['properties'], visible: 'isConnected', - },{ - id: 'bgcolor', label: gettext('Background'), type: 'color', - group: null, mode: ['edit', 'create'], disabled: 'isfgColorSet', - deps: ['fgcolor'], - },{ - id: 'fgcolor', label: gettext('Foreground'), type: 'color', - group: null, mode: ['edit', 'create'], disabled: 'isConnected', - },{ - id: 'connect_now', controlLabel: gettext('Connect now?'), type: 'checkbox', - group: null, mode: ['create'], - },{ - id: 'shared', label: gettext('Shared?'), type: 'switch', - mode: ['properties', 'create', 'edit'], 'options': {'size': 'mini'}, - readonly: function(model){ - var serverOwner = model.attributes.user_id; - if (!model.isNew() && serverOwner != current_user.id){ - return true; - } - return false; - },visible: function(){ - if (current_user.is_admin && pgAdmin.server_mode == 'True') - return true; - - return false; - }, - }, - { - id: 'comment', label: gettext('Comments'), type: 'multiline', group: null, - mode: ['properties', 'edit', 'create'], - },{ - id: 'host', label: gettext('Host name/address'), type: 'text', group: gettext('Connection'), - mode: ['properties', 'edit', 'create'],disabled: 'isShared', - control: Backform.InputControl.extend({ - onChange: function() { - Backform.InputControl.prototype.onChange.apply(this, arguments); - if (!this.model || !this.model.changed) { - this.model.inform_text = undefined; - return; - } - - if(this.model.origSessAttrs.host != this.model.changed.host && !this.model.isNew() && this.model.get('connected')) - { - this.model.inform_text = gettext( - 'To apply changes to the connection configuration, please disconnect from the server and then reconnect.' - ); - } else { - this.model.inform_text = undefined; - } - }, - }), - },{ - id: 'port', label: gettext('Port'), type: 'int', group: gettext('Connection'), - mode: ['properties', 'edit', 'create'], min: 1, max: 65535, disabled: 'isShared', - control: Backform.InputControl.extend({ - onChange: function() { - Backform.InputControl.prototype.onChange.apply(this, arguments); - if (!this.model || !this.model.changed) { - this.model.inform_text = undefined; - return; - } - - if(this.model.origSessAttrs.port != this.model.changed.port && !this.model.isNew() && this.model.get('connected')) - { - this.model.inform_text = gettext( - 'To apply changes to the connection configuration, please disconnect from the server and then reconnect.' - ); - } else { - this.model.inform_text = undefined; - } - }, - }), - },{ - id: 'db', label: gettext('Maintenance database'), type: 'text', group: gettext('Connection'), - mode: ['properties', 'edit', 'create'], readonly: 'isConnected',disabled: 'isShared', - },{ - id: 'username', label: gettext('Username'), type: 'text', group: gettext('Connection'), - mode: ['properties', 'edit', 'create'], - control: Backform.InputControl.extend({ - onChange: function() { - Backform.InputControl.prototype.onChange.apply(this, arguments); - if (!this.model || !this.model.changed) { - this.model.inform_text = undefined; - return; - } - - if(this.model.origSessAttrs.username != this.model.changed.username && !this.model.isNew() && this.model.get('connected')) - { - this.model.inform_text = gettext( - 'To apply changes to the connection configuration, please disconnect from the server and then reconnect.' - ); - } else { - this.model.inform_text = undefined; - } - }, - }), - },{ - id: 'kerberos_conn', label: gettext('Kerberos authentication?'), type: 'switch', - group: gettext('Connection'), 'options': { - 'onText': gettext('True'), 'offText': gettext('False'), 'size': 'mini', - } - },{ - id: 'gss_authenticated', label: gettext('GSS authenticated?'), type: 'switch', - group: gettext('Connection'), 'options': { - 'onText': gettext('True'), 'offText': gettext('False'), 'size': 'mini', - }, mode: ['properties'], visible: 'isConnected' - },{ - id: 'gss_encrypted', label: gettext('GSS encrypted?'), type: 'switch', - group: gettext('Connection'), 'options': { - 'onText': gettext('True'), 'offText': gettext('False'), 'size': 'mini', - }, mode: ['properties'], visible: 'isConnected', - },{ - id: 'password', label: gettext('Password'), type: 'password', maxlength: null, - group: gettext('Connection'), control: 'input', mode: ['create'], - deps: ['connect_now', 'kerberos_conn'], - visible: function(model) { - return model.get('connect_now') && model.isNew(); - }, - disabled: function(model) { - if (model.get('kerberos_conn')) - return true; - - return false; - }, - },{ - id: 'save_password', controlLabel: gettext('Save password?'), - type: 'checkbox', group: gettext('Connection'), mode: ['create'], - deps: ['connect_now', 'kerberos_conn'], visible: function(model) { - return model.get('connect_now') && model.isNew(); - }, - disabled: function(model) { - if (!current_user.allow_save_password || model.get('kerberos_conn')) - return true; - - return false; - }, - },{ - id: 'role', label: gettext('Role'), type: 'text', group: gettext('Connection'), - mode: ['properties', 'edit', 'create'], readonly: 'isConnected', - },{ - id: 'service', label: gettext('Service'), type: 'text', - mode: ['properties', 'edit', 'create'], readonly: 'isConnected', - group: gettext('Connection'), - },{ - id: 'sslmode', label: gettext('SSL mode'), control: 'select2', group: gettext('SSL'), - select2: { - allowClear: false, - minimumResultsForSearch: Infinity, - }, - mode: ['properties', 'edit', 'create'], disabled: 'isConnected', - 'options': [ - {label: gettext('Allow'), value: 'allow'}, - {label: gettext('Prefer'), value: 'prefer'}, - {label: gettext('Require'), value: 'require'}, - {label: gettext('Disable'), value: 'disable'}, - {label: gettext('Verify-CA'), value: 'verify-ca'}, - {label: gettext('Verify-Full'), value: 'verify-full'}, - ], - },{ - id: 'sslcert', label: gettext('Client certificate'), type: 'text', - group: gettext('SSL'), mode: ['edit', 'create'], - disabled: 'isSSL', readonly: 'isConnected', control: Backform.FileControl, - dialog_type: 'select_file', supp_types: ['*'], - deps: ['sslmode'], - },{ - id: 'sslkey', label: gettext('Client certificate key'), type: 'text', - group: gettext('SSL'), mode: ['edit', 'create'], - disabled: 'isSSL', readonly: 'isConnected', control: Backform.FileControl, - dialog_type: 'select_file', supp_types: ['*'], - deps: ['sslmode'], - },{ - id: 'sslrootcert', label: gettext('Root certificate'), type: 'text', - group: gettext('SSL'), mode: ['edit', 'create'], - disabled: 'isSSL', readonly: 'isConnected', control: Backform.FileControl, - dialog_type: 'select_file', supp_types: ['*'], - deps: ['sslmode'], - },{ - id: 'sslcrl', label: gettext('Certificate revocation list'), type: 'text', - group: gettext('SSL'), mode: ['edit', 'create'], - disabled: 'isSSL', readonly: 'isConnected', control: Backform.FileControl, - dialog_type: 'select_file', supp_types: ['*'], - deps: ['sslmode'], - },{ - id: 'sslcompression', label: gettext('SSL compression?'), type: 'switch', - mode: ['edit', 'create'], group: gettext('SSL'), - 'options': {'size': 'mini'}, - deps: ['sslmode'], disabled: 'isSSL', readonly: 'isConnected', - },{ - id: 'sslcert', label: gettext('Client certificate'), type: 'text', - group: gettext('SSL'), mode: ['properties'], - deps: ['sslmode'], - visible: function(model) { - var sslcert = model.get('sslcert'); - return !_.isUndefined(sslcert) && !_.isNull(sslcert); - }, - },{ - id: 'sslkey', label: gettext('Client certificate key'), type: 'text', - group: gettext('SSL'), mode: ['properties'], - deps: ['sslmode'], - visible: function(model) { - var sslkey = model.get('sslkey'); - return !_.isUndefined(sslkey) && !_.isNull(sslkey); - }, - },{ - id: 'sslrootcert', label: gettext('Root certificate'), type: 'text', - group: gettext('SSL'), mode: ['properties'], - deps: ['sslmode'], - visible: function(model) { - var sslrootcert = model.get('sslrootcert'); - return !_.isUndefined(sslrootcert) && !_.isNull(sslrootcert); - }, - },{ - id: 'sslcrl', label: gettext('Certificate revocation list'), type: 'text', - group: gettext('SSL'), mode: ['properties'], - deps: ['sslmode'], - visible: function(model) { - var sslcrl = model.get('sslcrl'); - return !_.isUndefined(sslcrl) && !_.isNull(sslcrl); - }, - },{ - id: 'sslcompression', label: gettext('SSL compression?'), type: 'switch', - mode: ['properties'], group: gettext('SSL'), - 'options': {'size': 'mini'}, - deps: ['sslmode'], visible: function(model) { - var sslmode = model.get('sslmode'); - return _.indexOf(SSL_MODES, sslmode) != -1; - }, - },{ - id: 'use_ssh_tunnel', label: gettext('Use SSH tunneling'), type: 'switch', - mode: ['properties', 'edit', 'create'], group: gettext('SSH Tunnel'), - 'options': {'size': 'mini'}, - disabled: function(model) { - if (!pgAdmin.Browser.utils.support_ssh_tunnel) { - setTimeout(function() { - model.set('use_ssh_tunnel', 0); - }, 10); - - return true; - } - - return false; - }, - readonly: 'isConnected', - },{ - id: 'tunnel_host', label: gettext('Tunnel host'), type: 'text', group: gettext('SSH Tunnel'), - mode: ['properties', 'edit', 'create'], deps: ['use_ssh_tunnel'], - disabled: function(model) { - return !model.get('use_ssh_tunnel'); - }, - readonly: 'isConnected', - },{ - id: 'tunnel_port', label: gettext('Tunnel port'), type: 'int', group: gettext('SSH Tunnel'), - mode: ['properties', 'edit', 'create'], deps: ['use_ssh_tunnel'], max: 65535, - disabled: function(model) { - return !model.get('use_ssh_tunnel'); - }, - readonly: 'isConnected', - },{ - id: 'tunnel_username', label: gettext('Username'), type: 'text', group: gettext('SSH Tunnel'), - mode: ['properties', 'edit', 'create'], deps: ['use_ssh_tunnel'], - disabled: function(model) { - return !model.get('use_ssh_tunnel'); - }, - readonly: 'isConnected', - },{ - id: 'tunnel_authentication', label: gettext('Authentication'), type: 'switch', - mode: ['properties', 'edit', 'create'], group: gettext('SSH Tunnel'), - 'options': {'onText': gettext('Identity file'), - 'offText': gettext('Password'), 'size': 'mini', width: '90'}, - deps: ['use_ssh_tunnel'], - disabled: function(model) { - return !model.get('use_ssh_tunnel'); - }, - readonly: 'isConnected', - }, { - id: 'tunnel_identity_file', label: gettext('Identity file'), type: 'text', - group: gettext('SSH Tunnel'), mode: ['properties', 'edit', 'create'], - control: Backform.FileControl, dialog_type: 'select_file', supp_types: ['*'], - deps: ['tunnel_authentication', 'use_ssh_tunnel'], - disabled: function(model) { - let file = model.get('tunnel_identity_file'); - if (!model.get('tunnel_authentication') && file) { - setTimeout(function() { - model.set('tunnel_identity_file', null); - }, 10); - } - return !model.get('tunnel_authentication') || !model.get('use_ssh_tunnel'); - }, - },{ - id: 'tunnel_password', label: gettext('Password'), type: 'password', - group: gettext('SSH Tunnel'), control: 'input', mode: ['create'], - deps: ['use_ssh_tunnel'], - disabled: function(model) { - return !model.get('use_ssh_tunnel'); - }, - readonly: 'isConnected', - }, { - id: 'save_tunnel_password', controlLabel: gettext('Save password?'), - type: 'checkbox', group: gettext('SSH Tunnel'), mode: ['create'], - deps: ['connect_now', 'use_ssh_tunnel'], visible: function(model) { - return model.get('connect_now') && model.isNew(); - }, - disabled: function(model) { - if (!current_user.allow_save_tunnel_password || - !model.get('use_ssh_tunnel')) - return true; - - return false; - }, - }, { - id: 'hostaddr', label: gettext('Host address'), type: 'text', group: gettext('Advanced'), - mode: ['properties', 'edit', 'create'], readonly: 'isConnected', - },{ - id: 'db_res', label: gettext('DB restriction'), type: 'select2', group: gettext('Advanced'), - mode: ['properties', 'edit', 'create'], readonly: 'isConnected', select2: {multiple: true, allowClear: false, - tags: true, tokenSeparators: [','], first_empty: false, selectOnClose: true, emptyOptions: true}, - },{ - id: 'passfile', label: gettext('Password file'), type: 'text', - group: gettext('Advanced'), mode: ['edit', 'create'], - disabled: 'isValidLib', readonly: 'isConnected', control: Backform.FileControl, - dialog_type: 'select_file', supp_types: ['*'], - },{ - id: 'passfile', label: gettext('Password file'), type: 'text', - group: gettext('Advanced'), mode: ['properties'], - visible: function(model) { - var passfile = model.get('passfile'); - return !_.isUndefined(passfile) && !_.isNull(passfile); - }, - },{ - id: 'connect_timeout', label: gettext('Connection timeout (seconds)'), - type: 'int', group: gettext('Advanced'), - mode: ['properties', 'edit', 'create'], readonly: 'isConnected', - min: 0, - }], - isVisible: function(model){ - var serverOwner = model.attributes.user_id; - if (!model.isNew() && serverOwner != current_user.id){ - return false; + getSchema: (treeNodeInfo, itemNodeData)=>{ + let schema = new ServerSchema( + getNodeListById(pgBrowser.Nodes['server_group'], treeNodeInfo, itemNodeData), + { + gid: treeNodeInfo['server_group']._id, } - return true; - - }, - isShared: function(model){ - var serverOwner = model.attributes.user_id; - if (!model.isNew() && serverOwner != current_user.id && model.attributes.shared){ - return true; - } - return false; - }, - validate: function() { - const validateModel = new modelValidation.ModelValidation(this); - return validateModel.validate(); - }, - isConnected: function(model) { - return model.get('connected'); - }, - isfgColorSet: function(model) { - var bgcolor = model.get('bgcolor'), - fgcolor = model.get('fgcolor'); - - if(model.get('connected')) { - return true; - } - // If fgcolor is set and bgcolor is not set then force bgcolor - // to set as white - if(_.isUndefined(bgcolor) || _.isNull(bgcolor) || !bgcolor) { - if(fgcolor) { - model.set('bgcolor', '#ffffff'); - } - } - - return false; - }, - isSSL: function(model) { - var ssl_mode = model.get('sslmode'); - return _.indexOf(SSL_MODES, ssl_mode) == -1; - }, - isValidLib: function() { - // older version of libpq do not support 'passfile' parameter in - // connect method, valid libpq must have version >= 100000 - return pgBrowser.utils.pg_libpq_version < 100000; - }, - }), + ); + return schema; + }, connection_lost: function(i, resp) { if (pgBrowser.tree) { var t = pgBrowser.tree, diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js new file mode 100644 index 000000000..afff409a9 --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js @@ -0,0 +1,529 @@ +import gettext from 'sources/gettext'; +import _ from 'lodash'; +import {Address4, Address6} from 'ip-address'; + +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import pgAdmin from 'sources/pgadmin'; +import {default as supportedServers} from 'pgadmin.server.supported_servers'; + +import current_user from 'pgadmin.user_management.current_user'; +import { isEmptyString } from 'sources/validators'; + +export default class ServerSchema extends BaseUISchema { + constructor(serverGroupOptions=[], initValues) { + super({ + gid: undefined, + id: undefined, + name: '', + bgcolor: '', + fgcolor: '', + sslmode: 'prefer', + host: '', + hostaddr: '', + port: 5432, + db: 'postgres', + username: current_user.name, + role: null, + connect_now: true, + password: undefined, + save_password: false, + db_res: '', + passfile: undefined, + sslcompression: false, + sslcert: undefined, + sslkey: undefined, + sslrootcert: undefined, + sslcrl: undefined, + service: undefined, + use_ssh_tunnel: 0, + tunnel_host: undefined, + tunnel_port: 22, + tunnel_username: undefined, + tunnel_identity_file: undefined, + tunnel_password: undefined, + tunnel_authentication: false, + save_tunnel_password: false, + connect_timeout: 10, + ...initValues, + }); + + this.serverGroupOptions = serverGroupOptions; + _.bindAll(this, 'isShared', 'isSSL'); + } + + get SSL_MODES() { return ['prefer', 'require', 'verify-ca', 'verify-full']; } + + isShared(state) { + if(!this.isNew(state) && state.user_id != current_user.id && state.shared) { + return true; + } + return false; + } + + isConnected(state) { + return Boolean(state.connected); + } + + isSSL(state) { + return this.SSL_MODES.indexOf(state.sslmode) == -1; + } + + isValidLib() { + // older version of libpq do not support 'passfile' parameter in + // connect method, valid libpq must have version >= 100000 + return pgAdmin.Browser.utils.pg_libpq_version < 100000; + } + + get baseFields() { + let obj = this; + return [ + { + id: 'id', label: gettext('ID'), type: 'int', group: null, + mode: ['properties'], + },{ + id: 'name', label: gettext('Name'), type: 'text', group: null, + mode: ['properties', 'edit', 'create'], noEmpty: true, + disabled: obj.isShared, + },{ + id: 'gid', label: gettext('Server group'), type: 'select', + options: obj.serverGroupOptions, + mode: ['create', 'edit'], + controlProps: { allowClear: false }, + disabled: obj.isShared, + }, + { + id: 'server_owner', label: gettext('Shared Server Owner'), type: 'text', mode: ['properties'], + visible: function(state) { + var serverOwner = state.user_id; + if (state.shared && serverOwner != current_user.id && pgAdmin.server_mode == 'True'){ + return true; + } + return false; + }, + }, + { + id: 'server_type', label: gettext('Server type'), type: 'select', + mode: ['properties'], visible: obj.isConnected, + options: supportedServers, + }, { + id: 'connected', label: gettext('Connected?'), type: 'switch', + mode: ['properties'], group: gettext('Connection'), + }, { + id: 'version', label: gettext('Version'), type: 'text', group: null, + mode: ['properties'], visible: obj.isConnected, + }, + { + id: 'bgcolor', label: gettext('Background'), type: 'color', + group: null, mode: ['edit', 'create'], + disabled: obj.isConnected, deps: ['fgcolor'], depChange: (state)=>{ + if(!state.bgcolor && state.fgcolor) { + return {'bgcolor': '#ffffff'}; + } + } + },{ + id: 'fgcolor', label: gettext('Foreground'), type: 'color', + group: null, mode: ['edit', 'create'], disabled: obj.isConnected, + }, + { + id: 'connect_now', label: gettext('Connect now?'), type: 'switch', + group: null, mode: ['create'], + }, + { + id: 'shared', label: gettext('Shared?'), type: 'switch', + mode: ['properties', 'create', 'edit'], + readonly: function(state){ + var serverOwner = state.user_id; + if (obj.isNew(state) && serverOwner != current_user.id) { + return true; + } + return false; + }, visible: function(){ + if (current_user.is_admin && pgAdmin.server_mode == 'True') + return true; + + return false; + }, + }, + { + id: 'comment', label: gettext('Comments'), type: 'multiline', group: null, + mode: ['properties', 'edit', 'create'], + }, + { + id: 'host', label: gettext('Host name/address'), type: 'text', group: gettext('Connection'), + mode: ['properties', 'edit', 'create'], disabled: obj.isShared, + depChange: (state)=>{ + if(obj.origData.host != state.host && !obj.isNew(state) && state.connected){ + obj.informText = gettext( + 'To apply changes to the connection configuration, please disconnect from the server and then reconnect.' + ); + } else { + obj.informText = undefined; + } + } + }, + { + id: 'port', label: gettext('Port'), type: 'int', group: gettext('Connection'), + mode: ['properties', 'edit', 'create'], min: 1, max: 65535, disabled: obj.isShared, + depChange: (state)=>{ + if(obj.origData.port != state.port && !obj.isNew(state) && state.connected){ + obj.informText = gettext( + 'To apply changes to the connection configuration, please disconnect from the server and then reconnect.' + ); + } else { + obj.informText = undefined; + } + } + },{ + id: 'db', label: gettext('Maintenance database'), type: 'text', group: gettext('Connection'), + mode: ['properties', 'edit', 'create'], readonly: obj.isConnected, disabled: obj.isShared, + noEmpty: true, + },{ + id: 'username', label: gettext('Username'), type: 'text', group: gettext('Connection'), + mode: ['properties', 'edit', 'create'], + depChange: (state)=>{ + if(obj.origData.username != state.username && !obj.isNew(state) && state.connected){ + obj.informText = gettext( + 'To apply changes to the connection configuration, please disconnect from the server and then reconnect.' + ); + } else { + obj.informText = undefined; + } + } + },{ + id: 'kerberos_conn', label: gettext('Kerberos authentication?'), type: 'switch', + group: gettext('Connection'), + },{ + id: 'gss_authenticated', label: gettext('GSS authenticated?'), type: 'switch', + group: gettext('Connection'), mode: ['properties'], visible: obj.isConnected, + },{ + id: 'gss_encrypted', label: gettext('GSS encrypted?'), type: 'switch', + group: gettext('Connection'), mode: ['properties'], visible: obj.isConnected, + },{ + id: 'password', label: gettext('Password'), type: 'password', maxlength: null, + group: gettext('Connection'), + mode: ['create'], + deps: ['connect_now', 'kerberos_conn'], + visible: function(state) { + return state.connect_now && obj.isNew(state); + }, + disabled: function(state) {return state.kerberos_conn;}, + },{ + id: 'save_password', label: gettext('Save password?'), + type: 'switch', group: gettext('Connection'), mode: ['create'], + deps: ['connect_now', 'kerberos_conn'], + visible: function(state) { + return state.connect_now && obj.isNew(state); + }, + disabled: function(state) { + if (!current_user.allow_save_password || state.kerberos_conn) + return true; + return false; + }, + },{ + id: 'role', label: gettext('Role'), type: 'text', group: gettext('Connection'), + mode: ['properties', 'edit', 'create'], readonly: obj.isConnected, + },{ + id: 'service', label: gettext('Service'), type: 'text', + mode: ['properties', 'edit', 'create'], readonly: obj.isConnected, + group: gettext('Connection'), + }, + { + id: 'sslmode', label: gettext('SSL mode'), type: 'select', group: gettext('SSL'), + controlProps: { + allowClear: false, + }, + mode: ['properties', 'edit', 'create'], disabled: obj.isConnected, + options: [ + {label: gettext('Allow'), value: 'allow'}, + {label: gettext('Prefer'), value: 'prefer'}, + {label: gettext('Require'), value: 'require'}, + {label: gettext('Disable'), value: 'disable'}, + {label: gettext('Verify-CA'), value: 'verify-ca'}, + {label: gettext('Verify-Full'), value: 'verify-full'}, + ], + }, + { + id: 'sslcert', label: gettext('Client certificate'), type: 'file', + group: gettext('SSL'), mode: ['edit', 'create'], + disabled: obj.isSSL, readonly: obj.isConnected, + controlProps: { + dialogType: 'select_file', supportedTypes: ['*'], + }, + deps: ['sslmode'], + }, + { + id: 'sslkey', label: gettext('Client certificate key'), type: 'file', + group: gettext('SSL'), mode: ['edit', 'create'], + disabled: obj.isSSL, readonly: obj.isConnected, + controlProps: { + dialogType: 'select_file', supportedTypes: ['*'], + }, + deps: ['sslmode'], + },{ + id: 'sslrootcert', label: gettext('Root certificate'), type: 'file', + group: gettext('SSL'), mode: ['edit', 'create'], + disabled: obj.isSSL, readonly: obj.isConnected, + controlProps: { + dialogType: 'select_file', supportedTypes: ['*'], + }, + deps: ['sslmode'], + },{ + id: 'sslcrl', label: gettext('Certificate revocation list'), type: 'file', + group: gettext('SSL'), mode: ['edit', 'create'], + disabled: obj.isSSL, readonly: obj.isConnected, + controlProps: { + dialogType: 'select_file', supportedTypes: ['*'], + }, + deps: ['sslmode'], + }, + { + id: 'sslcompression', label: gettext('SSL compression?'), type: 'switch', + mode: ['edit', 'create'], group: gettext('SSL'), + disabled: obj.isSSL, readonly: obj.isConnected, + deps: ['sslmode'], + }, + { + id: 'sslcert', label: gettext('Client certificate'), type: 'text', + group: gettext('SSL'), mode: ['properties'], + deps: ['sslmode'], + visible: function(state) { + var sslcert = state.sslcert; + return !_.isUndefined(sslcert) && !_.isNull(sslcert); + }, + },{ + id: 'sslkey', label: gettext('Client certificate key'), type: 'text', + group: gettext('SSL'), mode: ['properties'], + deps: ['sslmode'], + visible: function(state) { + var sslkey = state.sslkey; + return !_.isUndefined(sslkey) && !_.isNull(sslkey); + }, + },{ + id: 'sslrootcert', label: gettext('Root certificate'), type: 'text', + group: gettext('SSL'), mode: ['properties'], + deps: ['sslmode'], + visible: function(state) { + var sslrootcert = state.sslrootcert; + return !_.isUndefined(sslrootcert) && !_.isNull(sslrootcert); + }, + },{ + id: 'sslcrl', label: gettext('Certificate revocation list'), type: 'text', + group: gettext('SSL'), mode: ['properties'], + deps: ['sslmode'], + visible: function(state) { + var sslcrl = state.sslcrl; + return !_.isUndefined(sslcrl) && !_.isNull(sslcrl); + }, + },{ + id: 'sslcompression', label: gettext('SSL compression?'), type: 'switch', + mode: ['properties'], group: gettext('SSL'), + deps: ['sslmode'], + visible: function(state) { + return _.indexOf(obj.SSL_MODES, state.sslmode) != -1; + }, + }, + { + id: 'use_ssh_tunnel', label: gettext('Use SSH tunneling'), type: 'switch', + mode: ['properties', 'edit', 'create'], group: gettext('SSH Tunnel'), + disabled: function() { + return !pgAdmin.Browser.utils.support_ssh_tunnel; + }, + readonly: obj.isConnected, + },{ + id: 'tunnel_host', label: gettext('Tunnel host'), type: 'text', group: gettext('SSH Tunnel'), + mode: ['properties', 'edit', 'create'], deps: ['use_ssh_tunnel'], + disabled: function(state) { + return !state.use_ssh_tunnel; + }, + readonly: obj.isConnected, + },{ + id: 'tunnel_port', label: gettext('Tunnel port'), type: 'int', group: gettext('SSH Tunnel'), + mode: ['properties', 'edit', 'create'], deps: ['use_ssh_tunnel'], max: 65535, + disabled: function(state) { + return !state.use_ssh_tunnel; + }, + readonly: obj.isConnected, + },{ + id: 'tunnel_username', label: gettext('Username'), type: 'text', group: gettext('SSH Tunnel'), + mode: ['properties', 'edit', 'create'], deps: ['use_ssh_tunnel'], + disabled: function(state) { + return !state.use_ssh_tunnel; + }, + readonly: obj.isConnected, + },{ + id: 'tunnel_authentication', label: gettext('Authentication'), type: 'toggle', + mode: ['properties', 'edit', 'create'], group: gettext('SSH Tunnel'), + options: [ + {'label': gettext('Password'), value: false}, + {'label': gettext('Identity file'), value: true}, + ], + disabled: function(state) { + return !state.use_ssh_tunnel; + }, + readonly: obj.isConnected, + }, + { + id: 'tunnel_identity_file', label: gettext('Identity file'), type: 'file', + group: gettext('SSH Tunnel'), mode: ['properties', 'edit', 'create'], + controlProps: { + dialogType: 'select_file', supportedTypes: ['*'], + }, + deps: ['tunnel_authentication', 'use_ssh_tunnel'], + depChange: (state)=>{ + if (!state.tunnel_authentication && state.tunnel_identity_file) { + return {tunnel_identity_file: null}; + } + }, + disabled: function(state) { + return !state.tunnel_authentication || !state.use_ssh_tunnel; + }, + }, + { + id: 'tunnel_password', label: gettext('Password'), type: 'password', + group: gettext('SSH Tunnel'), mode: ['create'], + deps: ['use_ssh_tunnel'], + disabled: function(state) { + return !state.use_ssh_tunnel; + }, + readonly: obj.isConnected, + }, { + id: 'save_tunnel_password', label: gettext('Save password?'), + type: 'switch', group: gettext('SSH Tunnel'), mode: ['create'], + deps: ['connect_now', 'use_ssh_tunnel'], + visible: function(state) { + return state.connect_now && obj.isNew(state); + }, + disabled: function(state) { + return (!current_user.allow_save_tunnel_password || !state.use_ssh_tunnel); + }, + }, { + id: 'hostaddr', label: gettext('Host address'), type: 'text', group: gettext('Advanced'), + mode: ['properties', 'edit', 'create'], readonly: obj.isConnected, + }, + { + id: 'db_res', label: gettext('DB restriction'), type: 'select', group: gettext('Advanced'), + options: [], + mode: ['properties', 'edit', 'create'], readonly: obj.isConnected, controlProps: { + multiple: true, allowClear: false, creatable: true}, + }, + { + id: 'passfile', label: gettext('Password file'), type: 'file', + group: gettext('Advanced'), mode: ['edit', 'create'], + disabled: obj.isValidLib, readonly: obj.isConnected, + controlProps: { + dialogType: 'select_file', supportedTypes: ['*'], + }, + }, + { + id: 'passfile', label: gettext('Password file'), type: 'text', + group: gettext('Advanced'), mode: ['properties'], + visible: function(state) { + var passfile = state.passfile; + return !_.isUndefined(passfile) && !_.isNull(passfile); + }, + },{ + id: 'connect_timeout', label: gettext('Connection timeout (seconds)'), + type: 'int', group: gettext('Advanced'), + mode: ['properties', 'edit', 'create'], readonly: obj.isConnected, + min: 0, + } + ]; + } + + validate(state, setError) { + let errmsg = null; + + if (isEmptyString(state.service)) { + errmsg = gettext('Either Host name, Address or Service must be specified.'); + if(isEmptyString(state.host) && isEmptyString(state.hostaddr)) { + setError('host', errmsg); + return true; + } else { + errmsg = null; + setError('host', errmsg); + setError('hostaddr', errmsg); + } + + /* IP address validate */ + if (state.hostaddr) { + try { + new Address4(state.hostaddr); + } catch(e) { + try { + new Address6(state.hostaddr); + } catch(ex) { + errmsg = gettext('Host address must be valid IPv4 or IPv6 address.'); + setError('hostaddr', errmsg); + return true; + } + } + } else { + setError('hostaddr', null); + } + + if(isEmptyString(state.username)) { + errmsg = gettext('Username must be specified.'); + setError('username', errmsg); + return true; + } else { + errmsg = null; + setError('username', errmsg); + } + + if(isEmptyString(state.port)) { + errmsg = gettext('Port must be specified.'); + setError('port', errmsg); + return true; + } else { + errmsg = null; + setError('port', errmsg); + } + } else { + errmsg = null; + _.each(['host', 'hostaddr', 'db', 'username', 'port'], (item) => { + setError(item, errmsg); + }); + } + + if (state.use_ssh_tunnel) { + if(isEmptyString(state.tunnel_host)) { + errmsg = gettext('SSH Tunnel host must be specified.'); + setError('tunnel_host', errmsg); + return true; + } else { + errmsg = null; + setError('tunnel_host', errmsg); + } + + if(isEmptyString(state.tunnel_port)) { + errmsg = gettext('SSH Tunnel port must be specified.'); + setError('tunnel_port', errmsg); + return true; + } else { + errmsg = null; + setError('tunnel_port', errmsg); + } + + if(isEmptyString(state.tunnel_username)) { + errmsg = gettext('SSH Tunnel username must be specified.'); + setError('tunnel_username', errmsg); + return true; + } else { + errmsg = null; + setError('tunnel_username', errmsg); + } + + if (state.tunnel_authentication) { + if(isEmptyString(state.tunnel_identity_file)) { + errmsg = gettext('SSH Tunnel identity file must be specified.'); + setError('tunnel_identity_file', errmsg); + return true; + } else { + errmsg = null; + setError('tunnel_identity_file', errmsg); + } + } + } + return false; + } +} diff --git a/web/pgadmin/browser/server_groups/servers/static/js/variable.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/variable.ui.js new file mode 100644 index 000000000..25d30729d --- /dev/null +++ b/web/pgadmin/browser/server_groups/servers/static/js/variable.ui.js @@ -0,0 +1,130 @@ +import gettext from 'sources/gettext'; +import _ from 'lodash'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import { getNodeAjaxOptions, getNodeListByName } from '../../../../static/js/node_ajax'; + +export function getNodeVariableSchema(nodeObj, treeNodeInfo, itemNodeData, hasDatabase, hasRole) { + let keys = ['name', 'value']; + if(hasDatabase) { + keys.push('database'); + } + if(hasRole) { + keys.push('role'); + } + return new VariableSchema( + ()=>getNodeAjaxOptions('vopts', nodeObj, treeNodeInfo, itemNodeData, null, (vars)=>{ + var res = []; + _.each(vars, function(v) { + res.push({ + 'value': v.name, + 'image': undefined, + 'label': v.name, + 'vartype': v.vartype, + 'enumvals': v.enumvals, + 'max_val': v.max_val, + 'min_val': v.min_val, + }); + }); + + return res; + }), + ()=>getNodeListByName('database', treeNodeInfo, itemNodeData), + ()=>getNodeListByName('role', treeNodeInfo, itemNodeData), + keys + ); +} + +export default class VariableSchema extends BaseUISchema { + constructor(vnameOptions, databaseOptions, roleOptions, keys) { + super({ + name: undefined, + value: undefined, + role: null, + database: null, + }); + this.vnameOptions = vnameOptions; + this.databaseOptions = databaseOptions; + this.roleOptions = roleOptions; + this.varTypes = {}; + this.keys = keys; + } + + setVarTypes(options) { + options.forEach((option)=>{ + this.varTypes[option.value] = { + ...option, + }; + }); + } + + getValueFieldProps(variable) { + switch(variable?.vartype) { + case 'bool': + return 'switch'; + case 'enum': + return { + cell: 'select', + options: (variable.enumvals || []).map((val)=>({ + label: val, + value: val + })) + }; + case 'integer': + return 'int'; + case 'real': + return 'number'; + case 'string': + return 'text'; + default: + return ''; + } + } + + get baseFields() { + let obj = this; + return [ + { + id: 'id', label: gettext('ID'), type: 'int', group: null, + mode: ['properties'], + }, + { + id: 'name', label: gettext('Name'), type:'text', + readonly: function(state) { + return !obj.isNew(state); + }, + cell: ()=>({ + cell: 'select', + options: this.vnameOptions, + optionsLoaded: (options)=>{obj.setVarTypes(options);}, + controlProps: { allowClear: false }, + }), + noEmpty: true, + }, + { + id: 'value', label: gettext('Value'), type: 'text', + noEmpty: true, deps: ['name'], + depChange: (state, changeSource)=>{ + if(changeSource == 'name') { + return {...state + , value: null + }; + } + }, + cell: (row)=>{ + let variable = this.varTypes[row.name]; + return this.getValueFieldProps(variable); + } + }, + {id: 'database', label: gettext('Database'), type: 'text', + cell: ()=>({cell: 'select', options: this.databaseOptions }), + }, + {id: 'role', label: gettext('Role'), type: 'text', + cell: ()=>({cell: 'select', options: this.roleOptions, + controlProps: { + allowClear: false, + } + }), + }, + ]; + } +} diff --git a/web/pgadmin/browser/server_groups/static/js/server_group.js b/web/pgadmin/browser/server_groups/static/js/server_group.js index 6942b8308..5c6c41dfa 100644 --- a/web/pgadmin/browser/server_groups/static/js/server_group.js +++ b/web/pgadmin/browser/server_groups/static/js/server_group.js @@ -6,6 +6,7 @@ // This software is released under the PostgreSQL Licence // ////////////////////////////////////////////////////////////// +import ServerGroupSchema from './server_group.ui'; define('pgadmin.node.server_group', [ 'sources/gettext', 'sources/url_for', 'jquery', 'underscore', @@ -35,52 +36,7 @@ define('pgadmin.node.server_group', [ data: {'action': 'create'}, icon: 'wcTabIcon icon-server_group', }]); }, - model: pgAdmin.Browser.Node.Model.extend({ - defaults: { - id: undefined, - name: null, - user_id: undefined, - }, - schema: [ - { - id: 'id', label: gettext('ID'), type: 'int', group: null, - mode: ['properties'], - visible: function(model){ - if (model.attributes.user_id != current_user.id && !current_user.is_admin) - return false; - return true; - }, - },{ - id: 'name', label: gettext('Name'), type: 'text', group: null, - mode: ['properties', 'edit', 'create'], - disabled: function(model){ - if (model.attributes.user_id != current_user.id && !_.isUndefined(model.attributes.user_id)) - return true; - return false; - }, - }, - ], - validate: function() { - var errmsg = null; - - this.errorModel.clear(); - - if (!this.isNew() && 'id' in this.changed) { - errmsg = gettext('The ID cannot be changed.'); - this.errorModel.set('id', errmsg); - return errmsg; - } - if (_.isUndefined(this.get('name')) || - _.isNull(this.get('name')) || - String(this.get('name')).replace(/^\s+|\s+$/g, '') == '') { - errmsg = gettext('Name cannot be empty.'); - this.errorModel.set('name', errmsg); - return errmsg; - } - return null; - }, - }), - + getSchema: ()=>new ServerGroupSchema(), canDrop: function(itemData) { var serverOwner = itemData.user_id; if (serverOwner != current_user.id && !_.isUndefined(serverOwner)) diff --git a/web/pgadmin/browser/server_groups/static/js/server_group.ui.js b/web/pgadmin/browser/server_groups/static/js/server_group.ui.js new file mode 100644 index 000000000..f633640ae --- /dev/null +++ b/web/pgadmin/browser/server_groups/static/js/server_group.ui.js @@ -0,0 +1,25 @@ +import gettext from 'sources/gettext'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; + +export default class ServerGroupSchema extends BaseUISchema { + constructor() { + super({ + id: undefined, + name: null, + user_id: undefined, + }); + } + + get baseFields() { + return [ + { + id: 'id', label: gettext('ID'), type: 'int', group: null, + mode: ['properties'], visible: true, + },{ + id: 'name', label: gettext('Name'), type: 'text', group: null, + mode: ['properties', 'edit', 'create'], noEmpty: true, + disabled: false, + } + ]; + } +} diff --git a/web/pgadmin/browser/static/js/collection.js b/web/pgadmin/browser/static/js/collection.js index 47c877156..aadc34b6c 100644 --- a/web/pgadmin/browser/static/js/collection.js +++ b/web/pgadmin/browser/static/js/collection.js @@ -7,6 +7,8 @@ // ////////////////////////////////////////////////////////////// +import {removeNodeView} from './node_view'; + define([ 'sources/gettext', 'jquery', 'underscore', 'sources/pgadmin', 'backbone', 'alertify', 'backform', 'backgrid', 'sources/browser/generate_url', @@ -238,6 +240,8 @@ define([ j.data('obj-view', null); } + /* Remove any dom rendered by getNodeView */ + removeNodeView(j[0]); // Make sure the HTML element is empty. j.empty(); j.data('obj-view', gridView); diff --git a/web/pgadmin/browser/static/js/node.js b/web/pgadmin/browser/static/js/node.js index d58a2e037..1b00117d1 100644 --- a/web/pgadmin/browser/static/js/node.js +++ b/web/pgadmin/browser/static/js/node.js @@ -7,6 +7,8 @@ // ////////////////////////////////////////////////////////////// +import {getNodeView, removeNodeView} from './node_view'; + define('pgadmin.browser.node', [ 'sources/tree/pgadmin_tree_node', 'sources/url_for', 'sources/gettext', 'jquery', 'underscore', 'sources/pgadmin', @@ -1216,6 +1218,22 @@ define('pgadmin.browser.node', [ // Cache the current IDs for next time $(this).data('node-prop', treeHierarchy); + /* Remove any dom rendered by getNodeView */ + removeNodeView(j[0]); + /* getSchema is a schema for React. Get the react node view */ + if(that.getSchema) { + let treeNodeInfo = that.getTreeNodeHierarchy.apply(this, [item]); + getNodeView( + that.type, treeNodeInfo, 'properties', data, 'tab', j[0], this, onCancelFunc, onEdit, + (nodeData)=>{ + if(nodeData.node) { + onSaveFunc(nodeData.node, treeNodeInfo); + } + } + ); + return; + } + if (!content.hasClass('has-pg-prop-btn-group')) content.addClass('has-pg-prop-btn-group'); @@ -1460,6 +1478,28 @@ define('pgadmin.browser.node', [ action = 'edit'; } self.$container.attr('action-mode', action); + + self.icon( + _.isFunction(that['node_image']) ? + (that['node_image']).apply(that, [data]) : + (that['node_image'] || ('icon-' + that.type)) + ); + /* Remove any dom rendered by getNodeView */ + removeNodeView(j[0]); + /* getSchema is a schema for React. Get the react node view */ + if(that.getSchema) { + let treeNodeInfo = that.getTreeNodeHierarchy.apply(this, [item]); + getNodeView( + that.type, treeNodeInfo, action, data, 'dialog', j[0], this, onCancelFunc, onEdit, + (nodeData)=>{ + if(nodeData.node) { + onSaveFunc(nodeData.node, treeNodeInfo); + } + } + ); + return; + } + // We need to release any existing view, before // creating the new view. if (view) { @@ -1654,10 +1694,10 @@ define('pgadmin.browser.node', [ // Closing this panel this.close(); }.bind(panel), - updateTreeItem = function(obj) { + updateTreeItem = function(obj, tnode, node_info) { var _old = data, - _new = _.clone(view.model.tnode), - info = _.clone(view.model.node_info); + _new = tnode || _.clone(view.model.tnode), + info = node_info || _.clone(view.model.node_info); // Clear the cache for this node now. setTimeout(function() { @@ -1681,7 +1721,7 @@ define('pgadmin.browser.node', [ ); closePanel(false); }, - saveNewNode = function(obj) { + saveNewNode = function(obj, tnode, node_info) { var $props = this.$container.find('.obj_properties').first(), objview = $props.data('obj-view'); @@ -1691,8 +1731,8 @@ define('pgadmin.browser.node', [ }, 0); try { pgBrowser.Events.trigger( - 'pgadmin:browser:tree:add', _.clone(objview.model.tnode), - _.clone(objview.model.node_info) + 'pgadmin:browser:tree:add', _.clone(tnode || objview.model.tnode), + _.clone(node_info || objview.model.node_info) ); } catch (e) { console.warn(e.stack || e); @@ -1724,10 +1764,10 @@ define('pgadmin.browser.node', [ } } else { /* Show properties */ - properties(); onEdit = editInNewPanel.bind(panel); + properties(); } - if (panel.closeable()) { + if (panel.closeable() && !that.getSchema) { panel.on(wcDocker.EVENT.CLOSING, warnBeforeChangesLost.bind( panel, gettext('Changes will be lost. Are you sure you want to close the dialog?'), diff --git a/web/pgadmin/browser/static/js/node_ajax.js b/web/pgadmin/browser/static/js/node_ajax.js new file mode 100644 index 000000000..494cbb838 --- /dev/null +++ b/web/pgadmin/browser/static/js/node_ajax.js @@ -0,0 +1,164 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import _ from 'lodash'; +import getApiInstance from '../../../static/js/api_instance'; +import {generate_url} from 'sources/browser/generate_url'; +import pgAdmin from 'sources/pgadmin'; + +/* It generates the URL based on tree node selected */ +export function generateNodeUrl(treeNodeInfo, actionType, itemNodeData, withId, jumpAfterNode) { + let opURL = { + 'create': 'obj', + 'drop': 'obj', + 'edit': 'obj', + 'properties': 'obj', + 'statistics': 'stats', + }, + priority = -Infinity; + let nodeObj = this; + let itemID = withId && itemNodeData._type == nodeObj.type ? encodeURIComponent(itemNodeData._id) : ''; + actionType = actionType in opURL ? opURL[actionType] : actionType; + + if (nodeObj.parent_type) { + if (_.isString(nodeObj.parent_type)) { + let p = treeNodeInfo[nodeObj.parent_type]; + if (p) { + priority = p.priority; + } + } else { + _.each(nodeObj.parent_type, function(o) { + let p = treeNodeInfo[o]; + if (p) { + if (priority < p.priority) { + priority = p.priority; + } + } + }); + } + } + + let jump_after_priority = priority; + if(jumpAfterNode && treeNodeInfo[jumpAfterNode]) { + jump_after_priority = treeNodeInfo[jumpAfterNode].priority; + } + + var nodePickFunction = function(treeInfoValue) { + return (treeInfoValue.priority <= jump_after_priority || treeInfoValue.priority == priority); + }; + + return generate_url(pgAdmin.Browser.URL, treeNodeInfo, actionType, nodeObj.type, nodePickFunction, itemID); +} + + +/* Get the nodes list as options required by select controls + * The options are cached for performance reasons. + */ +export function getNodeAjaxOptions(url, nodeObj, treeNodeInfo, itemNodeData, params={}, transform=(data)=>data) { + let otherParams = { + urlWithId: false, + jumpAfterNode: null, + ...params + }; + return new Promise((resolve, reject)=>{ + const api = getApiInstance(); + let fullUrl = ''; + if(url) { + fullUrl = generateNodeUrl.call( + nodeObj, treeNodeInfo, url, itemNodeData, otherParams.urlWithId, nodeObj.parent_type, otherParams.jumpAfterNode + ); + } + + if (url) { + let cacheNode = pgAdmin.Browser.Nodes[otherParams.cacheNode] || nodeObj; + let cacheLevel = otherParams.cacheLevel || cacheNode.cache_level(treeNodeInfo, otherParams.urlWithId); + /* + * We needs to check, if we have already cached data for this url. + * If yes - use that, and do not bother about fetching it again, + * and use it. + */ + var data = cacheNode.cache(nodeObj.type + '#' + url, treeNodeInfo, cacheLevel); + + if (_.isUndefined(data) || _.isNull(data)) { + api.get(fullUrl) + .then((res)=>{ + data = res.data.data; + cacheNode.cache(nodeObj.type + '#' + url, treeNodeInfo, cacheLevel, data); + resolve(transform(data)); + }) + .catch((err)=>{ + reject(err); + }); + } else { + // To fetch only options from cache, we do not need time from 'at' + // attribute but only options. + resolve(transform(data.data || [])); + } + } + }); +} + +/* Get the nodes list based on current selected node id */ +export function getNodeListById(nodeObj, treeNodeInfo, itemNodeData, filter=()=>true) { + /* Transform the result to add image details */ + const transform = (rows) => { + var res = []; + + _.each(rows, function(r) { + if (filter(r)) { + var l = (_.isFunction(nodeObj['node_label']) ? + (nodeObj['node_label']).apply(nodeObj, [r]) : + r.label), + image = (_.isFunction(nodeObj['node_image']) ? + (nodeObj['node_image']).apply(nodeObj, [r]) : + (nodeObj['node_image'] || ('icon-' + nodeObj.type))); + + res.push({ + 'value': r._id, + 'image': image, + 'label': l, + }); + } + }); + + return res; + }; + + return getNodeAjaxOptions('nodes', nodeObj, treeNodeInfo, itemNodeData, null, transform); +} + +/* Get the nodes list based on node name passed */ +export function getNodeListByName(node, treeNodeInfo, itemNodeData, filter=()=>true, postTransform=(res)=>res) { + let nodeObj = pgAdmin.Browser.Nodes[node]; + /* Transform the result to add image details */ + const transform = (rows) => { + var res = []; + + _.each(rows, function(r) { + if (filter(r)) { + var l = (_.isFunction(nodeObj['node_label']) ? + (nodeObj['node_label']).apply(nodeObj, [r]) : + r.label), + image = (_.isFunction(nodeObj['node_image']) ? + (nodeObj['node_image']).apply(nodeObj, [r]) : + (nodeObj['node_image'] || ('icon-' + nodeObj.type))); + + res.push({ + 'value': r.label, + 'image': image, + 'label': l, + }); + } + }); + + return postTransform(res); + }; + + return getNodeAjaxOptions('nodes', nodeObj, treeNodeInfo, itemNodeData, null, transform); +} diff --git a/web/pgadmin/browser/static/js/node_view.jsx b/web/pgadmin/browser/static/js/node_view.jsx new file mode 100644 index 000000000..837364769 --- /dev/null +++ b/web/pgadmin/browser/static/js/node_view.jsx @@ -0,0 +1,198 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import pgAdmin from 'sources/pgadmin'; +import getApiInstance from 'sources/api_instance'; +import {getHelpUrl} from 'pgadmin.help'; +import SchemaView from 'sources/SchemaView'; +import { generateNodeUrl } from './node_ajax'; +import Alertify from 'pgadmin.alertifyjs'; +import gettext from 'sources/gettext'; +import 'wcdocker'; + +/* The entry point for rendering React based view in properties, called in node.js */ +export function getNodeView(nodeType, treeNodeInfo, actionType, itemNodeData, formType, container, containerPanel, onCancel, onEdit, onSave) { + let nodeObj = pgAdmin.Browser.Nodes[nodeType]; + let serverInfo = treeNodeInfo && ('server' in treeNodeInfo) && + pgAdmin.Browser.serverInfo && pgAdmin.Browser[treeNodeInfo.server._id]; + let inCatalog = treeNodeInfo && ('catalog' in treeNodeInfo); + let urlBase = generateNodeUrl.call(nodeObj, treeNodeInfo, actionType, itemNodeData, false, null); + const api = getApiInstance(); + const url = (isNew)=>{ + return urlBase + (isNew ? '' : itemNodeData._id); + }; + let isDirty = false; // usefull for warnings + let warnOnCloseFlag = true; + const confirmOnCloseReset = pgAdmin.Browser.get_preferences_for_module('browser').confirm_on_properties_close; + + /* Called when dialog is opened in edit mode, promise required */ + let initData = ()=>new Promise((resolve, reject)=>{ + api.get(url(false)) + .then((res)=>{ + resolve(res.data); + }) + .catch((err)=>{ + if(err.response){ + console.error('error resp', err.response); + } else if(err.request){ + console.error('error req', err.request); + } else if(err.message){ + console.error('error msg', err.message); + } + reject(err); + }); + }); + + /* on save button callback, promise required */ + const onSaveClick = (isNew, data)=>new Promise((resolve, reject)=>{ + return api({ + url: url(isNew), + method: isNew ? 'POST' : 'PUT', + data: data, + }).then((res)=>{ + /* Don't warn the user before closing dialog */ + warnOnCloseFlag = false; + resolve(res.data); + onSave(res.data); + }).catch((err)=>{ + reject(err); + }); + }); + + /* Called when switched to SQL tab, promise required */ + const getSQLValue = (isNew, changedData)=>{ + const msqlUrl = generateNodeUrl.call(nodeObj, treeNodeInfo, 'msql', itemNodeData, !isNew, null); + return new Promise((resolve, reject)=>{ + api({ + url: msqlUrl, + method: 'GET', + params: changedData, + }).then((res)=>{ + resolve(res.data.data); + }).catch((err)=>{ + if(err.response){ + console.error('error resp', err.response); + } else if(err.request){ + console.error('error req', err.request); + } else if(err.message){ + console.error('error msg', err.message); + } + reject(err); + }); + }); + }; + + /* Callback for help button */ + const onHelp = (isSqlHelp=false, isNew=false)=>{ + if(isSqlHelp) { + let server = treeNodeInfo.server; + let url = pgAdmin.Browser.utils.pg_help_path; + if (server.server_type == 'ppas') { + url = pgAdmin.Browser.utils.edbas_help_path; + } + + let fullUrl = ''; + if (nodeObj.sqlCreateHelp == '' && nodeObj.sqlAlterHelp != '') { + fullUrl = getHelpUrl(url, nodeObj.sqlAlterHelp, server.version, server.server_type); + } else if (nodeObj.sqlCreateHelp != '' && nodeObj.sqlAlterHelp == '') { + fullUrl = getHelpUrl(url, nodeObj.sqlCreateHelp, server.version, server.server_type); + } else { + if (isNew) { + fullUrl = getHelpUrl(url, nodeObj.sqlCreateHelp, server.version, server.server_type); + } else { + fullUrl = getHelpUrl(url, nodeObj.sqlAlterHelp, server.version, server.server_type); + } + } + + window.open(fullUrl, 'postgres_help'); + } else { + window.open(nodeObj.dialogHelp, 'pgadmin_help'); + } + }; + + /* A warning before closing the dialog with unsaved changes, based on preference */ + let warnBeforeChangesLost = (yesCallback)=>{ + let confirmOnClose = pgAdmin.Browser.get_preferences_for_module('browser').confirm_on_properties_close; + if (warnOnCloseFlag && confirmOnClose) { + if(isDirty){ + Alertify.confirm( + gettext('Warning'), + gettext('Changes will be lost. Are you sure you want to close the dialog?'), + function() { + yesCallback(); + return true; + }, + function() { + return true; + } + ).set('labels', { + ok: gettext('Yes'), + cancel: gettext('No'), + }).show(); + } else { + return true; + } + return false; + } else { + yesCallback(); + return true; + } + }; + + /* Bind the wcDocker dialog close event and check if user should be warned */ + if (containerPanel.closeable()) { + containerPanel.on(window.wcDocker.EVENT.CLOSING, warnBeforeChangesLost.bind( + containerPanel, + function() { + containerPanel.off(window.wcDocker.EVENT.CLOSING); + /* Always clean up the react mounted dom before closing */ + removeNodeView(container); + containerPanel.close(); + } + )); + } + + /* All other useful details can go with this object */ + const viewHelperProps = { + mode: actionType, + serverInfo: serverInfo ? { + type: serverInfo.type, + version: serverInfo.version, + }: undefined, + inCatalog: inCatalog, + }; + + /* Fire at will, mount the DOM */ + ReactDOM.render( + containerPanel.close()} + onHelp={onHelp} + onEdit={onEdit} + onDataChange={(dataChanged)=>{ + isDirty = dataChanged; + }} + confirmOnCloseReset={confirmOnCloseReset} + hasSQL={nodeObj.hasSQL && (actionType === 'create' || actionType === 'edit')} + getSQLValue={getSQLValue} + disableSqlHelp={nodeObj.sqlAlterHelp == '' && nodeObj.sqlCreateHelp == ''} + />, container); +} + +/* When switching from normal node to collection node, clean up the React mounted DOM */ +export function removeNodeView(container) { + ReactDOM.unmountComponentAtNode(container); +} diff --git a/web/pgadmin/browser/templates/browser/js/messages.js b/web/pgadmin/browser/templates/browser/js/messages.js index a101033d1..dba5d6687 100644 --- a/web/pgadmin/browser/templates/browser/js/messages.js +++ b/web/pgadmin/browser/templates/browser/js/messages.js @@ -28,6 +28,7 @@ define( 'MUST_BE_NUM' : gettext("'%s' must be a numeric."), 'MUST_GR_EQ' : gettext("'%s' must be greater than or equal to %s."), 'MUST_LESS_EQ' : gettext("'%s' must be less than or equal to %s."), + 'CANNOT_BE_EMPTY': gettext("'%s' cannot be empty."), 'STATISTICS_LABEL': gettext("Statistics"), 'STATISTICS_VALUE_LABEL': gettext("Value"), 'NODE_HAS_NO_SQL': gettext("No SQL could be generated for the selected object."), diff --git a/web/pgadmin/misc/themes/__init__.py b/web/pgadmin/misc/themes/__init__.py index 19dcd965d..8ebe86c39 100644 --- a/web/pgadmin/misc/themes/__init__.py +++ b/web/pgadmin/misc/themes/__init__.py @@ -42,7 +42,7 @@ def themes(app): # Let the default theme go if exception occurs pass - return theme_css + return theme_css, theme return { 'get_theme_css': get_theme_css, diff --git a/web/pgadmin/static/js/SchemaView/DataGridView.jsx b/web/pgadmin/static/js/SchemaView/DataGridView.jsx new file mode 100644 index 000000000..3cf16783b --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/DataGridView.jsx @@ -0,0 +1,399 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +/* The DataGridView component is based on react-table component */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { Box } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { PgIconButton } from '../components/Buttons'; +import AddIcon from '@material-ui/icons/AddOutlined'; +import { MappedCellControl } from './MappedControl'; +import EditRoundedIcon from '@material-ui/icons/EditRounded'; +import DeleteRoundedIcon from '@material-ui/icons/DeleteRounded'; +import { useTable, useBlockLayout, useResizeColumns, useSortBy, useExpanded } from 'react-table'; +import clsx from 'clsx'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; + +import gettext from 'sources/gettext'; +import { SCHEMA_STATE_ACTIONS } from '.'; +import FormView from './FormView'; +import { confirmDeleteRow } from '../helpers/legacyConnector'; +import CustomPropTypes from 'sources/custom_prop_types'; +import { evalFunc } from 'sources/utils'; + +const useStyles = makeStyles((theme)=>({ + grid: { + ...theme.mixins.panelBorder, + backgroundColor: theme.palette.background.default, + }, + gridHeader: { + display: 'flex', + ...theme.mixins.panelBorder.bottom, + backgroundColor: theme.otherVars.headerBg, + }, + gridHeaderText: { + padding: theme.spacing(0.5, 1), + fontWeight: theme.typography.fontWeightBold, + }, + gridControls: { + marginLeft: 'auto', + }, + gridControlsButton: { + border: 0, + borderRadius: 0, + ...theme.mixins.panelBorder.left, + }, + gridRowButton: { + border: 0, + borderRadius: 0, + padding: 0, + minWidth: 0, + backgroundColor: 'inherit', + }, + gridTableContainer: { + overflow: 'auto', + width: '100%', + }, + table: { + borderSpacing: 0, + width: '100%', + overflow: 'auto', + backgroundColor: theme.otherVars.tableBg, + }, + tableCell: { + margin: 0, + padding: theme.spacing(0.5), + ...theme.mixins.panelBorder.bottom, + ...theme.mixins.panelBorder.right, + position: 'relative', + textAlign: 'center' + }, + tableCellHeader: { + fontWeight: theme.typography.fontWeightBold, + padding: theme.spacing(1, 0.5), + textAlign: 'left', + }, + resizer: { + display: 'inline-block', + width: '5px', + height: '100%', + position: 'absolute', + right: 0, + top: 0, + transform: 'translateX(50%)', + zIndex: 1, + touchAction: 'none', + }, +})); + +function DataTableHeader({headerGroups}) { + const classes = useStyles(); + return ( +
+ {headerGroups.map((headerGroup, hi) => ( +
+ {headerGroup.headers.map((column, ci) => ( +
+
+ {column.render('Header')} + + {column.isSorted + ? column.isSortedDesc + ? ' 🔽' + : ' 🔼' + : ''} + +
+ {column.resizable && +
} +
+ ))} +
+ ))} +
+ ); +} + +DataTableHeader.propTypes = { + headerGroups: PropTypes.array.isRequired, +}; + +function DataTableRow({row, totalRows, canExpand, isResizing, viewHelperProps, formErr, schema, dataDispatch, accessPath}) { + const classes = useStyles(); + const [key, setKey] = useState(false); + // let key = useRef(true); + /* Memoize the row to avoid unnecessary re-render. + * If table data changes, then react-table re-renders the complete tables + * We can avoid re-render by if row data is not changed + */ + let depsMap = _.values(row.original, Object.keys(row.original).filter((k)=>!k.startsWith('btn'))); + depsMap = depsMap.concat([totalRows, row.isExpanded, key, isResizing]); + return ( + useMemo(()=> + <> +
+ {row.cells.map((cell, ci) => { + return ( +
+ {cell.render('Cell', { + reRenderRow: ()=>{setKey((currKey)=>!currKey);} + })} +
+ ); + })} +
+ { + canExpand && row.isExpanded && + + } + + , depsMap) + ); +} + +export default function DataGridView({ + value, viewHelperProps, formErr, schema, accessPath, dataDispatch, containerClassName, ...props}) { + const classes = useStyles(); + /* Calculate the fields which depends on the current field + deps has info on fields which the current field depends on. */ + const dependsOnField = useMemo(()=>{ + let res = {}; + schema.fields.forEach((field)=>{ + (field.deps || []).forEach((dep)=>{ + res[dep] = res[dep] || []; + res[dep].push(field.id); + }); + }); + return res; + }, []); + let columns = useMemo( + ()=>{ + let cols = []; + if(props.canEdit) { + let colInfo = { + Header: <> , + id: 'btn-edit', + accessor: ()=>{}, + resizable: false, + sortable: false, + dataType: 'edit', + width: 30, + minWidth: '0', + Cell: ({row})=>} className={classes.gridRowButton} + onClick={()=>{ + row.toggleRowExpanded(!row.isExpanded); + }} + /> + }; + colInfo.Cell.displayName = 'Cell', + colInfo.Cell.propTypes = { + row: PropTypes.object.isRequired, + }; + cols.push(colInfo); + } + if(props.canDelete) { + let colInfo = { + Header: <> , + id: 'btn-delete', + accessor: ()=>{}, + resizable: false, + sortable: false, + dataType: 'delete', + width: 30, + minWidth: '0', + Cell: ({row}) => { + return ( + } + onClick={()=>{ + confirmDeleteRow(()=>{ + dataDispatch({ + type: SCHEMA_STATE_ACTIONS.DELETE_ROW, + path: accessPath, + value: row.index, + }); + }, ()=>{}, props.customDeleteTitle, props.customDeleteMsg); + }} className={classes.gridRowButton} /> + ); + } + }; + colInfo.Cell.displayName = 'Cell', + colInfo.Cell.propTypes = { + row: PropTypes.object.isRequired, + }; + cols.push(colInfo); + } + + cols = cols.concat( + schema.fields + .map((field)=>{ + let colInfo = { + Header: field.label, + accessor: field.id, + field: field, + resizable: true, + sortable: true, + ...(field.minWidth ? {minWidth: field.minWidth} : {}), + Cell: ({value, row, ...other}) => { + let {visible, disabled, readonly, ..._field} = field; + + let verInLimit = (_.isUndefined(viewHelperProps.serverInfo) ? true : + ((_.isUndefined(field.server_type) ? true : + (viewHelperProps.serverInfo.type in field.server_type)) && + (_.isUndefined(field.min_version) ? true : + (viewHelperProps.serverInfo.version >= field.min_version)) && + (_.isUndefined(field.max_version) ? true : + (viewHelperProps.serverInfo.version <= field.max_version)))); + let _readonly = viewHelperProps.inCatalog || (viewHelperProps.mode == 'properties'); + if(!_readonly) { + _readonly = evalFunc(readonly, row.original || {}); + } + + let _visible = true; + if(visible) { + _visible = evalFunc(visible, row.original || {}); + } + _visible = _visible && verInLimit; + + disabled = evalFunc(disabled, row.original || {}); + + return { + /* Get the changes on dependent fields as well. + * The return value of depChange function is merged and passed to state. + */ + const depChange = (state)=>{ + let rowdata = _.get(state, accessPath.concat(row.index)); + _field.depChange && _.merge(rowdata, _field.depChange(rowdata, _field.id) || {}); + (dependsOnField[_field.id] || []).forEach((d)=>{ + d = _.find(schema.fields, (f)=>f.id==d); + if(d.depChange) { + _.merge(rowdata, d.depChange(rowdata, _field.id) || {}); + } + }); + return state; + }; + dataDispatch({ + type: SCHEMA_STATE_ACTIONS.SET_VALUE, + path: accessPath.concat([row.index, _field.id]), + value: value, + depChange: depChange, + }); + }} + reRenderRow={other.reRenderRow} + />; + }, + }; + colInfo.Cell.displayName = 'Cell', + colInfo.Cell.propTypes = { + row: PropTypes.object.isRequired, + value: PropTypes.any, + onCellChange: PropTypes.func, + }; + return colInfo; + }) + ); + return cols; + },[]); + + const onAddClick = useCallback(()=>{ + let newRow = {}; + columns.forEach((column)=>{ + if(column.field) { + newRow[column.field.id] = schema.defaults[column.field.id]; + } + }); + dataDispatch({ + type: SCHEMA_STATE_ACTIONS.ADD_ROW, + path: accessPath, + value: newRow, + }); + }); + + const defaultColumn = useMemo(()=>({ + minWidth: 175, + })); + + let tablePlugins = [ + useBlockLayout, + useResizeColumns, + useSortBy, + ]; + if(props.canEdit) { + tablePlugins.push(useExpanded); + } + const { + getTableProps, + getTableBodyProps, + headerGroups, + rows, + prepareRow, + } = useTable( + { + columns, + data: value || [], + defaultColumn, + manualSortBy: true, + autoResetSortBy: false, + autoResetExpanded: false, + }, + ...tablePlugins, + ); + + const isResizing = _.flatMap(headerGroups, headerGroup => headerGroup.headers.map(col=>col.isResizing)).includes(true); + + return ( + + + + {props.label} + + {props.canAdd && } className={classes.gridControlsButton} />} + + +
+ +
+ {rows.map((row, i) => { + prepareRow(row); + return ; + })} +
+
+
+
+ ); +} + +DataGridView.propTypes = { + label: PropTypes.string, + value: PropTypes.array, + viewHelperProps: PropTypes.object, + formErr: PropTypes.object, + schema: CustomPropTypes.schemaUI, + accessPath: PropTypes.array.isRequired, + dataDispatch: PropTypes.func.isRequired, + containerClassName: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + canEdit: PropTypes.bool, + canAdd: PropTypes.bool, + canDelete: PropTypes.bool, + customDeleteTitle: PropTypes.string, + customDeleteMsg: PropTypes.string, +}; diff --git a/web/pgadmin/static/js/SchemaView/FormView.jsx b/web/pgadmin/static/js/SchemaView/FormView.jsx new file mode 100644 index 000000000..485d983dd --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/FormView.jsx @@ -0,0 +1,257 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Box, makeStyles, Tab, Tabs } from '@material-ui/core'; +import _ from 'lodash'; +import PropTypes from 'prop-types'; + +import { MappedFormControl } from './MappedControl'; +import TabPanel from '../components/TabPanel'; +import DataGridView from './DataGridView'; +import { SCHEMA_STATE_ACTIONS } from '.'; +import { InputSQL } from '../components/FormComponents'; +import gettext from 'sources/gettext'; +import { evalFunc } from 'sources/utils'; +import CustomPropTypes from '../custom_prop_types'; + +const useStyles = makeStyles((theme)=>({ + fullSpace: { + padding: 0, + height: '100%' + }, + controlRow: { + paddingBottom: theme.spacing(1), + }, +})); + +/* Optional SQL tab */ +function SQLTab({active, getSQLValue}) { + const [sql, setSql] = useState('Loading...'); + useEffect(()=>{ + let unmounted = false; + if(active) { + setSql('Loading...'); + getSQLValue().then((value)=>{ + if(!unmounted) { + setSql(value); + } + }); + } + return ()=>{unmounted=true;}; + }, [active]); + + return ; +} + +SQLTab.propTypes = { + active: PropTypes.bool, + getSQLValue: PropTypes.func.isRequired, +}; + +/* The first component of schema view form */ +export default function FormView({ + value, formErr, schema={}, viewHelperProps, isNested=false, accessPath, dataDispatch, hasSQLTab, getSQLValue, onTabChange, firstEleRef}) { + let defaultTab = 'General'; + let tabs = {}; + let tabsClassname = {}; + const [tabValue, setTabValue] = useState(0); + const classes = useStyles(); + const firstElement = useRef(); + + schema = schema || {fields: []}; + + /* Calculate the fields which depends on the current field + deps has info on fields which the current field depends on. */ + const dependsOnField = useMemo(()=>{ + let res = {}; + schema.fields.forEach((field)=>{ + (field.deps || []).forEach((dep)=>{ + res[dep] = res[dep] || []; + res[dep].push(field.id); + }); + }); + return res; + }, []); + + /* Prepare the array of components based on the types */ + schema.fields.forEach((f)=>{ + let modeSuppoted = true; + if(f.mode) { + modeSuppoted = (f.mode.indexOf(viewHelperProps.mode) > -1); + } + if(modeSuppoted) { + let {visible, disabled, group, readonly, ...field} = f; + group = group || defaultTab; + + let verInLimit = (_.isUndefined(viewHelperProps.serverInfo) ? true : + ((_.isUndefined(field.server_type) ? true : + (viewHelperProps.serverInfo.type in field.server_type)) && + (_.isUndefined(field.min_version) ? true : + (viewHelperProps.serverInfo.version >= field.min_version)) && + (_.isUndefined(field.max_version) ? true : + (viewHelperProps.serverInfo.version <= field.max_version)))); + + let _readonly = viewHelperProps.inCatalog || (viewHelperProps.mode == 'properties'); + if(!_readonly) { + _readonly = evalFunc(readonly, value); + } + + let _visible = true; + + if(visible) { + _visible = evalFunc(visible, value); + } + _visible = _visible && verInLimit; + + disabled = evalFunc(disabled, value); + + + if(!tabs[group]) tabs[group] = []; + + /* Lets choose the path based on type */ + if(field.type === 'nested-tab') { + /* Pass on the top schema */ + field.schema.top = schema.top; + tabs[group].push( + + ); + } else if(field.type === 'collection') { + /* Pass on the top schema */ + field.schema.top = schema.top; + /* If its a collection, let data grid view handle it */ + tabs[group].push( + useMemo(()=>, [value[field.id]]) + ); + } else { + /* Its a form control */ + const hasError = field.id == formErr.name; + /* When there is a change, the dependent values can change + * lets pass the new changes to dependent and get the new values + * from there as well. + */ + tabs[group].push( + useMemo(()=>{ + if(firstEleRef && !firstEleRef.current) { + firstEleRef.current = ele; + } + }} + key={field.id} + viewHelperProps={viewHelperProps} + name={field.id} + value={value[field.id]} + readonly={_readonly} + disabled={disabled} + visible={_visible} + {...field} + onChange={(value)=>{ + /* Get the changes on dependent fields as well */ + const depChange = (state)=>{ + field.depChange && _.merge(state, field.depChange(state) || {}); + (dependsOnField[field.id] || []).forEach((d)=>{ + d = _.find(schema.fields, (f)=>f.id==d); + if(d.depChange) { + _.merge(state, d.depChange(state) || {}); + } + }); + return state; + }; + dataDispatch({ + type: SCHEMA_STATE_ACTIONS.SET_VALUE, + path: accessPath.concat(field.id), + value: value, + depChange: depChange, + }); + }} + hasError={hasError} + className={classes.controlRow} + />, [ + value[field.id], + _readonly, + disabled, + _visible, + hasError, + classes.controlRow, + ...(field.deps || []).map((dep)=>value[dep]) + ]) + ); + } + } + }); + + /* Add the SQL tab if required */ + let sqlTabActive = false; + if(hasSQLTab) { + let sqlTabName = gettext('SQL'); + sqlTabActive = (Object.keys(tabs).length === tabValue); + /* Re-render and fetch the SQL tab when it is active */ + tabs[sqlTabName] = [ + useMemo(()=>, [sqlTabActive]), + ]; + tabsClassname[sqlTabName] = classes.fullSpace; + } + + useEffect(()=>{ + firstElement.current && firstElement.current.focus(); + }, []); + + useEffect(()=>{ + onTabChange && onTabChange(tabValue, Object.keys(tabs)[tabValue], sqlTabActive); + }, [tabValue]); + + return ( + <> + + { + setTabValue(selTabValue); + }} + // indicatorColor="primary" + variant="scrollable" + scrollButtons="auto" + action={(ref)=>ref && ref.updateIndicator()} + > + {Object.keys(tabs).map((tabName)=>{ + return ; + })} + + + {Object.keys(tabs).map((tabName, i)=>{ + return ( + + {tabs[tabName]} + + ); + })} + ); +} + +FormView.propTypes = { + value: PropTypes.any, + formErr: PropTypes.object, + schema: CustomPropTypes.schemaUI.isRequired, + viewHelperProps: PropTypes.object, + isNested: PropTypes.bool, + accessPath: PropTypes.array.isRequired, + dataDispatch: PropTypes.func, + hasSQLTab: PropTypes.bool, + getSQLValue: PropTypes.func, + onTabChange: PropTypes.func, + firstEleRef: CustomPropTypes.ref, +}; diff --git a/web/pgadmin/static/js/SchemaView/MappedControl.jsx b/web/pgadmin/static/js/SchemaView/MappedControl.jsx new file mode 100644 index 000000000..46a815245 --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/MappedControl.jsx @@ -0,0 +1,199 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import React, { useCallback } from 'react'; +import _ from 'lodash'; + +import { FormInputText, FormInputSelect, FormInputSwitch, FormInputCheckbox, InputSQL, FormInputColor, FormInputFileSelect, FormInputToggle, InputSwitch } from '../components/FormComponents'; +import { InputSelect, InputText } from '../components/FormComponents'; +import Privilege from '../components/Privilege'; +import { evalFunc } from 'sources/utils'; +import PropTypes from 'prop-types'; +import CustomPropTypes from '../custom_prop_types'; + +/* Control mapping for form view */ +function MappedFormControlBase({type, value, id, onChange, className, visible, inputRef, ...props}) { + const name = id; + const onTextChange = useCallback((e) => { + let value = e; + if(e && e.target) { + value = e.target.value; + } + onChange && onChange(value); + }); + + const onIntChange = useCallback((e) => { + let value = e; + if(e && e.target) { + value = e.target.value; + } + if(!isNaN(parseInt(value))) { + value = parseInt(value); + } + onChange && onChange(value); + }); + + if(!visible) { + return <>; + } + + /* The mapping uses Form* components as it comes with labels */ + switch (type) { + case 'int': + return ; + case 'text': + return ; + case 'multiline': + return ; + case 'password': + return ; + case 'select': + return ; + case 'switch': + return onTextChange(e.target.checked, e.target.name)} className={className} + {...props} />; + case 'checkbox': + return onTextChange(e.target.checked, e.target.name)} className={className} + {...props} />; + case 'toggle': + return ; + case 'color': + return ; + case 'file': + return ; + case 'sql': + return ; + default: + return <>; + } +} + +MappedFormControlBase.propTypes = { + type: PropTypes.oneOfType([ + PropTypes.string, PropTypes.func, + ]).isRequired, + value: PropTypes.any, + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + onChange: PropTypes.func, + className: PropTypes.oneOfType([ + PropTypes.string, PropTypes.object, + ]), + visible: PropTypes.bool, + inputRef: CustomPropTypes.ref, +}; + +/* Control mapping for grid cell view */ +function MappedCellControlBase({cell, value, id, optionsLoaded, onCellChange, visible, reRenderRow,...props}) { + const name = id; + const onTextChange = useCallback((e) => { + let value = e; + if(e && e.target) { + value = e.target.value; + } + + onCellChange(value); + }); + + /* 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)=>{ + /* optionsLoaded is called when select options are fetched */ + optionsLoaded && optionsLoaded(res); + reRenderRow && reRenderRow(); + }); + + if(!visible) { + return <>; + } + + /* The mapping does not need Form* components as labels are not needed for grid cells */ + switch(cell) { + case 'int': + case 'number': + case 'text': + return ; + case 'password': + return ; + case 'select': + return ; + case 'switch': + return onTextChange(e.target.checked, e.target.name)} {...props} />; + case 'privilege': + return ; + default: + return <>; + } +} + +MappedCellControlBase.propTypes = { + cell: PropTypes.oneOfType([ + PropTypes.string, PropTypes.func, + ]).isRequired, + value: PropTypes.any, + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + onChange: PropTypes.func, + reRenderRow: PropTypes.func, + optionsLoaded: PropTypes.func, + onCellChange: PropTypes.func, + visible: PropTypes.bool +}; + +const ALLOWED_PROPS_FIELD_COMMON = [ + 'mode', 'value', 'readonly', 'disabled', 'hasError', 'id', + 'label', 'options', 'optionsLoaded', 'controlProps', 'schema', 'inputRef', + 'visible', 'autoFocus', 'helpMessage', 'className' +]; + +const ALLOWED_PROPS_FIELD_FORM = [ + 'type', 'onChange' +]; + +const ALLOWED_PROPS_FIELD_CELL = [ + 'cell', 'onCellChange', 'row', 'reRenderRow', +]; + + +export const MappedFormControl = (props)=>{ + let newProps = {...props}; + let typeProps = evalFunc(newProps.type, newProps.value); + if(typeof(typeProps) === 'object') { + newProps = { + ...newProps, + ...typeProps, + }; + } else { + newProps.type = typeProps; + } + + /* Filter out garbage props if any using ALLOWED_PROPS_FIELD */ + return ; +}; + +export const MappedCellControl = (props)=>{ + let newProps = {...props}; + let cellProps = evalFunc(newProps.cell, newProps.row); + if(typeof(cellProps) === 'object') { + newProps = { + ...newProps, + ...cellProps, + }; + } else { + newProps.cell = cellProps; + } + + /* Filter out garbage props if any using ALLOWED_PROPS_FIELD */ + return ; +}; diff --git a/web/pgadmin/static/js/SchemaView/base_schema.ui.js b/web/pgadmin/static/js/SchemaView/base_schema.ui.js new file mode 100644 index 000000000..fb01a24d2 --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/base_schema.ui.js @@ -0,0 +1,91 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +/* This is the base schema class for SchemaView. + * A UI schema must inherit this to use SchemaView for UI. + */ +export default class BaseUISchema { + constructor(defaults) { + /* Pass the initial data to constructor so that + they will set to defaults */ + this._defaults = defaults; + + this.keys = null; // If set, other fields except keys will be filtered + this.informText = null; // Inform text to show after save, this only saves it + this._top = null; + } + + /* Top schema is helpful if this is used as child */ + set top(val) { + this._top = val; + } + + get top() { + /* If no top, I'm the top */ + return this._top || this; + } + + /* The original data before any changes */ + set origData(val) { + this._origData = val; + } + + get origData() { + return this._origData || {}; + } + + /* Property allows to restrict setting this later */ + get defaults() { + return this._defaults || {}; + } + + /* ID key for the view state */ + get idAttribute() { + return 'id'; + } + + /* Schema fields, to be defined by inherited UI schema. Override this */ + get baseFields() { + throw new Error('Property method \'baseFields()\' must be implemented.'); + } + + /* Used by schema view component. Do not override this, it will + concat base fields with extraFields. + */ + get fields() { + /* Select only keys if specified */ + return this.baseFields + .filter((field)=>this.keys ? this.keys.indexOf(field.id) > -1 : true); + } + + /* Check if current data is new or existing */ + isNew(state) { + if(_.has(state, this.idAttribute)) { + return _.isUndefined(state[this.idAttribute]) + || _.isNull(state[this.idAttribute]); + } + /* Nested collection rows may or may not have idAttribute. + So to decide whether row is new or not set, the cid starts with + nn (not new) for existing rows. Newly added will start with 'c' (created) + */ + if(_.has(state, 'cid')) { + return !state.cid.startsWith('nn'); + } + return true; + } + + /* Called by SchemaView to validate data, return true indicates invalid. + validate will receive two params state and setError func + Eg - setError('fieldname', 'Some error'). + And return true if invalid, otherwise false. + */ + validate() { + return false; + } +} diff --git a/web/pgadmin/static/js/SchemaView/index.jsx b/web/pgadmin/static/js/SchemaView/index.jsx new file mode 100644 index 000000000..c3106b36c --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/index.jsx @@ -0,0 +1,663 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { Box, makeStyles } from '@material-ui/core'; +import {Accordion, AccordionSummary, AccordionDetails} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import SaveIcon from '@material-ui/icons/Save'; +import SettingsBackupRestoreIcon from '@material-ui/icons/SettingsBackupRestore'; +import CloseIcon from '@material-ui/icons/Close'; +import InfoIcon from '@material-ui/icons/InfoRounded'; +import HelpIcon from '@material-ui/icons/HelpRounded'; +import EditIcon from '@material-ui/icons/Edit'; +import diffArray from 'diff-arrays-of-objects'; +import _ from 'lodash'; + +import {FormFooterMessage, MESSAGE_TYPE } from 'sources/components/FormComponents'; +import Theme from 'sources/Theme'; +import { PrimaryButton, DefaultButton, PgIconButton } from 'sources/components/Buttons'; +import Loader from 'sources/components/Loader'; +import { minMaxValidator, numberValidator, integerValidator, emptyValidator, checkUniqueCol } from '../validators'; +import { MappedFormControl } from './MappedControl'; +import gettext from 'sources/gettext'; +import BaseUISchema from 'sources/SchemaView/base_schema.ui'; +import FormView from './FormView'; +import { pgAlertify } from '../helpers/legacyConnector'; +import { evalFunc } from 'sources/utils'; +import PropTypes from 'prop-types'; +import CustomPropTypes from '../custom_prop_types'; +import { parseApiError } from '../api_instance'; + +const useDialogStyles = makeStyles((theme)=>({ + root: { + display: 'flex', + flexDirection: 'column', + height: '100%', + }, + form: { + flexGrow: 1, + position: 'relative', + minHeight: 0, + display: 'flex', + flexDirection: 'column', + }, + footer: { + padding: theme.spacing(1), + background: theme.otherVars.headerBg, + display: 'flex', + zIndex: 1010, + ...theme.mixins.panelBorder.top, + }, + mappedControl: { + paddingBottom: theme.spacing(1), + }, + buttonMargin: { + marginRight: '0.5rem', + }, +})); + +/* Compare the sessData with schema.origData +schema.origData is set to incoming or default data +*/ +function getChangedData(topSchema, mode, sessData, stringify=false) { + let changedData = {}; + let isEdit = mode === 'edit'; + + /* The comparator and setter */ + const attrChanged = (currPath, change, force=false)=>{ + let origVal = _.get(topSchema.origData, currPath); + let sessVal = _.get(sessData, currPath); + let attrDefined = !_.isUndefined(origVal) && !_.isUndefined(sessVal) && !_.isNull(origVal) && !_.isNull(sessVal); + + /* If the orig value was null and new one is empty string, then its a "no change" */ + /* If the orig value and new value are of different datatype but of same value(numeric) "no change" */ + if ((_.isEqual(origVal, sessVal) + || ((origVal === null || _.isUndefined(origVal)) && sessVal === '') + || (attrDefined ? _.isEqual(origVal.toString(), sessVal.toString()) : false)) + && !force) { + return; + } else { + change = change || _.get(sessData, currPath); + _.set(changedData, currPath, stringify ? JSON.stringify(change) : change); + } + }; + + /* Will be called recursively as data can be nested */ + const parseChanges = (schema, accessPath, changedData)=>{ + schema.fields.forEach((field)=>{ + if(field.type === 'nested-tab') { + /* its nested */ + parseChanges(field.schema, accessPath, changedData); + } else { + let currPath = accessPath.concat(field.id); + /* Check for changes only if its in edit mode, otherwise everything is changed */ + if(isEdit && !_.isEqual(_.get(topSchema.origData, currPath), _.get(sessData, currPath))) { + let change = null; + if(field.type === 'collection') { + /* Use diffArray package to get the array diff and extract the info + cid is used to identify the rows uniquely */ + const changeDiff = diffArray( + _.get(topSchema.origData, currPath), + _.get(sessData, currPath), + 'cid' + ); + change = {}; + if(changeDiff.added.length > 0) { + change['added'] = cleanCid(changeDiff.added); + } + if(changeDiff.removed.length > 0) { + change['deleted'] = cleanCid(changeDiff.removed.map((row)=>{ + /* Deleted records should be original, not the changed */ + return _.find(_.get(topSchema.origData, currPath), ['cid', row.cid]); + })); + } + if(changeDiff.updated.length > 0) { + change['changed'] = cleanCid(changeDiff.updated); + } + if(Object.keys(change).length > 0) { + attrChanged(currPath, change, true); + } + } else { + attrChanged(currPath); + } + } else if(!isEdit) { + if(field.type === 'collection') { + let change = cleanCid(_.get(sessData, currPath)); + attrChanged(currPath, change); + } else { + attrChanged(currPath); + } + } + } + }); + }; + + parseChanges(topSchema, [], changedData); + return changedData; +} + +function validateSchema(schema, sessData, setError) { + sessData = sessData || {}; + for(let field of schema.fields) { + /* Skip id validation */ + if(schema.idAttribute == field.id) { + continue; + } + /* If the field is has nested schema then validate the schema */ + if(field.schema && (field.schema instanceof BaseUISchema)) { + /* A collection is an array */ + if(field.type === 'collection') { + let rows = sessData[field.id] || []; + + /* Validate duplicate rows */ + let dupInd = checkUniqueCol(rows, field.uniqueCol); + if(dupInd > 0) { + let uniqueColNames = _.filter(field.schema.fields, (uf)=>field.uniqueCol.indexOf(uf.id) > -1) + .map((uf)=>uf.label).join(', '); + setError(field.uniqueCol[0], gettext('%s in %s must be unique.', uniqueColNames, field.label)); + return true; + } + /* Loop through data */ + for(const row of rows) { + if(validateSchema(field.schema, row, setError)) { + return true; + } + } + } else { + /* A nested schema ? Recurse */ + if(validateSchema(field.schema, sessData, setError)) { + return true; + } + } + } else { + /* Normal field, default validations */ + let value = sessData[field.id]; + let message = null; + if(field.noEmpty) { + message = emptyValidator(field.label, value); + } + if(!message && (field.type == 'int' || field.type == 'numeric')) { + message = minMaxValidator(field.label, value, field.min, field.max); + } + if(!message && field.type == 'int') { + message = integerValidator(field.label, value); + } else if(!message && field.type == 'numeric') { + message = numberValidator(field.label, value); + } + if(message) { + setError(field.id, message); + return true; + } + } + } + return schema.validate(sessData, setError); +} + +export const SCHEMA_STATE_ACTIONS = { + INIT: 'init', + SET_VALUE: 'set_value', + ADD_ROW: 'add_row', + DELETE_ROW: 'delete_row', + RERENDER: 'rerender', +}; + +/* The main function which manipulates the session state based on actions */ +/* +The state is managed based on path array of a particular key +For Eg. if the state is +{ + key1: { + ckey1: [ + {a: 0, b: 0}, + {a: 1, b: 1} + ] + } +} +The path for b in first row will be [key1, ckey1, 0, b] +The path for second row of ckey1 will be [key1, ckey1, 1] +The path for key1 is [key1] +The state starts with path [] +*/ +const sessDataReducer = (state, action)=>{ + let data = _.cloneDeep(state); + let rows, cid; + switch(action.type) { + case SCHEMA_STATE_ACTIONS.INIT: + data = action.payload; + break; + case SCHEMA_STATE_ACTIONS.SET_VALUE: + _.set(data, action.path, action.value); + /* If there is any dep listeners get the changes */ + if(action.depChange) { + _.set(data, action.depChange(data)); + } + break; + case SCHEMA_STATE_ACTIONS.ADD_ROW: + /* Create id to identify a row uniquely, usefull when getting diff */ + cid = _.uniqueId('c'); + action.value['cid'] = cid; + rows = (_.get(data, action.path)||[]).concat(action.value); + _.set(data, action.path, rows); + break; + case SCHEMA_STATE_ACTIONS.DELETE_ROW: + rows = _.get(data, action.path)||[]; + rows.splice(action.value, 1); + _.set(data, action.path, rows); + break; + } + return data; +}; + +/* Remove cid key added by prepareData */ +function cleanCid(coll) { + if(!coll) { + return coll; + } + return coll.map((o)=>_.pickBy(o, (v, k)=>k!='cid')); +} + +function prepareData(origData) { + _.forIn(origData, function (val) { + if (_.isArray(val)) { + val.forEach(function(el) { + if (_.isObject(el)) { + /* The each row in collection need to have an id to identify them uniquely + This helps in easily getting what has changed */ + /* Nested collection rows may or may not have idAttribute. + So to decide whether row is new or not set, the cid starts with + nn (not new) for existing rows. Newly added will start with 'c' (created) + */ + el['cid'] = _.uniqueId('nn'); + prepareData(el); + } + }); + } + if (_.isObject(val)) { + prepareData(val); + } + }); + return origData; +} + +/* If its the dialog */ +function SchemaDialogView({ + getInitData, viewHelperProps, schema={}, ...props}) { + const classes = useDialogStyles(); + /* Some useful states */ + const [dirty, setDirty] = useState(false); + /* formErr has 2 keys - name and message. + Footer message will be displayed if message is set. + */ + const [formErr, setFormErr] = useState({}); + const [loaderText, setLoaderText] = useState(''); + const [saving, setSaving] = useState(false); + const [sqlTabActive, setSqlTabActive] = useState(false); + const [formReady, setFormReady] = useState(false); + const firstEleRef = useRef(); + const isNew = schema.isNew(schema.origData); + /* The session data */ + const [sessData, sessDispatch] = useReducer(sessDataReducer, {}); + + useEffect(()=>{ + /* if sessData changes, validate the schema */ + if(!formReady) return; + let isNotValid = validateSchema(schema, sessData, (name, message)=>{ + if(message) { + setFormErr({ + name: name, + message: message, + }); + } + }); + if(!isNotValid) setFormErr({}); + + /* check if anything changed */ + let dataChanged = Object.keys(getChangedData(schema, viewHelperProps.mode, sessData)).length > 0; + setDirty(dataChanged); + + /* tell the callbacks the data has changed */ + props.onDataChange && props.onDataChange(dataChanged); + }, [sessData]); + + useEffect(()=>{ + /* Docker on load focusses itself, so our focus should execute later */ + let focusTimeout = setTimeout(()=>{ + firstEleRef.current && firstEleRef.current.focus(); + }, 250); + + /* Re-triggering focus on already focussed loses the focus */ + if(viewHelperProps.mode === 'edit') { + setLoaderText('Loading...'); + /* If its an edit mode, get the initial data using getInitData + getInitData should be a promise */ + if(!getInitData) { + throw new Error('getInitData must be passed for edit'); + } + getInitData && getInitData().then((data)=>{firstEleRef.current; + data = data || {}; + /* Set the origData to incoming data, useful for comparing and reset */ + schema.origData = prepareData(data || {}); + sessDispatch({ + type: SCHEMA_STATE_ACTIONS.INIT, + payload: schema.origData, + }); + setFormReady(true); + setLoaderText(''); + + }); + } else { + /* Use the defaults as the initital data */ + schema.origData = prepareData(schema.defaults); + sessDispatch({ + type: SCHEMA_STATE_ACTIONS.INIT, + payload: schema.origData, + }); + setFormReady(true); + setLoaderText(''); + } + + /* Clear the focus timeout it unmounted */ + return ()=>clearTimeout(focusTimeout); + }, []); + + const onResetClick = ()=>{ + const resetIt = ()=>{ + sessDispatch({ + type: SCHEMA_STATE_ACTIONS.INIT, + payload: schema.origData, + }); + return true; + }; + /* Confirm before reset */ + if(props.confirmOnCloseReset) { + pgAlertify().confirm( + gettext('Warning'), + gettext('Changes will be lost. Are you sure you want to reset?'), + resetIt, + function() { + return true; + } + ).set('labels', { + ok: gettext('Yes'), + cancel: gettext('No'), + }).show(); + } else { + resetIt(); + } + }; + + const onSaveClick = ()=>{ + setSaving(true); + setLoaderText('Saving...'); + /* Get the changed data */ + let data = getChangedData(schema, viewHelperProps.mode, sessData); + + /* Add the id when in edit mode */ + if(viewHelperProps.mode === 'edit') { + data[schema.idAttribute] = schema.origData[schema.idAttribute]; + } else { + /* If new then merge the changed data with origData */ + data = _.merge(schema.origData, data); + } + props.onSave(isNew, data) + .then(()=>{ + if(schema.informText) { + pgAlertify().alert( + gettext('Warning'), + schema.informText, + ); + } + }).catch((err)=>{ + setFormErr({ + name: 'apierror', + message: parseApiError(err), + }); + }).finally(()=>{ + setSaving(false); + setLoaderText(''); + }); + }; + + const onErrClose = useCallback(()=>{ + /* Unset the error message, but not the name */ + setFormErr((prev)=>({ + ...prev, + message: '', + })); + }); + + const getSQLValue = ()=>{ + /* Called when SQL tab is active */ + if(dirty) { + if(!formErr.name) { + let changeData = getChangedData(schema, viewHelperProps.mode, sessData, true); + /* Call the passed incoming getSQLValue func to get the SQL + return of getSQLValue should be a promise. + */ + return props.getSQLValue(isNew, changeData); + } else { + return Promise.resolve('-- ' + gettext('Definition incomplete.')); + } + } else { + return Promise.resolve('-- ' + gettext('No updates.')); + } + }; + + /* I am Groot */ + return ( + + + + setSqlTabActive(sqlActive)} + firstEleRef={firstEleRef} /> + + + + {useMemo(()=> + props.onHelp(true, isNew)} icon={} + disabled={props.disableSqlHelp} className={classes.buttonMargin} title="SQL help for this object type."/> + props.onHelp(false, isNew)} icon={} title="Help for this dialog."/> + , [])} + + } className={classes.buttonMargin}> + {gettext('Close')} + + } disabled={!dirty || saving} className={classes.buttonMargin}> + {gettext('Reset')} + + } disabled={!dirty || saving || Boolean(formErr.name) || !formReady}> + {gettext('Save')} + + + + + ); +} + +SchemaDialogView.propTypes = { + getInitData: PropTypes.func, + viewHelperProps: PropTypes.shape({ + mode: PropTypes.string.isRequired, + serverInfo: PropTypes.shape({ + type: PropTypes.string, + version: PropTypes.number, + }), + inCatalog: PropTypes.bool, + }).isRequired, + schema: CustomPropTypes.schemaUI, + onSave: PropTypes.func, + onClose: PropTypes.func, + onHelp: PropTypes.func, + onDataChange: PropTypes.func, + confirmOnCloseReset: PropTypes.bool, + hasSQL: PropTypes.bool, + getSQLValue: PropTypes.func, + disableSqlHelp: PropTypes.bool, +}; + +const usePropsStyles = makeStyles((theme)=>({ + root: { + height: '100%', + minHeight: 0, + display: 'flex', + flexDirection: 'column' + }, + controlRow: { + paddingBottom: theme.spacing(1), + }, + form: { + padding: theme.spacing(1), + overflow: 'auto', + flexGrow: 1, + }, + toolbar: { + padding: theme.spacing(0.5), + background: theme.palette.background.default, + ...theme.mixins.panelBorder.bottom, + }, + buttonMargin: { + marginRight: '0.5rem', + }, +})); + +/* If its the properties tab */ +function SchemaPropertiesView({ + getInitData, viewHelperProps, schema={}, ...props}) { + const classes = usePropsStyles(); + let defaultTab = 'General'; + let tabs = {}; + const [origData, setOrigData] = useState({}); + const [loaderText, setLoaderText] = useState(''); + + useEffect(()=>{ + setLoaderText('Loading...'); + getInitData().then((data)=>{ + data = data || {}; + setOrigData(data || {}); + setLoaderText(''); + }); + }, [getInitData]); + + /* A simple loop to get all the controls for the fields */ + schema.fields.forEach((f)=>{ + let {visible, disabled, group, readonly, ...field} = f; + group = group || defaultTab; + + let verInLimit = (_.isUndefined(viewHelperProps.serverInfo) ? true : + ((_.isUndefined(field.server_type) ? true : + (viewHelperProps.serverInfo.type in field.server_type)) && + (_.isUndefined(field.min_version) ? true : + (viewHelperProps.serverInfo.version >= field.min_version)) && + (_.isUndefined(field.max_version) ? true : + (viewHelperProps.serverInfo.version <= field.max_version)))); + + let _visible = true; + if(field.mode) { + _visible = (field.mode.indexOf(viewHelperProps.mode) > -1); + } + if(_visible && visible) { + _visible = evalFunc(visible, origData); + } + + disabled = evalFunc(disabled, origData); + readonly = true; + if(_visible && verInLimit) { + if(!tabs[group]) tabs[group] = []; + tabs[group].push( + + ); + } + }); + + return ( + + + + props.onHelp(true, false)} icon={} disabled={props.disableSqlHelp} + title="SQL help for this object type." className={classes.buttonMargin} /> + } title="Edit the object" /> + + + + {Object.keys(tabs).map((tabName)=>{ + let id = tabName.replace(' ', ''); + return ( + + } + aria-controls={`${id}-content`} + id={`${id}-header`} + > + {tabName} + + + + {tabs[tabName]} + + + + ); + })} + + + + ); +} + +SchemaPropertiesView.propTypes = { + getInitData: PropTypes.func.isRequired, + viewHelperProps: PropTypes.shape({ + mode: PropTypes.string.isRequired, + serverInfo: PropTypes.shape({ + type: PropTypes.string, + version: PropTypes.number, + }), + inCatalog: PropTypes.bool, + }).isRequired, + schema: CustomPropTypes.schemaUI, + onHelp: PropTypes.func, + disableSqlHelp: PropTypes.bool, + onEdit: PropTypes.func, +}; + +export default function SchemaView({formType, ...props}) { + /* Switch the view based on formType */ + if(formType === 'tab') { + return ( + + + + ); + } + return ( + + + + ); +} + +SchemaView.propTypes = { + formType: PropTypes.oneOf(['tab', 'dialog']), +}; diff --git a/web/pgadmin/static/js/Theme/dark.js b/web/pgadmin/static/js/Theme/dark.js new file mode 100644 index 000000000..ab8414647 --- /dev/null +++ b/web/pgadmin/static/js/Theme/dark.js @@ -0,0 +1,89 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +/* The dark theme */ +import { createMuiTheme } from '@material-ui/core/styles'; +import { darken } from '@material-ui/core/styles/colorManipulator'; + +export default function(basicSettings) { + return createMuiTheme(basicSettings, { + palette: { + default: { + main: '#6b6b6b', + contrastText: '#fff', + borderColor: '#2e2e2e', + disabledBorderColor: '#2e2e2e', + disabledContrastText: '#fff', + hoverMain: '#303030', + hoverContrastText: '#fff', + hoverBorderColor: '#2e2e2e', + }, + primary: { + main: '#234d6e', + light: '#d6effc', + contrastText: '#fff', + hoverMain: darken('#234d6e', 0.25), + hoverBorderColor: darken('#234d6e', 0.25), + disabledMain: '#234d6e', + }, + success: { + main: '#26852B', + light: '#2B472C', + contrastText: '#000', + }, + error: { + main: '#da6758', + light: '#212121', + contrastText: '#fff', + lighter: '#212121', + }, + warning: { + main: '#eea236', + light: '#b18d5a', + contrastText: '#fff', + }, + info: { + main: '#fde74c', + }, + grey: { + '200': '#424242', + '400': '#303030', + '600': '#2e2e2e', + '800': '#212121', + }, + text: { + primary: '#d4d4d4', + }, + background: { + paper: '#212121', + default: '#212121', + } + }, + custom: { + icon: { + main: '#6b6b6b', + contrastText: '#fff', + borderColor: '#2e2e2e', + disabledMain: '#6b6b6b', + disabledContrastText: '#fff', + disabledBorderColor: '#2e2e2e', + hoverMain: '#303030', + hoverContrastText: '#fff', + } + }, + otherVars: { + borderColor: '#4a4a4a', + inputBorderColor: '#6b6b6b', + inputDisabledBg: 'inherit', + headerBg: '#424242', + activeColor: '#d4d4d4', + tableBg: '#424242', + } + }); +} diff --git a/web/pgadmin/static/js/Theme/high_contrast.js b/web/pgadmin/static/js/Theme/high_contrast.js new file mode 100644 index 000000000..898589d5b --- /dev/null +++ b/web/pgadmin/static/js/Theme/high_contrast.js @@ -0,0 +1,87 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +/* The dark theme */ +import { createMuiTheme } from '@material-ui/core/styles'; + +export default function(basicSettings) { + return createMuiTheme(basicSettings, { + palette: { + default: { + main: 'transparent', + contrastText: '#84d6ff', + borderColor: '#84d6ff', + disabledBorderColor: '#8B9CAD', + disabledContrastText: '#8B9CAD', + hoverMain: 'transparent', + hoverContrastText: '#fff', + hoverBorderColor: '#fff', + }, + primary: { + main: '#84D6FF', + light: '#84D6FF', + contrastText: '#010B15', + hoverMain: '#fff', + hoverBorderColor: '#fff', + disabledMain: '#8B9CAD', + }, + success: { + main: '#45D48A', + light: '#010B15', + contrastText: '#000', + }, + error: { + main: '#EE7A55', + light: '#EE7A55', + contrastText: '#010B15', + }, + warning: { + main: '#F4D35E', + light: '#F4D35E', + contrastText: '#010B15', + }, + info: { + main: '#fde74c', + }, + grey: { + '200': '#8B9CAD', + '400': '#2D3A48', + '600': '#1F2932', + '800': '#010B15', + }, + text: { + primary: '#fff', + }, + background: { + paper: '#010B15', + default: '#010B15', + }, + }, + custom: { + icon: { + main: '#010B15', + contrastText: '#fff', + borderColor: '#fff', + disabledMain: '#1F2932', + disabledContrastText: '#8B9CAD', + disabledBorderColor: '#8B9CAD', + hoverMain: '#fff', + hoverContrastText: '#010B15', + } + }, + otherVars: { + borderColor: '#4a4a4a', + inputBorderColor: '#6b6b6b', + inputDisabledBg: '#1F2932', + headerBg: '#010B15', + activeColor: '#d4d4d4', + tableBg: '#010B15', + } + }); +} diff --git a/web/pgadmin/static/js/Theme/index.jsx b/web/pgadmin/static/js/Theme/index.jsx new file mode 100644 index 000000000..57bf72964 --- /dev/null +++ b/web/pgadmin/static/js/Theme/index.jsx @@ -0,0 +1,353 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +/* The complete styling file for Material-UI components used + * This will become the main theme file for pgAdmin. All the + * custom themes info will come here. + */ + +import React, { useMemo } from 'react'; +import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles'; +import CustomPropTypes from '../custom_prop_types'; + +import getStandardTheme from './standard'; +import getDarkTheme from './dark'; +import getHightContrastTheme from './high_contrast'; + +/* Common settings across all themes */ +let basicSettings = createMuiTheme(); +basicSettings = createMuiTheme(basicSettings, { + typography: { + fontSize: 14, + htmlFontSize: 14, + }, + shape: { + borderRadius: 4, + }, + overrides: { + MuiTabs: { + root: { + minHeight: 0, + } + }, + PrivateTabIndicator: { + root: { + height: '3px', + transition: basicSettings.transitions.create(['all'], {duration: '150ms'}), + } + }, + MuiTab: { + root: { + textTransform: 'none', + minHeight: 0, + padding: '5px 10px', + [basicSettings.breakpoints.up('xs')]: { + minWidth: 0, + }, + [basicSettings.breakpoints.up('sm')]: { + minWidth: 0, + }, + [basicSettings.breakpoints.up('md')]: { + minWidth: 0, + }, + [basicSettings.breakpoints.up('lg')]: { + minWidth: 0, + }, + }, + textColorInherit: { + textTransform: 'none', + opacity: 1, + } + }, + MuiButton: { + root: { + textTransform: 'none,', + padding: basicSettings.spacing(0.5, 1.5), + '&.Mui-disabled': { + opacity: 0.65, + } + }, + contained: { + boxShadow: 'none', + '&:hover': { + boxShadow: 'none', + } + }, + outlined: { + padding: basicSettings.spacing(0.375, 1), + }, + startIcon: { + marginRight: basicSettings.spacing(0.5), + } + }, + MuiOutlinedInput: { + multiline: { + padding: '0px', + }, + input: { + padding: basicSettings.spacing(0.75, 1.5), + borderRadius: 'inherit', + }, + inputMultiline: { + padding: basicSettings.spacing(0.75, 1.5), + }, + adornedEnd: { + paddingRight: basicSettings.spacing(1.5), + } + }, + MuiToggleButton: { + root: { + textTransform: 'none,', + padding: basicSettings.spacing(0.5, 2.5, 0.5, 0.5), + color: 'abc', + '&:hover':{ + backgroundColor: 'abc', + }, + '&$selected': { + color: 'abc', + backgroundColor: 'abc', + '&:hover':{ + backgroundColor: 'abc', + } + } + } + }, + MuiAccordion: { + root: { + boxShadow: 'none', + } + }, + MuiAccordionSummary: { + root: { + minHeight: 0, + '&.Mui-expanded': { + minHeight: 0, + }, + padding: basicSettings.spacing(0, 1), + fontWeight: basicSettings.typography.fontWeightBold, + }, + content: { + margin: basicSettings.spacing(1), + '&.Mui-expanded': { + margin: basicSettings.spacing(1), + } + }, + expandIcon: { + order: -1, + } + }, + MuiAccordionDetails: { + root: { + padding: basicSettings.spacing(1), + } + }, + MuiFormControlLabel: { + root: { + marginBottom: 0, + marginLeft: 0, + marginRight: 0, + } + }, + MuiFormHelperText: { + root: { + fontSize: '1em', + } + }, + MuiTypography: { + body1: { + fontSize: '1em', + } + } + }, + transitions: { + duration: { + shortest: 50, + shorter: 100, + short: 150, + standard: 200, + complex: 175, + enteringScreen: 125, + leavingScreen: 95, + } + }, + props: { + MuiTextField: { + variant: 'outlined', + }, + MuiButton: { + disableTouchRipple: true, + }, + MuiIconButton: { + size: 'small', + disableTouchRipple: true, + }, + MuiAccordion: { + defaultExpanded: true, + }, + MuiTab: { + textColor: 'inherit', + } + }, +}); + +/* Get the final theme after merging base theme with selected theme */ +function getFinalTheme(baseTheme) { + let mixins = { + panelBorder: { + border: '1px solid '+baseTheme.otherVars.borderColor, + top: { + borderTop: '1px solid '+baseTheme.otherVars.borderColor, + }, + bottom: { + borderBottom: '1px solid '+baseTheme.otherVars.borderColor, + }, + right: { + borderRight: '1px solid '+baseTheme.otherVars.borderColor, + } + }, + nodeIcon: { + backgroundPosition: 'center', + padding: baseTheme.spacing(0, 1.5), + } + }; + + return createMuiTheme({ + mixins: mixins, + overrides: { + MuiOutlinedInput: { + notchedOutline: { + borderColor: baseTheme.otherVars.inputBorderColor, + } + }, + MuiTabs: { + root: { + backgroundColor: baseTheme.otherVars.headerBg, + ...mixins.panelBorder.bottom + }, + indicator: { + backgroundColor: baseTheme.otherVars.activeColor, + } + }, + MuiFormLabel: { + root: { + color: baseTheme.palette.text.primary, + fontSize: baseTheme.typography.fontSize, + }, + asterisk: { + color: baseTheme.palette.error.main, + } + }, + MuiInputBase: { + root: { + backgroundColor: baseTheme.palette.background.default, + }, + inputMultiline: { + fontSize: baseTheme.typography.fontSize, + height: 'unset', + backgroundColor: baseTheme.palette.background.default, + '&[readonly]': { + backgroundColor: baseTheme.palette.inputDisabledBg, + } + }, + input: { + fontSize: baseTheme.typography.fontSize, + height: 'unset', + backgroundColor: baseTheme.palette.background.default, + '&[readonly]': { + backgroundColor: baseTheme.otherVars.inputDisabledBg, + } + } + }, + MuiIconButton: { + root: { + color: baseTheme.palette.text.primary, + } + }, + MuiAccordion: { + root: { + ...mixins.panelBorder, + } + }, + MuiAccordionSummary: { + root: { + ...mixins.panelBorder.bottom, + backgroundColor: baseTheme.otherVars.headerBg, + } + }, + MuiSwitch: { + root: { + width: 54, + height: 28, + padding: '7px 12px', + }, + switchBase: { + padding: baseTheme.spacing(0.5), + '&.Mui-checked': { + color: baseTheme.palette.success.main, + transform: 'translateX(24px)', + }, + '&.Mui-checked + .MuiSwitch-track': { + backgroundColor: baseTheme.palette.success.light, + }, + }, + }, + MuiCheckbox: { + root: { + padding: '0px', + color: baseTheme.otherVars.inputBorderColor, + } + }, + MuiToggleButton: { + root: { + paddingRight: baseTheme.spacing(2.5), + paddingLeft: baseTheme.spacing(0.5), + color: 'abc', + '&:hover':{ + backgroundColor: 'abc', + }, + '&$selected': { + color: 'abc', + backgroundColor: 'abc', + '&:hover':{ + backgroundColor: 'abc', + } + } + } + }, + } + }, baseTheme); +} + +/* Theme wrapper used by DOM containers to apply theme */ +/* In future, this will be moved to App container */ +export default function Theme(props) { + const theme = useMemo(()=>{ + /* We'll remove this in future, we can get the value from preferences directly */ + let themeName = document.querySelector('link[data-theme]')?.getAttribute('data-theme'); + let baseTheme = getStandardTheme(basicSettings); + switch(themeName) { + case 'dark': + baseTheme = getDarkTheme(baseTheme); + break; + case 'high_contrast': + baseTheme = getHightContrastTheme(baseTheme); + break; + } + return getFinalTheme(baseTheme); + }, []); + return ( + + {props.children} + + ); +} + +Theme.propTypes = { + children: CustomPropTypes.children, +}; diff --git a/web/pgadmin/static/js/Theme/standard.js b/web/pgadmin/static/js/Theme/standard.js new file mode 100644 index 000000000..46586f72e --- /dev/null +++ b/web/pgadmin/static/js/Theme/standard.js @@ -0,0 +1,95 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +/* The standard theme */ +import { createMuiTheme } from '@material-ui/core/styles'; +import { fade, darken } from '@material-ui/core/styles/colorManipulator'; + +export default function(basicSettings) { + return createMuiTheme(basicSettings, { + palette: { + default: { + main: '#fff', + contrastText: '#222', + borderColor: '#bac1cd', + disabledBorderColor: '#bac1cd', + disabledContrastText: '#222', + hoverMain: '#ebeef3', + hoverContrastText: '#222', + hoverBorderColor: '#bac1cd', + }, + primary: { + main: '#326690', + light: '#d6effc', + contrastText: '#fff', + hoverMain: darken('#326690', 0.25), + hoverBorderColor: darken('#326690', 0.25), + disabledMain: '#326690', + }, + success: { + main: '#26852B', + light: '#D9ECDA', + contrastText: '#000', + }, + error: { + main: '#CC0000', + light: '#FAECEC', + contrastText: '#fff', + }, + warning: { + main: '#eea236', + light: '#fce5c5', + contrastText: '#000', + }, + info: { + main: '#fde74c', + }, + grey: { + '200': '#f3f5f9', + '400': '#ebeef3', + '600': '#bac1cd', + '800': '#848ea0', + }, + text: { + primary: '#222', + }, + background: { + paper: '#fff', + default: '#fff', + }, + }, + custom: { + icon: { + main: '#fff', + contrastText: '#222', + borderColor: '#bac1cd', + disabledMain: '#fff', + disabledContrastText: '#222', + disabledBorderColor: '#bac1cd', + hoverMain: '#ebeef3', + hoverContrastText: '#222', + } + }, + otherVars: { + reactSelect: { + padding: '5px 8px', + }, + borderColor: '#dde0e6', + loader: { + backgroundColor: fade('#000', 0.65), + color: '#fff', + }, + inputBorderColor: '#dde0e6', + inputDisabledBg: '#f3f5f9', + headerBg: '#fff', + activeColor: '#326690', + tableBg: '#fff', + } + }); +} diff --git a/web/pgadmin/static/js/api_instance.js b/web/pgadmin/static/js/api_instance.js new file mode 100644 index 000000000..30e2f707a --- /dev/null +++ b/web/pgadmin/static/js/api_instance.js @@ -0,0 +1,45 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import pgAdmin from 'sources/pgadmin'; +import gettext from 'sources/gettext'; +import axios from 'axios'; + +/* Get the axios instance to call back end APIs. +Do not import axios directly, instead use this */ +export default function getApiInstance(headers={}) { + const api = axios.create({ + headers: { + 'Content-type': 'application/json', + [pgAdmin.csrf_token_header]: pgAdmin.csrf_token, + ...headers, + } + }); + return api; +} + +export function parseApiError(error) { + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + if(error.response.headers['content-type'] == 'application/json') { + return error.response.data.errormsg; + } else { + return error.response.statusText; + } + } else if (error.request) { + // The request was made but no response was received + // `error.request` is an instance of XMLHttpRequest in the browser and an instance of + // http.ClientRequest in node.js + return gettext('Connection to pgAdmin server has been lost'); + } else { + // Something happened in setting up the request that triggered an Error + return error.message; + } +} diff --git a/web/pgadmin/static/js/components/Buttons.jsx b/web/pgadmin/static/js/components/Buttons.jsx new file mode 100644 index 000000000..981f90246 --- /dev/null +++ b/web/pgadmin/static/js/components/Buttons.jsx @@ -0,0 +1,106 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { Button, makeStyles, Tooltip } from '@material-ui/core'; +import React, { forwardRef } from 'react'; +import clsx from 'clsx'; +import PropTypes from 'prop-types'; +import CustomPropTypes from '../custom_prop_types'; + +const useStyles = makeStyles((theme)=>({ + primaryButton: { + '&.Mui-disabled': { + color: theme.palette.primary.contrastText, + backgroundColor: theme.palette.primary.disabledMain, + }, + '&:hover': { + backgroundColor: theme.palette.primary.hoverMain, + borderColor: theme.palette.primary.hoverBorderColor, + }, + }, + defaultButton: { + backgroundColor: theme.palette.default.main, + color: theme.palette.default.contrastText, + border: '1px solid '+theme.palette.default.borderColor, + '&.Mui-disabled': { + color: theme.palette.default.disabledContrastText, + borderColor: theme.palette.default.disabledBorderColor + }, + '&:hover': { + backgroundColor: theme.palette.default.hoverMain, + color: theme.palette.default.hoverContrastText, + borderColor: theme.palette.default.hoverBorderColor, + } + }, + iconButton: { + padding: '3px 6px', + borderColor: theme.custom.icon.borderColor, + color: theme.custom.icon.contrastText, + backgroundColor: theme.custom.icon.main, + '&.Mui-disabled': { + borderColor: theme.custom.icon.disabledBorderColor, + backgroundColor: theme.custom.icon.disabledMain, + color: theme.custom.icon.disabledContrastText, + }, + '&:hover': { + backgroundColor: theme.custom.icon.hoverMain, + color: theme.custom.icon.hoverContrastText, + } + } +})); + +/* pgAdmin primary button */ +export const PrimaryButton = forwardRef((props, ref)=>{ + let {children, className, ...otherProps} = props; + const classes = useStyles(); + + return ( + + ); +}); +PrimaryButton.displayName = 'PrimaryButton'; +PrimaryButton.propTypes = { + children: CustomPropTypes.children, + className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), +}; + +/* pgAdmin default button */ +export const DefaultButton = forwardRef((props, ref)=>{ + let {children, className, ...otherProps} = props; + const classes = useStyles(); + + return ( + + ); +}); +DefaultButton.displayName = 'DefaultButton'; +DefaultButton.propTypes = { + children: CustomPropTypes.children, + className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), +}; + +/* pgAdmin Icon button, takes Icon component as input */ +export const PgIconButton = forwardRef(({icon, title, className, ...props}, ref)=>{ + const classes = useStyles(); + + /* Tooltip does not work for disabled items */ + return ( + + + {icon} + + + ); +}); +PgIconButton.displayName = 'PgIconButton'; +PgIconButton.propTypes = { + icon: CustomPropTypes.children, + title: PropTypes.string.isRequired, + className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), +}; diff --git a/web/pgadmin/static/js/components/CodeMirror.jsx b/web/pgadmin/static/js/components/CodeMirror.jsx new file mode 100644 index 000000000..6fac7e2cd --- /dev/null +++ b/web/pgadmin/static/js/components/CodeMirror.jsx @@ -0,0 +1,40 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import React, { useEffect, useRef } from 'react'; +import {default as OrigCodeMirror} from 'bundled_codemirror'; +import PropTypes from 'prop-types'; + +/* React wrapper for CodeMirror */ +export default function CodeMirror({name, value, options}) { + const taRef = useRef(); + const cmObj = useRef(); + + useEffect(()=>{ + /* Create the object only once on mount */ + cmObj.current = new OrigCodeMirror.fromTextArea( + taRef.current, options); + }, []); + + useEffect(()=>{ + /* Refresh when value changes */ + if(cmObj.current) { + cmObj.current.setValue(value); + cmObj.current.refresh(); + } + }, [value]); + + return