Added React framework for the properties dialog and port Server Group, Server, and Database dialogs. 

Following changes done for the framework:
 - Framework for creating React based dynamic form view out of a pre-defined UI schema. Previously, it was based on Backform/Backbone.
 - The new framework and components will use MaterialUI as the base. Previously, Bootstrap/Backform/jQuery components were used.
 - The new code uses JSS instead of CSS since material UI and most modern React libraries also use JSS. In the future, this will allow us to change the theme in real-time without refresh.
 - 90% code covered by 80-85 new jasmine test cases.
 - Server group node UI Schema migration to new, with schema test cases.
 - Server node UI Schema migration to new, with schema test cases.
 - Database node UI Schema migration to new, with schema test cases.
 - Few other UI changes.

Fixes #6130
This commit is contained in:
Aditya Toshniwal 2021-06-29 14:33:36 +05:30 committed by Akshay Joshi
parent a10b0c7786
commit 764677431f
63 changed files with 8489 additions and 1181 deletions

View File

@ -18,6 +18,7 @@ New features
Housekeeping
************
| `Issue #6130 <https://redmine.postgresql.org/issues/6130>`_ - Added React framework for the properties dialog and port server group, server, and database dialogs.
Bug fixes
*********

View File

@ -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",
@ -114,12 +122,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",

View File

@ -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',
@ -308,30 +313,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!
@ -356,187 +387,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;
},
}),
});

View File

@ -0,0 +1,216 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
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;
}
},
},
];
}
}

View File

@ -0,0 +1,82 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
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;
}
}

View File

@ -0,0 +1,32 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
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,
}];
}
}

View File

@ -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});
getSchema: (treeNodeInfo, itemNodeData)=>{
let schema = new ServerSchema(
getNodeListById(pgBrowser.Nodes['server_group'], treeNodeInfo, itemNodeData),
{
gid: treeNodeInfo['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;
}
return schema;
},
}),
},{
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;
}
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;
},
}),
connection_lost: function(i, resp) {
if (pgBrowser.tree) {
var t = pgBrowser.tree,

View File

@ -0,0 +1,538 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
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;
}
}

View File

@ -0,0 +1,139 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
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,
}
}),
},
];
}
}

View File

@ -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))

View File

@ -0,0 +1,34 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
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,
}
];
}
}

View File

@ -7,6 +7,8 @@
//
//////////////////////////////////////////////////////////////
import {removeNodeView} from './node_view';
define([
'sources/gettext', 'jquery', 'underscore', 'sources/pgadmin',
'backbone', 'alertify', 'backform', 'backgrid', 'sources/browser/generate_url',
@ -239,6 +241,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);

View File

@ -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',
@ -1240,6 +1242,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');
@ -1484,6 +1502,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) {
@ -1678,10 +1718,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() {
@ -1705,7 +1745,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');
@ -1715,8 +1755,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);
@ -1748,10 +1788,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?'),

View File

@ -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);
}

View File

@ -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(
<SchemaView
formType={formType}
getInitData={initData}
schema={nodeObj.getSchema.call(nodeObj, treeNodeInfo, itemNodeData)}
viewHelperProps={viewHelperProps}
onSave={onSaveClick}
onClose={()=>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);
}

View File

@ -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."),

View File

@ -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,

View File

@ -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 (
<div>
{headerGroups.map((headerGroup, hi) => (
<div key={hi} {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column, ci) => (
<div key={ci} {...column.getHeaderProps()}>
<div {...(column.sortable ? column.getSortByToggleProps() : {})} className={clsx(classes.tableCell, classes.tableCellHeader)}>
{column.render('Header')}
<span>
{column.isSorted
? column.isSortedDesc
? ' 🔽'
: ' 🔼'
: ''}
</span>
</div>
{column.resizable &&
<div
{...column.getResizerProps()}
className={classes.resizer}
/>}
</div>
))}
</div>
))}
</div>
);
}
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(()=>
<>
<div {...row.getRowProps()} className="tr">
{row.cells.map((cell, ci) => {
return (
<div key={ci} {...cell.getCellProps()} className={classes.tableCell}>
{cell.render('Cell', {
reRenderRow: ()=>{setKey((currKey)=>!currKey);}
})}
</div>
);
})}
</div>
{
canExpand && row.isExpanded &&
<FormView key={row.index} value={row.original} viewHelperProps={viewHelperProps} formErr={formErr} dataDispatch={dataDispatch}
schema={schema} accessPath={accessPath} isNested={true}/>
}
</>
, 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: <>&nbsp;</>,
id: 'btn-edit',
accessor: ()=>{},
resizable: false,
sortable: false,
dataType: 'edit',
width: 30,
minWidth: '0',
Cell: ({row})=><PgIconButton data-test="expand-row" title={gettext('Edit row')} icon={<EditRoundedIcon />} 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: <>&nbsp;</>,
id: 'btn-delete',
accessor: ()=>{},
resizable: false,
sortable: false,
dataType: 'delete',
width: 30,
minWidth: '0',
Cell: ({row}) => {
return (
<PgIconButton data-test="delete-row" title={gettext('Delete row')} icon={<DeleteRoundedIcon />}
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 <MappedCellControl rowIndex={row.index} value={value}
row={row.original} {..._field}
readonly={_readonly}
disabled={disabled}
visible={_visible}
onCellChange={(value)=>{
/* 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 (
<Box className={containerClassName}>
<Box className={classes.grid}>
<Box className={classes.gridHeader}>
<Box className={classes.gridHeaderText}>{props.label}</Box>
<Box className={classes.gridControls}>
{props.canAdd && <PgIconButton data-test="add-row" title={gettext('Add row')} onClick={onAddClick} icon={<AddIcon />} className={classes.gridControlsButton} />}
</Box>
</Box>
<div {...getTableProps()} className={classes.table}>
<DataTableHeader headerGroups={headerGroups} />
<div {...getTableBodyProps()}>
{rows.map((row, i) => {
prepareRow(row);
return <DataTableRow key={i} row={row} totalRows={rows.length} canExpand={props.canEdit}
value={value} viewHelperProps={viewHelperProps} formErr={formErr} isResizing={isResizing}
schema={schema} accessPath={accessPath.concat([row.index])} dataDispatch={dataDispatch} />;
})}
</div>
</div>
</Box>
</Box>
);
}
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,
};

View File

@ -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 <InputSQL
value={sql}
options={{
readOnly: true,
}}
/>;
}
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(
<FormView key={`nested${tabs[group].length}`} value={value} viewHelperProps={viewHelperProps} formErr={formErr}
schema={field.schema} accessPath={accessPath} dataDispatch={dataDispatch} isNested={true} />
);
} 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(()=><DataGridView key={field.id} value={value[field.id]} viewHelperProps={viewHelperProps} formErr={formErr}
schema={field.schema} accessPath={accessPath.concat(field.id)} dataDispatch={dataDispatch} containerClassName={classes.controlRow}
{...field}/>, [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(()=><MappedFormControl
inputRef={(ele)=>{
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(()=><SQLTab key="sqltab" active={sqlTabActive} getSQLValue={getSQLValue} />, [sqlTabActive]),
];
tabsClassname[sqlTabName] = classes.fullSpace;
}
useEffect(()=>{
firstElement.current && firstElement.current.focus();
}, []);
useEffect(()=>{
onTabChange && onTabChange(tabValue, Object.keys(tabs)[tabValue], sqlTabActive);
}, [tabValue]);
return (
<>
<Box>
<Tabs
value={tabValue}
onChange={(event, selTabValue) => {
setTabValue(selTabValue);
}}
// indicatorColor="primary"
variant="scrollable"
scrollButtons="auto"
action={(ref)=>ref && ref.updateIndicator()}
>
{Object.keys(tabs).map((tabName)=>{
return <Tab key={tabName} label={tabName} />;
})}
</Tabs>
</Box>
{Object.keys(tabs).map((tabName, i)=>{
return (
<TabPanel key={tabName} value={tabValue} index={i} classNameRoot={isNested ? classes.fullSpace : tabsClassname[tabName]}>
{tabs[tabName]}
</TabPanel>
);
})}
</>);
}
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,
};

View File

@ -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 <FormInputText name={name} value={value} onChange={onIntChange} className={className} inputRef={inputRef} {...props}/>;
case 'text':
return <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props}/>;
case 'multiline':
return <FormInputText name={name} value={value} onChange={onTextChange} className={className}
inputRef={inputRef} controlProps={{multiline: true}} {...props}/>;
case 'password':
return <FormInputText name={name} value={value} onChange={onTextChange} className={className} type='password' inputRef={inputRef} {...props}/>;
case 'select':
return <FormInputSelect name={name} value={value} onChange={onTextChange} className={className} {...props} />;
case 'switch':
return <FormInputSwitch name={name} value={value}
onChange={(e)=>onTextChange(e.target.checked, e.target.name)} className={className}
{...props} />;
case 'checkbox':
return <FormInputCheckbox name={name} value={value}
onChange={(e)=>onTextChange(e.target.checked, e.target.name)} className={className}
{...props} />;
case 'toggle':
return <FormInputToggle name={name} value={value}
onChange={onTextChange} className={className}
{...props} />;
case 'color':
return <FormInputColor name={name} value={value} onChange={onTextChange} className={className} {...props} />;
case 'file':
return <FormInputFileSelect name={name} value={value} onChange={onTextChange} className={className} {...props} />;
case 'sql':
return <InputSQL name={name} value={value} onChange={onTextChange} className={className} {...props}/>;
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 <InputText name={name} value={value} onChange={onTextChange} {...props}/>;
case 'password':
return <InputText name={name} value={value} onChange={onTextChange} {...props}/>;
case 'select':
return <InputSelect name={name} value={value} onChange={onTextChange} optionsLoaded={optionsLoadedRerender} {...props}/>;
case 'switch':
return <InputSwitch name={name} value={value}
onChange={(e)=>onTextChange(e.target.checked, e.target.name)} {...props} />;
case 'privilege':
return <Privilege name={name} value={value} onChange={onTextChange} {...props}/>;
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 <MappedFormControlBase {..._.pick(newProps, _.union(ALLOWED_PROPS_FIELD_COMMON, ALLOWED_PROPS_FIELD_FORM))}/>;
};
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 <MappedCellControlBase {..._.pick(newProps, _.union(ALLOWED_PROPS_FIELD_COMMON, ALLOWED_PROPS_FIELD_CELL))}/>;
};

View File

@ -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;
}
}

View File

@ -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 (
<Box className={classes.root}>
<Box className={classes.form}>
<Loader message={loaderText}/>
<FormView value={sessData} viewHelperProps={viewHelperProps} formErr={formErr}
schema={schema} accessPath={[]} dataDispatch={sessDispatch}
hasSQLTab={props.hasSQL} getSQLValue={getSQLValue} onTabChange={(i, tabName, sqlActive)=>setSqlTabActive(sqlActive)}
firstEleRef={firstEleRef} />
<FormFooterMessage type={MESSAGE_TYPE.ERROR} message={sqlTabActive ? '' : formErr.message}
onClose={onErrClose} />
</Box>
<Box className={classes.footer}>
{useMemo(()=><Box>
<PgIconButton data-test="sql-help" onClick={()=>props.onHelp(true, isNew)} icon={<InfoIcon />}
disabled={props.disableSqlHelp} className={classes.buttonMargin} title="SQL help for this object type."/>
<PgIconButton data-test="dialog-help" onClick={()=>props.onHelp(false, isNew)} icon={<HelpIcon />} title="Help for this dialog."/>
</Box>, [])}
<Box marginLeft="auto">
<DefaultButton data-test="Close" onClick={props.onClose} startIcon={<CloseIcon />} className={classes.buttonMargin}>
{gettext('Close')}
</DefaultButton>
<DefaultButton data-test="Reset" onClick={onResetClick} startIcon={<SettingsBackupRestoreIcon />} disabled={!dirty || saving} className={classes.buttonMargin}>
{gettext('Reset')}
</DefaultButton>
<PrimaryButton data-test="Save" onClick={onSaveClick} startIcon={<SaveIcon />} disabled={!dirty || saving || Boolean(formErr.name) || !formReady}>
{gettext('Save')}
</PrimaryButton>
</Box>
</Box>
</Box>
);
}
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(
<MappedFormControl
key={field.id}
viewHelperProps={viewHelperProps}
name={field.id}
value={origData[field.id]}
readonly={readonly}
disabled={disabled}
visible={_visible}
{...field}
className={classes.controlRow}
/>
);
}
});
return (
<Box className={classes.root}>
<Loader message={loaderText}/>
<Box className={classes.toolbar}>
<PgIconButton
data-test="help" onClick={()=>props.onHelp(true, false)} icon={<InfoIcon />} disabled={props.disableSqlHelp}
title="SQL help for this object type." className={classes.buttonMargin} />
<PgIconButton data-test="edit"
onClick={props.onEdit} icon={<EditIcon />} title="Edit the object" />
</Box>
<Box className={classes.form}>
<Box>
{Object.keys(tabs).map((tabName)=>{
let id = tabName.replace(' ', '');
return (
<Accordion key={id}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`${id}-content`}
id={`${id}-header`}
>
{tabName}
</AccordionSummary>
<AccordionDetails>
<Box style={{width: '100%'}}>
{tabs[tabName]}
</Box>
</AccordionDetails>
</Accordion>
);
})}
</Box>
</Box>
</Box>
);
}
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 (
<Theme>
<SchemaPropertiesView {...props}/>
</Theme>
);
}
return (
<Theme>
<SchemaDialogView {...props}/>
</Theme>
);
}
SchemaView.propTypes = {
formType: PropTypes.oneOf(['tab', 'dialog']),
};

View File

@ -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',
}
});
}

View File

@ -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',
}
});
}

View File

@ -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 (
<ThemeProvider theme={theme}>
{props.children}
</ThemeProvider>
);
}
Theme.propTypes = {
children: CustomPropTypes.children,
};

View File

@ -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',
}
});
}

View File

@ -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;
}
}

View File

@ -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 (
<Button ref={ref} variant="contained" color="primary" className={clsx(classes.primaryButton, className)} {...otherProps}>{children}</Button>
);
});
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 (
<Button ref={ref} variant="outlined" color="default" className={clsx(classes.defaultButton, className)} {...otherProps}>{children}</Button>
);
});
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 (
<Tooltip title={title || ''} aria-label={title || ''}>
<DefaultButton ref={ref} style={{minWidth: 0}} className={clsx(classes.iconButton, className)} {...props}>
{icon}
</DefaultButton>
</Tooltip>
);
});
PgIconButton.displayName = 'PgIconButton';
PgIconButton.propTypes = {
icon: CustomPropTypes.children,
title: PropTypes.string.isRequired,
className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
};

View File

@ -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 <textarea ref={taRef} name={name} />;
}
CodeMirror.propTypes = {
name: PropTypes.string,
value: PropTypes.string,
options: PropTypes.object
};

View File

@ -0,0 +1,842 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
/* Common form components used in pgAdmin */
import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { Box, FormControl, OutlinedInput, FormHelperText,
Grid, IconButton, FormControlLabel, Switch, Checkbox, useTheme, InputLabel } from '@material-ui/core';
import { ToggleButton, ToggleButtonGroup } from '@material-ui/lab';
import ReportProblemIcon from '@material-ui/icons/ReportProblemRounded';
import InfoIcon from '@material-ui/icons/InfoRounded';
import CloseIcon from '@material-ui/icons/CloseRounded';
import CheckIcon from '@material-ui/icons/CheckCircleOutlineRounded';
import CheckRoundedIcon from '@material-ui/icons/CheckRounded';
import FolderOpenRoundedIcon from '@material-ui/icons/FolderOpenRounded';
import Select, {components as RSComponents} from 'react-select';
import CreatableSelect from 'react-select/creatable';
import Pickr from '@simonwep/pickr';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import CodeMirror from './CodeMirror';
import gettext from 'sources/gettext';
import { showFileDialog } from '../helpers/legacyConnector';
import _ from 'lodash';
import { DefaultButton, PrimaryButton, PgIconButton } from './Buttons';
import CustomPropTypes from '../custom_prop_types';
const useStyles = makeStyles((theme) => ({
formRoot: {
padding: '1rem'
},
img: {
maxWidth: '100%',
height: 'auto'
},
info: {
color: theme.palette.info.main,
marginLeft: '0.25rem',
fontSize: '1rem',
},
formLabel: {
margin: theme.spacing(0.75, 0.75, 0.75, 0.75),
display: 'flex',
},
formLabelError: {
color: theme.palette.error.main,
},
sql: {
height: '100%',
},
optionIcon: {
...theme.mixins.nodeIcon,
},
colorBtn: {
height: theme.spacing(3.5),
minHeight: theme.spacing(3.5),
width: theme.spacing(3.5),
minWidth: theme.spacing(3.5),
}
}));
export const MESSAGE_TYPE = {
SUCCESS: 'Success',
ERROR: 'Error',
INFO: 'Info',
CLOSE: 'Close',
};
/* Icon based on MESSAGE_TYPE */
function FormIcon({type, close=false, ...props}) {
let TheIcon = null;
if(close) {
TheIcon = CloseIcon;
} else if(type === MESSAGE_TYPE.SUCCESS) {
TheIcon = CheckIcon;
} else if(type === MESSAGE_TYPE.ERROR) {
TheIcon = ReportProblemIcon;
} else if(type === MESSAGE_TYPE.INFO) {
TheIcon = InfoIcon;
}
return <TheIcon fontSize="small" {...props} />;
}
FormIcon.propTypes = {
type: PropTypes.oneOf(Object.values(MESSAGE_TYPE)),
close: PropTypes.bool,
};
/* Wrapper on any form component to add label, error indicator and help message */
export function FormInput({children, error, className, label, helpMessage, required, testcid}) {
const classes = useStyles();
const cid = testcid || _.uniqueId('c');
const helpid = `h${cid}`;
return (
<Grid container spacing={0} className={className}>
<Grid item lg={3} md={3} sm={3} xs={12}>
<InputLabel htmlFor={cid} className={clsx(classes.formLabel, error?classes.formLabelError : null)} required={required}>
{label}
{error && <FormIcon type={MESSAGE_TYPE.ERROR} style={{marginLeft: 'auto'}}/>}
</InputLabel>
</Grid>
<Grid item lg={9} md={9} sm={9} xs={12}>
<FormControl error={Boolean(error)} fullWidth>
{React.cloneElement(children, {cid, helpid})}
</FormControl>
<FormHelperText id={helpid} variant="outlined">{helpMessage}</FormHelperText>
</Grid>
</Grid>
);
}
FormInput.propTypes = {
children: CustomPropTypes.children,
error: PropTypes.bool,
className: CustomPropTypes.className,
label: PropTypes.string,
helpMessage: PropTypes.string,
required: PropTypes.bool,
testcid: PropTypes.any,
};
export function InputSQL({value, options}) {
const classes = useStyles();
return (
<CodeMirror
value={value||''}
options={{
lineNumbers: true,
mode: 'text/x-pgsql',
...options,
}}
className={classes.sql}
/>
);
}
InputSQL.propTypes = {
value: PropTypes.string,
options: PropTypes.object,
};
export function FormInputSQL({hasError, required, label, className, helpMessage, testcid, value, controlProps}) {
return (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
<InputSQL value={value} options={controlProps}/>
</FormInput>
);
}
FormInputSQL.propTypes = {
hasError: PropTypes.bool,
required: PropTypes.bool,
label: PropTypes.string,
className: CustomPropTypes.className,
helpMessage: PropTypes.string,
testcid: PropTypes.string,
value: PropTypes.string,
controlProps: PropTypes.object,
};
/* Use forwardRef to pass ref prop to OutlinedInput */
export const InputText = forwardRef(({
cid, helpid, readonly, disabled, maxlength=255, value, onChange, controlProps, ...props}, ref)=>{
const classes = useStyles();
return(
<OutlinedInput
ref={ref}
color="primary"
fullWidth
className={classes.formInput}
inputProps={{
id: cid,
maxLength: maxlength,
'aria-describedby': helpid,
}}
readOnly={Boolean(readonly)}
disabled={Boolean(disabled)}
rows={4}
notched={false}
value={value || ''}
onChange={onChange}
{...controlProps}
{...props}
/>
);
});
InputText.displayName = 'InputText';
InputText.propTypes = {
cid: PropTypes.string,
helpid: PropTypes.string,
label: PropTypes.string,
readonly: PropTypes.bool,
disabled: PropTypes.bool,
maxlength: PropTypes.number,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
onChange: PropTypes.func,
controlProps: PropTypes.object,
};
export function FormInputText({hasError, required, label, className, helpMessage, testcid, ...props}) {
return (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
<InputText label={label} {...props}/>
</FormInput>
);
}
FormInputText.propTypes = {
hasError: PropTypes.bool,
required: PropTypes.bool,
label: PropTypes.string,
className: CustomPropTypes.className,
helpMessage: PropTypes.string,
testcid: PropTypes.string,
};
/* Using the existing file dialog functions using showFileDialog */
export function InputFileSelect({controlProps, onChange, disabled, readonly, ...props}) {
const inpRef = useRef();
const onFileSelect = (value)=>{
onChange && onChange(decodeURI(value));
inpRef.current.focus();
};
return (
<InputText ref={inpRef} disabled={disabled} readonly={readonly} {...props} endAdornment={
<IconButton onClick={()=>showFileDialog(controlProps, onFileSelect)}
disabled={disabled||readonly} aria-label={gettext('Select a file')}><FolderOpenRoundedIcon /></IconButton>
} />
);
}
InputFileSelect.propTypes = {
controlProps: PropTypes.object,
onChange: PropTypes.func,
disabled: PropTypes.bool,
readonly: PropTypes.bool,
};
export function FormInputFileSelect({
hasError, required, label, className, helpMessage, testcid, ...props}) {
return (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
<InputFileSelect required={required} label={label} {...props}/>
</FormInput>
);
}
FormInputFileSelect.propTypes = {
hasError: PropTypes.bool,
required: PropTypes.bool,
label: PropTypes.string,
className: CustomPropTypes.className,
helpMessage: PropTypes.string,
testcid: PropTypes.string,
};
export function InputSwitch({cid, helpid, value, onChange, readonly, controlProps, ...props}) {
return (
<Switch color="primary"
checked={Boolean(value)}
onChange={
readonly ? ()=>{} : onChange
}
id={cid}
inputProps={{
'aria-describedby': helpid,
}}
{...controlProps}
{...props}
/>
);
}
InputSwitch.propTypes = {
cid: PropTypes.string,
helpid: PropTypes.string,
value: PropTypes.any,
onChange: PropTypes.func,
readonly: PropTypes.bool,
controlProps: PropTypes.object,
};
export function FormInputSwitch({hasError, required, label, className, helpMessage, testcid, ...props}) {
return (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
<InputSwitch {...props}/>
</FormInput>
);
}
FormInputSwitch.propTypes = {
hasError: PropTypes.bool,
required: PropTypes.bool,
label: PropTypes.string,
className: CustomPropTypes.className,
helpMessage: PropTypes.string,
testcid: PropTypes.string,
};
export function InputCheckbox({cid, helpid, value, onChange, controlProps, readonly, ...props}) {
controlProps = controlProps || {};
return (
<FormControlLabel
control={
<Checkbox
id={cid}
checked={Boolean(value)}
onChange={readonly ? ()=>{} : onChange}
color="primary"
inputProps={{
'aria-describedby': helpid,
}}
{...props}
/>
}
label={controlProps.label}
/>
);
}
InputCheckbox.propTypes = {
cid: PropTypes.string,
helpid: PropTypes.string,
value: PropTypes.bool,
controlProps: PropTypes.object,
onChange: PropTypes.func,
readonly: PropTypes.bool,
};
export function FormInputCheckbox({hasError, required, label,
className, helpMessage, testcid, ...props}) {
return (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
<InputCheckbox {...props}/>
</FormInput>
);
}
FormInputCheckbox.propTypes = {
hasError: PropTypes.bool,
required: PropTypes.bool,
label: PropTypes.string,
className: CustomPropTypes.className,
helpMessage: PropTypes.string,
testcid: PropTypes.string,
};
export function InputToggle({cid, value, onChange, options, disabled, readonly, ...props}) {
return (
<ToggleButtonGroup
id={cid}
value={value}
exclusive
onChange={(e, val)=>{val!==null && onChange(val);}}
{...props}
>
{
(options||[]).map((option)=>{
const isSelected = option.value === value;
const isDisabled = disabled || (readonly && isSelected);
return (
<ToggleButton key={option.label} value={option.value} component={isSelected ? PrimaryButton : DefaultButton}
disabled={isDisabled} aria-label={option.label}>
<CheckRoundedIcon style={{visibility: isSelected ? 'visible': 'hidden'}}/>&nbsp;{option.label}
</ToggleButton>
);
})
}
</ToggleButtonGroup>
);
}
InputToggle.propTypes = {
cid: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
options: PropTypes.array,
controlProps: PropTypes.object,
onChange: PropTypes.func,
disabled: PropTypes.bool,
readonly: PropTypes.bool,
};
export function FormInputToggle({hasError, required, label,
className, helpMessage, testcid, ...props}) {
return (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
<InputToggle {...props}/>
</FormInput>
);
}
FormInputToggle.propTypes = {
hasError: PropTypes.bool,
required: PropTypes.bool,
label: PropTypes.string,
className: CustomPropTypes.className,
helpMessage: PropTypes.string,
testcid: PropTypes.string,
};
/* react-select package is used for select input
* Customizing the select styles to fit existing theme
*/
const customReactSelectStyles = (theme, readonly)=>({
input: (provided) => {
return {...provided, padding: 0, margin: 0, color: 'inherit'};
},
singleValue: (provided) => {
return {
...provided,
color: 'inherit',
};
},
control: (provided, state) => ({
...provided,
minHeight: '0',
backgroundColor: readonly ? theme.otherVars.inputDisabledBg : theme.palette.background.default,
borderColor: theme.otherVars.inputBorderColor,
...(state.isFocused ? {
borderColor: theme.palette.primary.main,
boxShadow: 'inset 0 0 0 1px '+theme.palette.primary.main,
'&:hover': {
borderColor: theme.palette.primary.main,
}
} : {}),
}),
dropdownIndicator: (provided)=>({
...provided,
padding: '0rem 0.25rem',
}),
indicatorsContainer: (provided)=>({
...provided,
...(readonly ? {display: 'none'} : {})
}),
clearIndicator: (provided)=>({
...provided,
padding: '0rem 0.25rem',
}),
valueContainer: (provided)=>({
...provided,
padding: theme.otherVars.reactSelect.padding,
}),
menu: (provided)=>({
...provided,
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
boxShadow: 'none',
border: '1px solid ' + theme.otherVars.inputBorderColor,
}),
menuPortal: (provided)=>({
...provided, zIndex: 9999,
backgroundColor: 'inherit',
color: 'inherit',
}),
option: (provided, state)=>({
...provided,
padding: '0.5rem',
backgroundColor: state.isFocused ? theme.palette.grey[200] :
(state.isSelected ? theme.palette.primary.main : 'inherit'),
}),
multiValue: (provided)=>({
...provided,
backgroundColor: theme.palette.grey[400],
}),
multiValueLabel: (provided)=>({
...provided,
fontSize: '1em',
zIndex: 9999,
color: theme.palette.text.primary
}),
multiValueRemove: (provided)=>({
...provided,
'&:hover': {
backgroundColor: 'unset',
color: theme.palette.error.main,
},
...(readonly ? {display: 'none'} : {})
}),
});
function OptionView({image, label}) {
const classes = useStyles();
return (
<>
{image && <span className={clsx(classes.optionIcon, image)}></span>}
<span>{label}</span>
</>
);
}
OptionView.propTypes = {
image: PropTypes.string,
label: PropTypes.string,
};
function CustomSelectOption(props) {
return (
<RSComponents.Option {...props}>
<OptionView image={props.data.image} label={props.data.label} />
</RSComponents.Option>
);
}
CustomSelectOption.propTypes = {
data: PropTypes.object,
};
function CustomSelectSingleValue(props) {
return (
<RSComponents.SingleValue {...props}>
<OptionView image={props.data.image} label={props.data.label} />
</RSComponents.SingleValue>
);
}
CustomSelectSingleValue.propTypes = {
data: PropTypes.object,
};
function getRealValue(options, value, creatable) {
let realValue = null;
if(_.isArray(value)) {
if(creatable) {
realValue = value.map((val)=>({label:val, value: val}));
} else {
realValue = value.map((val)=>(_.find(options, (option)=>option.value==val)));
}
} else {
realValue = _.find(options, (option)=>option.value==value) || null;
}
return realValue;
}
export function InputSelect({
cid, onChange, options, readonly=false, value, controlProps={}, optionsLoaded, disabled, ...props}) {
const [[finalOptions, isLoading], setFinalOptions] = useState([[], true]);
const theme = useTheme();
useEffect(()=>{
let optPromise = options, umounted=false;
if(typeof options === 'function') {
optPromise = options();
}
Promise.resolve(optPromise)
.then((res)=>{
/* If component unmounted, dont update state */
if(!umounted) {
optionsLoaded && optionsLoaded(res, value);
setFinalOptions([res || [], false]);
}
});
return ()=>umounted=true;
}, []);
const onChangeOption = useCallback((selectVal, action)=>{
if(_.isArray(selectVal)) {
onChange && onChange(selectVal.map((option)=>option.value, action.name));
} else {
onChange && onChange(selectVal ? selectVal.value : null, action.name);
}
}, [onChange]);
const realValue = getRealValue(finalOptions, value, controlProps.creatable);
const otherProps = {
isSearchable: !readonly,
isClearable: !readonly && (!_.isUndefined(controlProps.allowClear) ? controlProps.allowClear : true),
isDisabled: Boolean(disabled),
};
const styles = customReactSelectStyles(theme, readonly || disabled);
const commonProps = {
components: {
Option: CustomSelectOption,
SingleValue: CustomSelectSingleValue,
},
isMulti: Boolean(controlProps.multiple),
openMenuOnClick: !readonly,
onChange: onChangeOption,
isLoading: isLoading,
options: finalOptions,
value: realValue,
menuPortalTarget: document.body,
styles: styles,
inputId: cid,
...otherProps,
...props
};
if(!controlProps.creatable) {
return (
<Select {...commonProps}/>
);
} else {
return (
<CreatableSelect {...commonProps}/>
);
}
}
InputSelect.propTypes = {
cid: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]),
options: PropTypes.oneOfType([PropTypes.array, PropTypes.instanceOf(Promise), PropTypes.func]),
controlProps: PropTypes.object,
optionsLoaded: PropTypes.func,
onChange: PropTypes.func,
disabled: PropTypes.bool,
readonly: PropTypes.bool,
};
export function FormInputSelect({
hasError, required, className, label, helpMessage, testcid, ...props}) {
return (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
<InputSelect {...props}/>
</FormInput>
);
}
FormInputSelect.propTypes = {
hasError: PropTypes.bool,
required: PropTypes.bool,
label: PropTypes.string,
className: CustomPropTypes.className,
helpMessage: PropTypes.string,
testcid: PropTypes.string,
};
/* React wrapper on color pickr */
export function InputColor({value, controlProps, disabled, onChange, currObj}) {
const pickrOptions = {
showPalette: true,
allowEmpty: true,
colorFormat: 'HEX',
defaultColor: null,
position: 'right-middle',
clearText: gettext('No color'),
...controlProps,
disabled: disabled,
};
const eleRef = useRef();
const pickrObj = useRef();
const classes = useStyles();
const setColor = (value)=>{
pickrObj.current &&
pickrObj.current.setColor((_.isUndefined(value) || value == '') ? pickrOptions.defaultColor : value);
};
const destroyPickr = ()=>{
if(pickrObj.current) {
pickrObj.current.destroy();
pickrObj.current = null;
}
};
const initPickr = ()=>{
/* pickr does not have way to update options, need to
destroy and recreate pickr to reflect options */
destroyPickr();
pickrObj.current = new Pickr({
el: eleRef.current,
useAsButton: true,
theme: 'monolith',
swatches: [
'#000', '#666', '#ccc', '#fff', '#f90', '#ff0', '#0f0',
'#f0f', '#f4cccc', '#fce5cd', '#d0e0e3', '#cfe2f3', '#ead1dc', '#ea9999',
'#b6d7a8', '#a2c4c9', '#d5a6bd', '#e06666','#93c47d', '#76a5af', '#c27ba0',
'#f1c232', '#6aa84f', '#45818e', '#a64d79', '#bf9000', '#0c343d', '#4c1130',
],
position: pickrOptions.position,
strings: {
clear: pickrOptions.clearText,
},
components: {
palette: pickrOptions.showPalette,
preview: true,
hue: pickrOptions.showPalette,
interaction: {
clear: pickrOptions.allowEmpty,
defaultRepresentation: pickrOptions.colorFormat,
disabled: pickrOptions.disabled,
},
},
}).on('init', instance => {
setColor(value);
disabled && instance.disable();
const {lastColor} = instance.getRoot().preview;
const {clear} = instance.getRoot().interaction;
/* Cycle the keyboard navigation within the color picker */
clear.addEventListener('keydown', (e)=>{
if(e.keyCode === 9) {
e.preventDefault();
e.stopPropagation();
lastColor.focus();
}
});
lastColor.addEventListener('keydown', (e)=>{
if(e.keyCode === 9 && e.shiftKey) {
e.preventDefault();
e.stopPropagation();
clear.focus();
}
});
}).on('clear', () => {
onChange && onChange('');
}).on('change', (color) => {
onChange && onChange(color.toHEXA().toString());
}).on('show', (color, instance) => {
const {palette} = instance.getRoot().palette;
palette.focus();
}).on('hide', (instance) => {
const button = instance.getRoot().button;
button.focus();
});
if(currObj) {
currObj(pickrObj.current);
}
};
useEffect(()=>{
initPickr();
return ()=>{
destroyPickr();
};
}, [...Object.values(pickrOptions)]);
useEffect(()=>{
if(pickrObj.current) {
setColor(value);
}
}, [value]);
let btnStyles = {backgroundColor: value};
return (
// <Button variant="contained" ref={eleRef} className={classes.colorBtn} style={btnStyles} disabled={pickrOptions.disabled}>
// {(_.isUndefined(value) || _.isNull(value) || value === '') && <CloseIcon />}
// </Button>
<PgIconButton ref={eleRef} title={gettext('Select the color')} className={classes.colorBtn} style={btnStyles} disabled={pickrOptions.disabled}
icon={(_.isUndefined(value) || _.isNull(value) || value === '') && <CloseIcon />}
/>
);
}
InputColor.propTypes = {
value: PropTypes.string,
controlProps: PropTypes.object,
onChange: PropTypes.func,
disabled: PropTypes.bool,
currObj: PropTypes.func,
};
export function FormInputColor({
hasError, required, className, label, helpMessage, testcid, ...props}) {
return (
<FormInput required={required} label={label} error={hasError} className={className} helpMessage={helpMessage} testcid={testcid}>
<InputColor {...props}/>
</FormInput>
);
}
FormInputColor.propTypes = {
hasError: PropTypes.bool,
required: PropTypes.bool,
className: CustomPropTypes.className,
label: PropTypes.string,
helpMessage: PropTypes.string,
testcid: PropTypes.string,
};
const useStylesFormFooter = makeStyles((theme)=>({
root: {
padding: theme.spacing(0.5),
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
container: {
borderWidth: '1px',
borderStyle: 'solid',
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(0.5),
display: 'flex',
alignItems: 'center',
},
containerSuccess: {
borderColor: theme.palette.success.main,
backgroundColor: theme.palette.success.light,
},
iconSuccess: {
color: theme.palette.success.main,
},
containerError: {
borderColor: theme.palette.error.main,
backgroundColor: theme.palette.error.light,
},
iconError: {
color: theme.palette.error.main,
},
message: {
marginLeft: theme.spacing(0.5),
},
closeButton: {
marginLeft: 'auto',
},
}));
/* The form footer used mostly for showing error */
export function FormFooterMessage({type=MESSAGE_TYPE.SUCCESS, message, closable=true, onClose=()=>{}}) {
const classes = useStylesFormFooter();
if(!message) {
return <></>;
}
return (
<Box className={classes.root}>
<Box className={clsx(classes.container, classes[`container${type}`])}>
<FormIcon type={type} className={classes[`icon${type}`]}/>
<Box className={classes.message}>{message}</Box>
{closable && <IconButton className={clsx(classes.closeButton, classes[`icon${type}`])} onClick={onClose}>
<FormIcon close={true}/>
</IconButton>}
</Box>
</Box>
);
}
FormFooterMessage.propTypes = {
type: PropTypes.oneOf(Object.values(MESSAGE_TYPE)).isRequired,
message: PropTypes.string,
closable: PropTypes.bool,
onClose: PropTypes.func,
};

View File

@ -0,0 +1,57 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { CircularProgress, Box, Typography, makeStyles } from '@material-ui/core';
import React from 'react';
import PropTypes from 'prop-types';
const useStyles = makeStyles((theme)=>({
root: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: theme.otherVars.loader.backgroundColor,
color: theme.otherVars.loader.color,
zIndex: 1000,
display: 'flex',
},
loaderRoot: {
color: theme.otherVars.loader.color,
display: 'flex',
alignItems: 'center',
margin: 'auto',
'.MuiTypography-root': {
marginLeft: theme.spacing(1),
}
},
loader: {
color: theme.otherVars.loader.color,
}
}));
export default function Loader({message}) {
const classes = useStyles();
if(!message) {
return <></>;
}
return (
<Box className={classes.root}>
<Box className={classes.loaderRoot}>
<CircularProgress className={classes.loader} />
<Typography>{message}</Typography>
</Box>
</Box>
);
}
Loader.propTypes = {
message: PropTypes.string,
};

View File

@ -0,0 +1,176 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { makeStyles } from '@material-ui/core';
import _ from 'lodash';
import React from 'react';
import { InputCheckbox, InputText } from './FormComponents';
import clsx from 'clsx';
import PropTypes from 'prop-types';
const useStyles = makeStyles(()=>({
/* Display the privs table only when focussed */
root: {
'&:not(:focus-within) .priv-table': {
display: 'none',
}
},
table: {
borderSpacing: 0,
width: '100%',
fontSize: '0.8em',
},
tableCell: {
textAlign: 'left',
}
}));
export default function Privilege({value, onChange, controlProps}) {
// All available privileges in the PostgreSQL database server for
// generating the label for the specific Control
const LABELS = {
'C': 'CREATE',
'T': 'TEMPORARY',
'c': 'CONNECT',
'a': 'INSERT',
'r': 'SELECT',
'w': 'UPDATE',
'd': 'DELETE',
'D': 'TRUNCATE',
'x': 'REFERENCES',
't': 'TRIGGER',
'U': 'USAGE',
'X': 'EXECUTE',
};
let all = false;
let allWithGrant = false;
const classes = useStyles();
let textValue = '';
for(const v of value||[]) {
if(v.privilege) {
textValue += v.privilege_type;
if(v.with_grant) {
textValue += '*';
}
}
}
const checkboxId = _.uniqueId();
/* Calculate the real display value by merging all supported privs with incoming value */
const realVal = (controlProps?.supportedPrivs || []).map((priv)=>{
let inValue = _.find(value, (v)=>v.privilege_type===priv) || {
privilege: false, with_grant: false,
};
return {
privilege_type: priv,
privilege: Boolean(inValue.privilege),
with_grant: Boolean(inValue.with_grant),
};
});
const onCheckAll = (e, forWithGrant)=>{
let newValue = [];
/* Push all the privs or ignore if unchecked and return empty */
realVal.forEach((v)=>{
if(forWithGrant) {
newValue.push({
...v,
privilege: true,
with_grant: e.target.checked,
});
} else if(e.target.checked) {
newValue.push({
...v,
privilege: e.target.checked,
});
}
});
onChange(newValue);
};
const onCheck = (e, forWithGrant)=>{
let exists = false;
let newValue = [];
/* Calculate the newValue by pushing all selected and ignore if unchecked */
(value||[]).forEach((v)=>{
if(v.privilege_type === e.target.name) {
exists = true;
if(forWithGrant) {
newValue.push({
...v,
with_grant: e.target.checked,
});
}
} else {
newValue.push(v);
}
});
if(!exists && e.target.checked) {
newValue.push({
privilege_type: e.target.name,
privilege: e.target.checked,
with_grant: false,
});
}
onChange(newValue);
};
/* If all supported privs and incoming value length matches, clearly all are selected */
all = (realVal.length === (value || []).length);
allWithGrant = (realVal.length === (value || []).length) && (value || []).every((d)=>d.with_grant);
return (
<div className={classes.root}>
<InputText value={textValue} readOnly/>
<table className={clsx(classes.table, 'priv-table')}>
{(realVal.length > 1) && <thead>
<tr>
<td className={classes.tableCell}>
<InputCheckbox name="all" controlProps={{label: 'ALL'}} id={checkboxId} size="small"
onChange={(e)=>onCheckAll(e, false)} value={all}/>
</td>
<td className={classes.tableCell}>
<InputCheckbox name="all" controlProps={{label: 'WITH GRANT OPTION'}} id={checkboxId} size="small"
disabled={!all} onChange={(e)=>onCheckAll(e, true)} value={allWithGrant}/>
</td>
</tr>
</thead>}
<tbody>
{
realVal.map((d)=>{
return (
<tr key={d.privilege_type}>
<td className={classes.tableCell}>
<InputCheckbox name={d.privilege_type} controlProps={{label: LABELS[d.privilege_type]}}
id={checkboxId} value={Boolean(d.privilege)} size="small"
onChange={(e)=>onCheck(e, false)}/>
</td>
<td className={classes.tableCell}>
<InputCheckbox name={d.privilege_type} controlProps={{label: 'WITH GRANT OPTION'}}
id={checkboxId} value={Boolean(d.with_grant)} size="small" disabled={!d.privilege}
onChange={(e)=>onCheck(e, true)}/>
</td>
</tr>
);
})
}
</tbody>
</table>
</div>
);
}
Privilege.propTypes = {
value: PropTypes.array,
onChange: PropTypes.func,
controlProps: PropTypes.object,
};

View File

@ -0,0 +1,42 @@
/////////////////////////////////////////////////////////////
//
// 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 {Box, makeStyles} from '@material-ui/core';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import CustomPropTypes from '../custom_prop_types';
const useStyles = makeStyles((theme)=>({
root: {
height: '100%',
padding: theme.spacing(1),
overflow: 'auto',
backgroundColor: theme.palette.grey[400]
}
}));
/* Material UI does not have any tabpanel component, we create one for us */
export default function TabPanel({children, classNameRoot, className, value, index}) {
const classes = useStyles();
const active = value === index;
return (
<Box className={clsx(classes.root, classNameRoot)} component="div" hidden={!active}>
<Box style={{height: '100%'}} className={className}>{children}</Box>
</Box>
);
}
TabPanel.propTypes = {
children: CustomPropTypes.children,
classNameRoot: CustomPropTypes.className,
className: CustomPropTypes.className,
value: PropTypes.any.isRequired,
index: PropTypes.any.isRequired,
};

View File

@ -1,10 +1,32 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import PropTypes from 'prop-types';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
/* Common Prop types */
const CustomPropTypes = {
ref: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
schemaUI: PropTypes.instanceOf(BaseUISchema),
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]),
className: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
])
};
export default CustomPropTypes;

View File

@ -0,0 +1,66 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
/* This file will have wrappers and connectors used by React components to
* re-use any existing non-react components.
* These functions may not be needed once all are migrated
*/
import Alertify from 'pgadmin.alertifyjs';
import gettext from 'sources/gettext';
import pgAdmin from 'sources/pgadmin';
export function confirmDeleteRow(onOK, onCancel, title, message) {
Alertify.confirm(
title || gettext('Delete Row'),
message || gettext('Are you sure you wish to delete this row?'),
function() {
onOK();
return true;
},
function() {
onCancel();
return true;
}
);
}
/* Don't import alertfiy directly in react files. Not good for testability */
export function pgAlertify() {
return Alertify;
}
/* Used by file select component to re-use existing logic */
export function showFileDialog(dialogParams, onFileSelect) {
let params = {
supported_types: dialogParams.supportedTypes || [],
dialog_type: dialogParams.dialogType || 'select_file',
dialog_title: dialogParams.dialogTitle || '',
btn_primary: dialogParams.btnPrimary || '',
};
pgAdmin.FileManager.init();
pgAdmin.FileManager.show_dialog(params);
const onDialogClose = ()=>removeListeners();
pgAdmin.Browser.Events.on('pgadmin-storage:finish_btn:' + params.dialog_type, onFileSelect);
pgAdmin.Browser.Events.on('pgadmin-storage:cancel_btn:' + params.dialog_type, onDialogClose);
const removeListeners = ()=>{
pgAdmin.Browser.Events.off('pgadmin-storage:finish_btn:' + params.dialog_type, onFileSelect);
pgAdmin.Browser.Events.off('pgadmin-storage:cancel_btn:' + params.dialog_type, onDialogClose);
};
}
export function onPgadminEvent(eventName, handler) {
pgAdmin.Browser.Events.on(eventName, handler);
}
export function offPgadminEvent(eventName, handler) {
pgAdmin.Browser.Events.off(eventName, handler);
}

View File

@ -436,4 +436,10 @@ export function registerDetachEvent(panel){
$((this.$container)[0].ownerDocument).find('.wcIFrameFloating').find('.wcIFrameFloating').css('height', height);
});
/* If a function, then evaluate */
export function evalFunc(func, param) {
if(_.isFunction(func)) {
return func.apply(null, [param]);
}
return func;
}

View File

@ -0,0 +1,70 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import {sprintf} from 'sources/utils';
import _ from 'lodash';
import pgAdmin from 'sources/pgadmin';
/* Validate value for min max range */
export function minMaxValidator(label, value, minValue, maxValue) {
if((_.isUndefined(value) || _.isNull(value) || String(value) === ''))
return null;
if (!_.isUndefined(minValue) && value < minValue) {
return sprintf(pgAdmin.Browser.messages.MUST_GR_EQ, label, minValue);
} else if (!_.isUndefined(maxValue) && value > maxValue) {
return sprintf(pgAdmin.Browser.messages.MUST_LESS_EQ, label, maxValue);
}
return null;
}
/* Validate value to check if it is a number */
export function numberValidator(label, value) {
if((_.isUndefined(value) || _.isNull(value) || String(value) === ''))
return null;
var pattern = new RegExp('^-?[0-9]+(\.?[0-9]*)?$');
if (!pattern.test(value)) {
return sprintf(pgAdmin.Browser.messages.MUST_BE_NUM, label);
}
return null;
}
/* Validate value to check if it is an integer */
export function integerValidator(label, value) {
if((_.isUndefined(value) || _.isNull(value) || String(value) === ''))
return null;
var pattern = new RegExp('^-?[0-9]*$');
if (!pattern.test(value)) {
return sprintf(pgAdmin.Browser.messages.MUST_BE_INT, label);
}
return null;
}
/* Validate value to check if it is empty */
export function emptyValidator(label, value) {
if(isEmptyString(value) || String(value).replace(/^\s+|\s+$/g, '') == '') {
return sprintf(pgAdmin.Browser.messages.CANNOT_BE_EMPTY, label);
}
return null;
}
export function isEmptyString(string) {
return _.isUndefined(string) || _.isNull(string) || String(string).trim() === '';
}
/* Validate rows to check for any duplicate rows based on uniqueCols-columns array */
export function checkUniqueCol(rows, uniqueCols) {
if(uniqueCols) {
for(const [i, row] of rows.entries()) {
for(const checkRow of rows.slice(0, i)) {
if(_.isEqual(_.pick(checkRow, uniqueCols), _.pick(row, uniqueCols))) return i;
}
}
}
return -1;
}

View File

@ -107,7 +107,7 @@ legend {
color: $btn-secondary-hover-fg !important;
} &.disabled, &:disabled {
color: $btn-secondary-disabled-fg !important;
border-color: $btn-secondary-disabled-bg !important;
border-color: $btn-secondary-disabled-border-color !important;
}
color: $btn-secondary-fg !important;

View File

@ -264,6 +264,7 @@
.obj_properties {
padding: 0px;
height: 100%;
}
.obj_properties .pgadmin-control .uneditable-input {

View File

@ -189,7 +189,7 @@
.wcFrameEdge {
background-color: transparent;
border: none;
z-index: 5;
z-index: 5000;
}
.wcFrameEdgeN, .wcFrameEdgeS {

View File

@ -286,7 +286,7 @@ $btn-secondary-fg: $color-fg !default;
$btn-secondary-hover-fg: $color-fg !default;
$btn-secondary-border: $color-gray !default;
$btn-secondary-hover-bg: $color-gray-light !default;
$btn-secondary-disabled-bg: $color-gray !default;
$btn-secondary-disabled-border-color: $color-gray !default;
$btn-secondary-disabled-fg: $color-fg !default;
$btn-frame-close-bg:$btn-secondary-hover-bg !default;

View File

@ -151,7 +151,7 @@ $btn-secondary-bg: transparent;
$btn-secondary-fg: $color-primary;
$btn-secondary-hover-fg: $color-fg;
$btn-secondary-border: $color-primary;
$btn-secondary-disabled-bg: $color-gray-lighter;
$btn-secondary-disabled-border-color: $color-gray-lighter;
$btn-secondary-disabled-fg: $color-gray-lighter;
$btn-frame-close-bg: $color-gray-light;

View File

@ -21,7 +21,8 @@
<!-- Base template stylesheets -->
<link type="text/css" rel="stylesheet" href="{{ url_for('static', filename='js/generated/style.css')}}"/>
<link type="text/css" rel="stylesheet" href="{{ url_for('static', filename='js/generated/pgadmin.style.css')}}"/>
<link type="text/css" rel="stylesheet" href="{{ url_for('static', filename=('js/generated/'+get_theme_css())) }}"/>
{% set theme = get_theme_css() %}
<link type="text/css" rel="stylesheet" href="{{ url_for('static', filename=('js/generated/'+theme[0])) }}" data-theme="{{theme[1]}}"/>
<!--View specified stylesheets-->
{% block css_link %}{% endblock %}

View File

@ -0,0 +1,501 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import '../helper/enzyme.helper';
import { createMount } from '@material-ui/core/test-utils';
import {TestSchema, TestSchemaAllTypes} from './TestSchema.ui';
import pgAdmin from 'sources/pgadmin';
import {messages} from '../fake_messages';
import SchemaView from '../../../pgadmin/static/js/SchemaView';
import * as legacyConnector from 'sources/helpers/legacyConnector';
const initData = {
id: 1,
field1: 'field1val',
field2: 1,
fieldcoll: [
{field3: 1, field4: 'field4val1', field5: 'field5val1'},
{field3: 2, field4: 'field4val2', field5: 'field5val2'},
],
field3: 3,
field4: 'field4val',
};
function getInitData() {
return Promise.resolve(initData);
}
function getSchema() {
return new TestSchema();
}
function getSchemaAllTypes() {
return new TestSchemaAllTypes();
}
describe('SchemaView', ()=>{
let mount;
/* Use createMount so that material ui components gets the required context */
/* https://material-ui.com/guides/testing/#api */
beforeAll(()=>{
mount = createMount();
});
afterAll(() => {
mount.cleanUp();
});
beforeEach(()=>{
jasmineEnzyme();
/* messages used by validators */
pgAdmin.Browser = pgAdmin.Browser || {};
pgAdmin.Browser.messages = pgAdmin.Browser.messages || messages;
});
describe('SchemaDialogView', ()=>{
let ctrl,
onSave=jasmine.createSpy('onSave').and.returnValue(Promise.resolve()),
onClose=jasmine.createSpy('onClose'),
onHelp=jasmine.createSpy('onHelp'),
onEdit=jasmine.createSpy('onEdit'),
onDataChange=jasmine.createSpy('onDataChange'),
getSQLValue=jasmine.createSpy('onEdit').and.returnValue(Promise.resolve('select 1;')),
ctrlMount = (props)=>{
ctrl?.unmount();
ctrl = mount(<SchemaView
formType='dialog'
schema={getSchema()}
viewHelperProps={{
mode: 'create',
}}
onSave={onSave}
onClose={onClose}
onHelp={onHelp}
onEdit={onEdit}
onDataChange={onDataChange}
confirmOnCloseReset={true}
hasSQL={true}
getSQLValue={getSQLValue}
disableSqlHelp={false}
{...props}
/>);
},
simulateValidData = ()=>{
ctrl.find('MappedFormControl[id="field1"]').find('input').simulate('change', {target: {value: 'val1'}});
ctrl.find('MappedFormControl[id="field2"]').find('input').simulate('change', {target: {value: '2'}});
ctrl.find('MappedFormControl[id="field5"]').find('textarea').simulate('change', {target: {value: 'val5'}});
/* Add a row */
ctrl.find('DataGridView').find('PgIconButton[data-test="add-row"]').find('button').simulate('click');
ctrl.find('DataGridView').find('PgIconButton[data-test="add-row"]').find('button').simulate('click');
ctrl.find('MappedCellControl[id="field5"]').at(0).find('input').simulate('change', {target: {value: 'rval51'}});
ctrl.find('MappedCellControl[id="field5"]').at(1).find('input').simulate('change', {target: {value: 'rval52'}});
};
beforeEach(()=>{
ctrlMount();
});
it('init', (done)=>{
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('MappedFormControl').length).toBe(6);
expect(ctrl.find('FormView').length).toBe(2);
expect(ctrl.find('DataGridView').length).toBe(1);
expect(ctrl.find('DefaultButton[data-test="Reset"]').prop('disabled')).toBeTrue();
expect(ctrl.find('PrimaryButton[data-test="Save"]').prop('disabled')).toBeTrue();
done();
}, 0);
});
it('change text', (done)=>{
ctrl.find('MappedFormControl[id="field2"]').find('input').simulate('change', {target: {value: '2'}});
setTimeout(()=>{
ctrl.update();
/* Error should come for field1 as it is empty and noEmpty true */
expect(ctrl.find('FormFooterMessage').prop('message')).toBe('\'Field1\' cannot be empty.');
expect(ctrl.find('PrimaryButton[data-test="Save"]').prop('disabled')).toBeTrue();
done();
}, 0);
});
it('close error on click', (done)=>{
ctrl.find('FormFooterMessage').find('button').simulate('click');
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('FormFooterMessage').prop('message')).toBe('');
done();
}, 0);
});
it('valid form data', (done)=>{
simulateValidData();
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('FormFooterMessage').prop('message')).toBeFalsy();
done();
}, 0);
});
describe('DataGridView', ()=>{
it('add row', (done)=>{
ctrl.find('DataGridView').find('PgIconButton[data-test="add-row"]').find('button').simulate('click');
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('DataGridView').find('DataTableRow').length).toBe(1);
done();
}, 0);
});
it('remove row', (done)=>{
simulateValidData();
/* Press OK */
let confirmSpy = spyOn(legacyConnector, 'confirmDeleteRow').and.callFake((yesFn)=>{
yesFn();
});
ctrl.find('DataGridView').find('PgIconButton[data-test="delete-row"]').at(0).find('button').simulate('click');
expect(confirmSpy.calls.argsFor(0)[2]).toBe('Custom delete title');
expect(confirmSpy.calls.argsFor(0)[3]).toBe('Custom delete message');
/* Press Cancel */
spyOn(legacyConnector, 'confirmDeleteRow').and.callFake((yesFn, cancelFn)=>{
cancelFn();
});
ctrl.find('DataGridView').find('PgIconButton[data-test="delete-row"]').at(0).find('button').simulate('click');
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('DataGridView').find('DataTableRow').length).toBe(1);
done();
}, 0);
});
it('expand row', (done)=>{
simulateValidData();
ctrl.find('DataGridView').find('PgIconButton[data-test="expand-row"]').at(0).find('button').simulate('click');
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('DataGridView').find('FormView').length).toBe(1);
done();
}, 0);
});
it('unique col test', (done)=>{
simulateValidData();
ctrl.find('MappedCellControl[id="field5"]').at(1).find('input').simulate('change', {target: {value: 'rval51'}});
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('FormFooterMessage').prop('message')).toBe('Field5 in FieldColl must be unique.');
done();
}, 0);
});
});
describe('SQL tab', ()=>{
it('no changes', (done)=>{
ctrl.find('ForwardRef(Tab)[label="SQL"]').find('button').simulate('click');
setTimeout(()=>{
ctrl.update();
/* Dont show error message */
expect(ctrl.find('FormFooterMessage').prop('message')).toBe('');
expect(ctrl.find('CodeMirror').prop('value')).toBe('-- No updates.');
done();
}, 0);
});
it('data invalid', (done)=>{
ctrl.find('MappedFormControl[id="field2"]').find('input').simulate('change', {target: {value: '2'}});
ctrl.find('ForwardRef(Tab)[label="SQL"]').find('button').simulate('click');
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('CodeMirror').prop('value')).toBe('-- Definition incomplete.');
done();
}, 0);
});
it('valid data', (done)=>{
simulateValidData();
ctrl.find('ForwardRef(Tab)[label="SQL"]').find('button').simulate('click');
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('CodeMirror').prop('value')).toBe('select 1;');
done();
}, 0);
});
});
it('onSave click', (done)=>{
simulateValidData();
let alertSpy = spyOn(legacyConnector.pgAlertify(), 'alert');
onSave.calls.reset();
ctrl.find('PrimaryButton[data-test="Save"]').simulate('click');
setTimeout(()=>{
expect(onSave.calls.argsFor(0)[0]).toBe(true);
expect(onSave.calls.argsFor(0)[1]).toEqual({
id: undefined,
field1: 'val1',
field2: 2,
field5: 'val5',
fieldcoll: [
{field3: null, field4: null, field5: 'rval51'},
{field3: null, field4: null, field5: 'rval52'},
]
});
expect(alertSpy).toHaveBeenCalledWith('Warning', 'some inform text');
done();
}, 0);
});
describe('onReset', ()=>{
it('with confirm check and yes click', (done)=>{
simulateValidData();
onDataChange.calls.reset();
let confirmSpy = spyOn(legacyConnector.pgAlertify(), 'confirm').and.callThrough();
ctrl.find('DefaultButton[data-test="Reset"]').simulate('click');
/* Press OK */
confirmSpy.calls.argsFor(0)[2]();
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('DefaultButton[data-test="Reset"]').prop('disabled')).toBeTrue();
expect(ctrl.find('PrimaryButton[data-test="Save"]').prop('disabled')).toBeTrue();
expect(onDataChange).toHaveBeenCalledWith(false);
done();
}, 0);
});
it('with confirm check and cancel click', (done)=>{
simulateValidData();
let confirmSpy = spyOn(legacyConnector.pgAlertify(), 'confirm').and.callThrough();
ctrl.find('DefaultButton[data-test="Reset"]').simulate('click');
/* Press cancel */
confirmSpy.calls.argsFor(0)[3]();
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('DefaultButton[data-test="Reset"]').prop('disabled')).toBeFalse();
expect(ctrl.find('PrimaryButton[data-test="Save"]').prop('disabled')).toBeFalse();
done();
}, 0);
});
it('no confirm check', (done)=>{
ctrlMount({
confirmOnCloseReset: false,
});
ctrl.update();
simulateValidData();
onDataChange.calls.reset();
let confirmSpy = spyOn(legacyConnector.pgAlertify(), 'confirm').and.callThrough();
ctrl.find('DefaultButton[data-test="Reset"]').simulate('click');
setTimeout(()=>{
ctrl.update();
expect(confirmSpy).not.toHaveBeenCalled();
expect(ctrl.find('DefaultButton[data-test="Reset"]').prop('disabled')).toBeTrue();
expect(ctrl.find('PrimaryButton[data-test="Save"]').prop('disabled')).toBeTrue();
expect(onDataChange).toHaveBeenCalledWith(false);
done();
}, 0);
});
});
it('onHelp SQL', (done)=>{
ctrl.find('PgIconButton[data-test="sql-help"]').find('button').simulate('click');
setTimeout(()=>{
ctrl.update();
expect(onHelp).toHaveBeenCalledWith(true, true);
done();
}, 0);
});
it('onHelp Dialog', (done)=>{
ctrl.find('PgIconButton[data-test="dialog-help"]').find('button').simulate('click');
setTimeout(()=>{
ctrl.update();
expect(onHelp).toHaveBeenCalledWith(false, true);
done();
}, 0);
});
describe('edit mode', ()=>{
let simulateChanges = ()=>{
ctrl.find('MappedFormControl[id="field1"]').find('input').simulate('change', {target: {value: 'val1'}});
ctrl.find('MappedFormControl[id="field5"]').find('textarea').simulate('change', {target: {value: 'val5'}});
/* Add a row */
ctrl.find('DataGridView').find('PgIconButton[data-test="add-row"]').find('button').simulate('click');
ctrl.find('MappedCellControl[id="field5"]').at(2).find('input').simulate('change', {target: {value: 'rval53'}});
/* Remove the 1st row */
ctrl.find('DataTableRow').find('PgIconButton[data-test="delete-row"]').at(0).find('button').simulate('click');
/* Edit the 2nd row which is first now*/
ctrl.find('MappedCellControl[id="field5"]').at(0).find('input').simulate('change', {target: {value: 'rvalnew'}});
};
beforeEach(()=>{
ctrlMount({
getInitData: getInitData,
viewHelperProps: {
mode: 'edit',
}
});
/* Press OK */
spyOn(legacyConnector, 'confirmDeleteRow').and.callFake((yesFn)=>{
yesFn();
});
});
it('init', (done)=>{
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('MappedFormControl').length).toBe(4);
expect(ctrl.find('FormView').length).toBe(2);
expect(ctrl.find('DataGridView').length).toBe(1);
expect(ctrl.find('DataTableRow').length).toBe(2);
expect(ctrl.find('DefaultButton[data-test="Reset"]').prop('disabled')).toBeTrue();
expect(ctrl.find('PrimaryButton[data-test="Save"]').prop('disabled')).toBeTrue();
done();
}, 0);
});
it('onSave after change', (done)=>{
setTimeout(()=>{
ctrl.update();
simulateChanges();
let alertSpy = spyOn(legacyConnector.pgAlertify(), 'alert');
onSave.calls.reset();
ctrl.find('PrimaryButton[data-test="Save"]').simulate('click');
setTimeout(()=>{
ctrl.update();
expect(onSave.calls.argsFor(0)[0]).toBe(false);
expect(onSave.calls.argsFor(0)[1]).toEqual({
id: 1,
field1: 'val1',
field5: 'val5',
fieldcoll: {
added: [
{ field3: null, field4: null, field5: 'rval53'}
],
changed: [
{ field3: 2, field4: 'field4val2', field5: 'rvalnew'}
],
deleted: [
{ field3: 1, field4: 'field4val1', field5: 'field5val1'}
]
}
});
expect(alertSpy).toHaveBeenCalledWith('Warning', 'some inform text');
done();
}, 0);
}, 0);
});
it('onReset after change', (done)=>{
setTimeout(()=>{
ctrl.update();
simulateChanges();
onDataChange.calls.reset();
let confirmSpy = spyOn(legacyConnector.pgAlertify(), 'confirm').and.callThrough();
ctrl.find('DefaultButton[data-test="Reset"]').simulate('click');
/* Press OK */
confirmSpy.calls.argsFor(0)[2]();
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('DefaultButton[data-test="Reset"]').prop('disabled')).toBeTrue();
expect(ctrl.find('PrimaryButton[data-test="Save"]').prop('disabled')).toBeTrue();
expect(onDataChange).toHaveBeenCalledWith(false);
done();
}, 0);
}, 0);
});
});
});
describe('all types', ()=>{
let ctrl;
beforeEach(()=>{
ctrl?.unmount();
ctrl = mount(<SchemaView
formType='dialog'
schema={getSchemaAllTypes()}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={true}
getSQLValue={()=>'select 1;'}
disableSqlHelp={false}
/>);
});
it('init', ()=>{
/* Add a row */
ctrl.find('DataGridView').find('PgIconButton[data-test="add-row"]').find('button').simulate('click');
setTimeout(()=>{
ctrl.update();
}, 0);
});
});
describe('SchemaPropertiesView', ()=>{
let onEdit = jasmine.createSpy('onEdit'),
onHelp = jasmine.createSpy('onHelp'),
ctrl = null;
beforeEach(()=>{
ctrl = mount(<SchemaView
formType='tab'
schema={getSchema()}
getInitData={getInitData}
viewHelperProps={{
mode: 'properties',
}}
onHelp={onHelp}
disableSqlHelp={false}
onEdit={onEdit}
/>);
});
it('init', (done)=>{
setTimeout(()=>{
ctrl.update();
expect(ctrl.find('MappedFormControl').length).toBe(6);
expect(ctrl.find('ForwardRef(Accordion)').length).toBe(2);
done();
}, 0);
});
it('onHelp', (done)=>{
ctrl.find('PgIconButton[data-test="help"]').find('button').simulate('click');
setTimeout(()=>{
ctrl.update();
expect(onHelp).toHaveBeenCalled();
done();
}, 0);
});
it('onEdit', (done)=>{
ctrl.find('PgIconButton[data-test="edit"]').find('button').simulate('click');
setTimeout(()=>{
ctrl.update();
expect(onEdit).toHaveBeenCalled();
done();
}, 0);
});
});
});

View File

@ -0,0 +1,190 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
class TestSubSchema extends BaseUISchema {
constructor() {
super({
field3: null,
field4: null,
});
this.keys = ['field3', 'field4', 'field5'];
}
get baseFields() {
return [
{
id: 'field3', label: 'Field3', type: 'int', group: null,
mode: ['properties'], visible: true, cell: 'int'
},
{
id: 'field4', label: 'Field4', type: 'select', group: null,
cell: ()=>({cell: 'select', options: []}),
mode: ['properties', 'edit', 'create'],
disabled: (state)=>this.isNew(state), deps: ['field5'],
depChange: ()=>{}, optionsLoaded: ()=>{},
},
{
id: 'field5', label: 'Field5', type: 'multiline', group: null,
cell: 'text', mode: ['properties', 'edit', 'create'], disabled: false,
noEmpty: true, minWidth: '50%',
},
{
id: 'fieldskip', label: 'FieldSkip', type: 'text', group: null,
cell: 'text', mode: ['properties', 'edit', 'create'], disabled: false,
noEmpty: true,
}
];
}
}
export class TestSchema extends BaseUISchema {
constructor() {
super({
id: undefined,
field1: null,
field2: null,
fieldcoll: null,
});
this.informText = 'some inform text';
}
get baseFields() {
return [
{
id: 'id', label: 'ID', type: 'int', group: null,
mode: ['properties'],
},{
id: 'field1', label: 'Field1', type: 'text', group: null,
mode: ['properties', 'edit', 'create'], disabled: false,
noEmpty: true, visible: true,
},{
id: 'field2', label: 'Field2', type: ()=>({
type: 'int', min: 0, max: 255,
}), group: null, mode: ['properties', 'create'],
disabled: ()=>false, visible: ()=>true, deps: ['field1'],
depChange: ()=>({})
},{
id: 'fieldcoll', label: 'FieldColl', type: 'collection', group: null,
mode: ['edit', 'create'], schema: new TestSubSchema(),
canAdd: true, canEdit: true, canDelete: true, uniqueCol: ['field5'],
customDeleteTitle: 'Custom delete title',
customDeleteMsg: 'Custom delete message',
},{
type: 'nested-tab', group: null,
mode: ['edit', 'create'], schema: new TestSubSchema(),
},{
id: 'field6', label: 'Field6', type: 'numeric', group: null,
mode: ['properties', 'create'],
disabled: false,
},{
id: 'field7', label: 'Field7', type: 'text', group: 'Advanced',
mode: ['properties'],
},{
id: 'fieldinvis', label: 'fieldinvis', type: 'text', group: 'Advanced',
mode: ['properties', 'edit', 'create'], visible: false,
},
];
}
validate() {
return false;
}
}
class TestSubSchemaAllTypes extends BaseUISchema {
constructor() {
super();
}
get baseFields() {
return [
{
id: 'int', label: 'int', type: 'int', group: null,
mode: ['create'], cell: 'int'
},{
id: 'text', label: 'text', type: 'text', group: null,
mode: ['create'], cell: 'text'
},{
id: 'password', label: 'password', type: 'password', group: null,
mode: ['create'], cell: 'password',
},{
id: 'select', label: 'select', type: 'select', group: null,
mode: ['create'], options: [], cell: 'select'
},{
id: 'switch', label: 'switch', type: 'switch', group: null,
mode: ['create'], options: [], cell: 'switch'
},{
id: 'privilege', label: 'privilege', type: 'privilege', group: null,
mode: ['create'], options: [], cell: 'privilege',
}
];
}
}
export class TestSchemaAllTypes extends BaseUISchema {
constructor() {
super();
}
get baseFields() {
return [
{
id: 'int', label: 'int', type: 'int', group: null,
mode: ['create'],
},{
id: 'text', label: 'text', type: 'text', group: null,
mode: ['create'],
},{
id: 'multiline', label: 'multiline', type: 'multiline', group: null,
mode: ['create'],
},{
id: 'password', label: 'password', type: 'password', group: null,
mode: ['create'],
},{
id: 'select', label: 'select', type: 'select', group: null,
mode: ['create'], options: [],
},{
id: 'switch', label: 'switch', type: 'switch', group: null,
mode: ['create'], options: [],
},{
id: 'checkbox', label: 'checkbox', type: 'checkbox', group: null,
mode: ['create'],
},{
id: 'toggle', label: 'toggle', type: 'toggle', group: null,
mode: ['create'],
},{
id: 'color', label: 'color', type: 'color', group: null,
mode: ['create'],
},{
id: 'file', label: 'file', type: 'file', group: null,
mode: ['create'],
},{
id: 'sql', label: 'sql', type: 'sql', group: null,
mode: ['create'],
},{
id: 'function', label: 'function', type: ()=>'text',
group: null,mode: ['create'],
},{
id: 'collection', label: 'collection', type: 'collection', group: null,
mode: ['edit', 'create'], schema: new TestSubSchemaAllTypes(),
canAdd: true, canEdit: true, canDelete: true,
}
];
}
validate() {
return false;
}
}

View File

@ -0,0 +1,56 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import '../helper/enzyme.helper';
import { withTheme } from '../fake_theme';
import { createMount } from '@material-ui/core/test-utils';
import InfoIcon from '@material-ui/icons/InfoRounded';
import {PrimaryButton, DefaultButton, PgIconButton} from 'sources/components/Buttons';
/* MUI Components need to be wrapped in Theme for theme vars */
describe('components Buttons', ()=>{
let mount;
/* Use createMount so that material ui components gets the required context */
/* https://material-ui.com/guides/testing/#api */
beforeAll(()=>{
mount = createMount();
});
afterAll(() => {
mount.cleanUp();
});
beforeEach(()=>{
jasmineEnzyme();
});
it('PrimaryButton', ()=>{
let ThemedBtn = withTheme(PrimaryButton);
let btn = mount(<ThemedBtn>Test</ThemedBtn>);
expect(btn.getDOMNode().classList.contains('MuiButton-containedPrimary')).toBe(true);
});
it('DefaultButton', ()=>{
let ThemedBtn = withTheme(DefaultButton);
let btn = mount(<ThemedBtn className="testClass">Test</ThemedBtn>);
expect(btn.getDOMNode().classList.contains('MuiButton-outlined')).toBe(true);
expect(btn.getDOMNode().classList.contains('testClass')).toBe(true);
});
it('PgIconButton', ()=>{
let Icon = <InfoIcon />;
let ThemedBtn = withTheme(PgIconButton);
let btn = mount(<ThemedBtn title="The icon button" icon={Icon} className="testClass"></ThemedBtn>);
expect(btn.find(InfoIcon)).not.toBe(null);
});
});

View File

@ -0,0 +1,46 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import '../helper/enzyme.helper';
import {default as OrigCodeMirror} from 'bundled_codemirror';
import CodeMirror from 'sources/components/CodeMirror';
import { mount } from 'enzyme';
describe('CodeMirror', ()=>{
let cmInstance, options={
lineNumbers: true,
mode: 'text/x-pgsql',
}, cmObj = jasmine.createSpyObj('cmObj', {'setValue': ()=>{}, 'refresh': ()=>{}});
beforeEach(()=>{
jasmineEnzyme();
spyOn(OrigCodeMirror, 'fromTextArea').and.returnValue(cmObj);
cmInstance = mount(
<CodeMirror
value={'Init text'}
options={options}
className="testClass"
/>);
});
it('init', ()=>{
/* textarea ref passed to fromTextArea */
expect(OrigCodeMirror.fromTextArea).toHaveBeenCalledWith(cmInstance.getDOMNode(), options);
expect(cmObj.setValue).toHaveBeenCalledWith('Init text');
expect(cmObj.refresh).toHaveBeenCalled();
});
it('change value', ()=>{
cmInstance.setProps({value: 'the new text'});
expect(cmObj.setValue).toHaveBeenCalledWith('the new text');
expect(cmObj.refresh).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,606 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import '../helper/enzyme.helper';
import { withTheme } from '../fake_theme';
import { createMount } from '@material-ui/core/test-utils';
import { OutlinedInput, FormHelperText, IconButton, FormControlLabel,
Switch, Checkbox, Button, InputLabel } from '@material-ui/core';
import Select from 'react-select';
import CreatableSelect from 'react-select/creatable';
import CheckRoundedIcon from '@material-ui/icons/CheckRounded';
import ReportProblemIcon from '@material-ui/icons/ReportProblemRounded';
import InfoIcon from '@material-ui/icons/InfoRounded';
import CloseIcon from '@material-ui/icons/CloseRounded';
import CheckIcon from '@material-ui/icons/CheckCircleOutlineRounded';
import {FormInputText, FormInputFileSelect, FormInputSQL,
FormInputSwitch, FormInputCheckbox, FormInputToggle, FormInputSelect,
FormInputColor,
FormFooterMessage,
MESSAGE_TYPE} from '../../../pgadmin/static/js/components/FormComponents';
import * as legacyConnector from '../../../pgadmin/static/js/helpers/legacyConnector';
import CodeMirror from '../../../pgadmin/static/js/components/CodeMirror';
import { ToggleButton } from '@material-ui/lab';
import { DefaultButton, PrimaryButton } from '../../../pgadmin/static/js/components/Buttons';
/* MUI Components need to be wrapped in Theme for theme vars */
describe('FormComponents', ()=>{
let mount;
/* Use createMount so that material ui components gets the required context */
/* https://material-ui.com/guides/testing/#api */
beforeAll(()=>{
mount = createMount();
});
afterAll(() => {
mount.cleanUp();
});
beforeEach(()=>{
jasmineEnzyme();
});
describe('FormInputText', ()=>{
let ThemedFormInputText = withTheme(FormInputText), ctrl;
beforeEach(()=>{
ctrl = mount(
<ThemedFormInputText
label="First"
className="someClass"
testcid="inpCid"
helpMessage="some help message"
/* InputText */
readonly={false}
disabled={false}
maxlength={50}
value={'thevalue'}
controlProps={{
extraprop: 'test'
}}
/>);
});
it('init', ()=>{
expect(ctrl.find(InputLabel).text()).toBe('First');
expect(ctrl.find(OutlinedInput).prop('extraprop')).toEqual('test');
expect( ctrl.find(OutlinedInput).prop('inputProps')).toEqual(jasmine.objectContaining({
maxLength: 50,
}));
expect(ctrl.find(OutlinedInput).prop('readOnly')).toBe(false);
expect(ctrl.find(OutlinedInput).prop('disabled')).toBe(false);
expect(ctrl.find(OutlinedInput).prop('value')).toBe('thevalue');
expect(ctrl.find(FormHelperText).text()).toBe('some help message');
});
it('props change', ()=>{
let onChange = ()=>{};
ctrl.setProps({
readonly: true,
disabled: true,
value: 'new value',
onChange: onChange,
});
expect(ctrl.find(OutlinedInput).prop('readOnly')).toBe(true);
expect(ctrl.find(OutlinedInput).prop('disabled')).toBe(true);
expect(ctrl.find(OutlinedInput).prop('value')).toBe('new value');
expect(ctrl.find(OutlinedInput).prop('onChange')).toBe(onChange);
});
it('accessibility', ()=>{
expect(ctrl.find(InputLabel)).toHaveProp('htmlFor', 'inpCid');
expect(ctrl.find(FormHelperText)).toHaveProp('id', 'hinpCid');
let inputProps = ctrl.find(OutlinedInput).prop('inputProps');
expect(inputProps).toEqual(jasmine.objectContaining({
id: 'inpCid',
'aria-describedby': 'hinpCid',
}));
});
});
describe('FormInputFileSelect', ()=>{
let ThemedFormInputFileSelect = withTheme(FormInputFileSelect), ctrl;
beforeEach(()=>{
spyOn(legacyConnector, 'showFileDialog').and.callFake((controlProps, onFileSelect)=>{
onFileSelect('selected/file');
});
ctrl = mount(
<ThemedFormInputFileSelect
label="First"
className="someClass"
testcid="inpCid"
helpMessage="some help message"
/* InputText */
readonly={false}
disabled={false}
value={'thevalue'}
controlProps={{
dialogType: 'select_file', supportedTypes: ['*'],
}}
/>);
});
it('init', ()=>{
expect(ctrl.find(InputLabel).text()).toBe('First');
expect(ctrl.find(OutlinedInput).prop('readOnly')).toBe(false);
expect(ctrl.find(OutlinedInput).prop('disabled')).toBe(false);
expect(ctrl.find(OutlinedInput).prop('value')).toBe('thevalue');
expect(ctrl.find(FormHelperText).text()).toBe('some help message');
});
it('props change', ()=>{
ctrl.setProps({
readonly: true,
disabled: true,
value: 'new value',
});
expect(ctrl.find(OutlinedInput).prop('readOnly')).toBe(true);
expect(ctrl.find(OutlinedInput).prop('disabled')).toBe(true);
expect(ctrl.find(OutlinedInput).prop('value')).toBe('new value');
expect(ctrl.find(IconButton).prop('disabled')).toBe(true);
});
it('file select', (done)=>{
let onChange = jasmine.createSpy();
ctrl.setProps({
onChange: onChange,
});
ctrl.find(IconButton).simulate('click');
setTimeout(()=>{
expect(onChange).toHaveBeenCalledWith('selected/file');
done();
}, 0);
});
it('accessibility', ()=>{
expect(ctrl.find(InputLabel)).toHaveProp('htmlFor', 'inpCid');
expect(ctrl.find(FormHelperText)).toHaveProp('id', 'hinpCid');
let inputProps = ctrl.find(OutlinedInput).prop('inputProps');
expect(inputProps).toEqual(jasmine.objectContaining({
id: 'inpCid',
'aria-describedby': 'hinpCid',
}));
});
});
describe('FormInputSQL', ()=>{
let ThemedFormInputSQL = withTheme(FormInputSQL), ctrl;
beforeEach(()=>{
ctrl = mount(
<ThemedFormInputSQL
label="First"
className="someClass"
testcid="inpCid"
helpMessage="some help message"
/* InputSQL */
value={'thevalue'}
controlProps={{
op1: 'test'
}}
/>);
});
it('init', ()=>{
expect(ctrl.find(InputLabel).text()).toBe('First');
expect(ctrl.find(CodeMirror).prop('value')).toEqual('thevalue');
expect(ctrl.find(CodeMirror).prop('options')).toEqual(jasmine.objectContaining({
op1: 'test'
}));
expect(ctrl.find(FormHelperText).text()).toBe('some help message');
});
});
describe('FormInputSwitch', ()=>{
let ThemedFormInputSwitch = withTheme(FormInputSwitch), ctrl, onChange=()=>{return 1;};
beforeEach(()=>{
ctrl = mount(
<ThemedFormInputSwitch
label="First"
className="someClass"
testcid="inpCid"
helpMessage="some help message"
/* InputSwitch */
readonly={false}
value={false}
onChange={onChange}
/>);
});
it('init', ()=>{
expect(ctrl.find(InputLabel).text()).toBe('First');
expect(ctrl.find(Switch).prop('checked')).toBe(false);
expect(ctrl.find(Switch).prop('onChange')).toBe(onChange);
expect(ctrl.find(FormHelperText).text()).toBe('some help message');
});
it('props change', ()=>{
ctrl.setProps({
readonly: true,
value: true,
});
expect(ctrl.find(Switch).prop('checked')).toBe(true);
expect(ctrl.find(Switch).prop('onChange')).not.toBe(onChange);
});
it('accessibility', ()=>{
expect(ctrl.find(InputLabel)).toHaveProp('htmlFor', 'inpCid');
expect(ctrl.find(FormHelperText)).toHaveProp('id', 'hinpCid');
expect(ctrl.find(Switch).prop('id')).toBe('inpCid');
let inputProps = ctrl.find(Switch).prop('inputProps');
expect(inputProps).toEqual(jasmine.objectContaining({
'aria-describedby': 'hinpCid',
}));
});
});
describe('FormInputCheckbox', ()=>{
let ThemedFormInputCheckbox = withTheme(FormInputCheckbox), ctrl, onChange=()=>{return 1;};
beforeEach(()=>{
ctrl = mount(
<ThemedFormInputCheckbox
label="First"
className="someClass"
testcid="inpCid"
helpMessage="some help message"
/* InputCheckbox */
disabled={false}
value={false}
onChange={onChange}
controlProps={{
label: 'Second'
}}
/>);
});
it('init', ()=>{
expect(ctrl.find(InputLabel).text()).toBe('First');
expect(ctrl.find(FormControlLabel).prop('label')).toBe('Second');
expect(ctrl.find(Checkbox).prop('checked')).toBe(false);
expect(ctrl.find(Checkbox).prop('onChange')).toBe(onChange);
expect(ctrl.find(FormHelperText).text()).toBe('some help message');
});
it('props change', ()=>{
ctrl.setProps({
readonly: true,
value: true,
});
expect(ctrl.find(Checkbox).prop('checked')).toBe(true);
expect(ctrl.find(Checkbox).prop('onChange')).not.toBe(onChange);
});
it('accessibility', ()=>{
expect(ctrl.find(InputLabel)).toHaveProp('htmlFor', 'inpCid');
expect(ctrl.find(FormHelperText)).toHaveProp('id', 'hinpCid');
expect(ctrl.find(Checkbox).prop('id')).toBe('inpCid');
let inputProps = ctrl.find(Checkbox).prop('inputProps');
expect(inputProps).toEqual(jasmine.objectContaining({
'aria-describedby': 'hinpCid',
}));
});
});
describe('FormInputToggle', ()=>{
let ThemedFormInputToggle = withTheme(FormInputToggle), ctrl, onChange=()=>{return 1;};
beforeEach(()=>{
ctrl = mount(
<ThemedFormInputToggle
label="First"
className="someClass"
testcid="inpCid"
helpMessage="some help message"
/* InputToggle */
disabled={false}
options={[
{label: 'Op1', value: 1},
{label: 'Op2', value: 2},
{label: 'Op3', value: 3},
]}
value={2}
onChange={onChange}
/>);
});
it('init', ()=>{
expect(ctrl.find(InputLabel).text()).toBe('First');
expect(ctrl.find(ToggleButton).length).toBe(3);
expect(ctrl.find(PrimaryButton).length).toBe(1);
expect(ctrl.find(DefaultButton).length).toBe(2);
expect(ctrl.find(ToggleButton).at(1).prop('component')).toBe(PrimaryButton);
expect(ctrl.find(FormHelperText).text()).toBe('some help message');
});
it('props change', ()=>{
ctrl.setProps({
value: 1,
});
expect(ctrl.find(ToggleButton).at(0).prop('component')).toBe(PrimaryButton);
expect(ctrl.find(ToggleButton).at(0)
.find(CheckRoundedIcon)
.prop('style')).toEqual(jasmine.objectContaining({
visibility: 'visible'
}));
});
it('accessibility', ()=>{
expect(ctrl.find(InputLabel)).toHaveProp('htmlFor', 'inpCid');
expect(ctrl.find(FormHelperText)).toHaveProp('id', 'hinpCid');
});
});
describe('FormInputSelect', ()=>{
let ThemedFormInputSelect = withTheme(FormInputSelect), ctrl, onChange=jasmine.createSpy('onChange'),
ctrlMount = (props)=>{
ctrl?.unmount();
ctrl = mount(
<ThemedFormInputSelect
label="First"
className="someClass"
testcid="inpCid"
helpMessage="some help message"
/* InputSelect */
readonly={false}
disabled={false}
options={[
{label: 'Op1', value: 1},
{label: 'Op2', value: 2},
{label: 'Op3', value: 3},
]}
value={1}
onChange={onChange}
{...props}
/>);
};
beforeEach(()=>{
ctrlMount();
});
it('init', (done)=>{
expect(ctrl.find(Select).exists()).toBe(true);
expect(ctrl.find(CreatableSelect).exists()).toBe(false);
expect(ctrl.find(FormHelperText).text()).toBe('some help message');
setTimeout(()=>{
ctrl.update();
expect(ctrl.find(Select).props()).toEqual(jasmine.objectContaining({
isMulti: false,
value: {label: 'Op1', value: 1},
inputId: 'inpCid',
isSearchable: true,
isClearable: true,
isDisabled: false,
}));
done();
}, 0);
});
it('readonly disabled', (done)=>{
ctrl.setProps({
readonly: true,
disabled: true,
});
setTimeout(()=>{
ctrl.update();
expect(ctrl.find(Select).props()).toEqual(jasmine.objectContaining({
isSearchable: false,
isClearable: false,
isDisabled: true,
openMenuOnClick: false,
}));
done();
}, 0);
});
it('no-clear with multi', (done)=>{
ctrl.setProps({
controlProps: {
allowClear: false,
multiple: true,
},
value: [2, 3],
});
setTimeout(()=>{
ctrl.update();
expect(ctrl.find(Select).props()).toEqual(jasmine.objectContaining({
isMulti: true,
isClearable: false,
value: [{label: 'Op2', value: 2}, {label: 'Op3', value: 3}]
}));
done();
}, 0);
});
it('creatable with multi', (done)=>{
ctrl.setProps({
controlProps: {
creatable: true,
multiple: true,
},
value: ['val1', 'val2'],
});
setTimeout(()=>{
ctrl.update();
expect(ctrl.find(Select).exists()).toBe(false);
expect(ctrl.find(CreatableSelect).exists()).toBe(true);
expect(ctrl.find(CreatableSelect).props()).toEqual(jasmine.objectContaining({
isMulti: true,
value: [{label: 'val1', value: 'val1'}, {label: 'val2', value: 'val2'}]
}));
done();
}, 0);
});
it('promise options', (done)=>{
let optionsLoaded = jasmine.createSpy();
let res = [
{label: 'PrOp1', value: 1},
{label: 'PrOp2', value: 2},
{label: 'PrOp3', value: 3},
];
/* For options change, remount needed */
ctrlMount({
options: ()=>Promise.resolve(res),
value: 3,
optionsLoaded: optionsLoaded,
});
setTimeout(()=>{
ctrl.update();
expect(optionsLoaded).toHaveBeenCalledWith(res, 3);
expect(ctrl.find(Select).props()).toEqual(jasmine.objectContaining({
value: {label: 'PrOp3', value: 3},
}));
done();
}, 0);
});
it('accessibility', ()=>{
expect(ctrl.find(InputLabel)).toHaveProp('htmlFor', 'inpCid');
expect(ctrl.find(FormHelperText)).toHaveProp('id', 'hinpCid');
});
});
describe('FormInputColor', ()=>{
let pickrObj = React.createRef();
let ThemedFormInputColor = withTheme(FormInputColor), ctrl, onChange=jasmine.createSpy('onChange');
beforeEach(()=>{
ctrl = mount(
<ThemedFormInputColor
label="First"
className="someClass"
testcid="inpCid"
helpMessage="some help message"
/* InputColor */
disabled={false}
value="#f0f"
onChange={onChange}
currObj={(obj)=>pickrObj.current=obj}
/>);
});
afterEach(()=>{
ctrl.unmount();
});
it('init', (done)=>{
setTimeout(()=>{
ctrl.update();
expect(ctrl.find(Button).prop('style')).toEqual(jasmine.objectContaining({
backgroundColor: '#f0f',
}));
done();
}, 0);
});
it('no color', (done)=>{
ctrl.setProps({
value: null,
});
setTimeout(()=>{
ctrl.update();
expect(ctrl.find(Button).prop('style')).toEqual(jasmine.objectContaining({
backgroundColor: null,
}));
expect(ctrl.find(Button).find(CloseIcon).exists()).toBe(true);
done();
}, 0);
});
it('other events', (done)=>{
pickrObj.current.applyColor(false);
setTimeout(()=>{
expect(onChange).toHaveBeenCalled();
done();
}, 0);
});
it('accessibility', ()=>{
expect(ctrl.find(InputLabel)).toHaveProp('htmlFor', 'inpCid');
expect(ctrl.find(FormHelperText)).toHaveProp('id', 'hinpCid');
});
});
describe('FormFooterMessage', ()=>{
let ThemedFormFooterMessage = withTheme(FormFooterMessage), ctrl, onClose=jasmine.createSpy('onClose');
beforeEach(()=>{
ctrl = mount(
<ThemedFormFooterMessage
type={MESSAGE_TYPE.SUCCESS}
message="Some message"
closable={false}
onClose={onClose}
/>);
});
// if(close) {
// TheIcon = CloseIcon;
// } else if(type === MESSAGE_TYPE.SUCCESS) {
// TheIcon = CheckIcon;
// } else if(type === MESSAGE_TYPE.ERROR) {
// TheIcon = ReportProblemIcon;
// } else if(type === MESSAGE_TYPE.INFO) {
// TheIcon = InfoIcon;
// }
it('init', ()=>{
expect(ctrl.find(CheckIcon).exists()).toBeTrue();
expect(ctrl.text()).toBe('Some message');
});
it('change types', ()=>{
ctrl.setProps({
type: MESSAGE_TYPE.ERROR,
});
expect(ctrl.find(ReportProblemIcon).exists()).toBeTrue();
ctrl.setProps({
type: MESSAGE_TYPE.INFO,
});
expect(ctrl.find(InfoIcon).exists()).toBeTrue();
});
it('closable', ()=>{
ctrl.setProps({
closable: true,
});
expect(ctrl.find(CloseIcon).exists()).toBeTrue();
ctrl.find(IconButton).simulate('click');
expect(onClose).toHaveBeenCalled();
});
it('no message', ()=>{
ctrl.setProps({
message: '',
});
expect(ctrl.isEmptyRender()).toBeTrue();
});
});
});

View File

@ -0,0 +1,52 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import '../helper/enzyme.helper';
import { createMount } from '@material-ui/core/test-utils';
import { withTheme } from '../fake_theme';
import Loader from 'sources/components/Loader';
/* MUI Components need to be wrapped in Theme for theme vars */
describe('Loader', ()=>{
let mount, loaderInst, ThemedLoader;
/* Use createMount so that material ui components gets the required context */
/* https://material-ui.com/guides/testing/#api */
beforeAll(()=>{
mount = createMount();
/* Loader need Mui Theme context as well */
ThemedLoader = withTheme(Loader);
});
afterAll(() => {
mount.cleanUp();
});
beforeEach(()=>{
jasmineEnzyme();
loaderInst = mount(<ThemedLoader message={'loading'} />);
});
it('init', ()=>{
expect(loaderInst.find('.MuiTypography-root').text()).toBe('loading');
});
it('no message', ()=>{
loaderInst.setProps({message: ''});
expect(loaderInst.isEmptyRender()).toBeTruthy();
});
it('change message', ()=>{
loaderInst.setProps({message: 'test message'});
expect(loaderInst.find('.MuiTypography-root').text()).toBe('test message');
});
});

View File

@ -0,0 +1,204 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import '../helper/enzyme.helper';
import Privilege from 'sources/components/Privilege';
import { mount } from 'enzyme';
describe('Privilege', ()=>{
let ctrl, onChange = jasmine.createSpy('onChange');
beforeEach(()=>{
jasmineEnzyme();
ctrl = mount(
<Privilege
value={[{
privilege_type: 'C',
privilege: true,
with_grant: false,
},{
privilege_type: 'a',
privilege: true,
with_grant: true,
}]}
controlProps={{
supportedPrivs: ['C', 'a', 'r']
}}
onChange={onChange}
/>);
});
it('init', ()=>{
expect(ctrl.find('InputText').prop('value')).toBe('Ca*');
expect(ctrl.find('InputCheckbox[name="C"]').at(0).prop('value')).toBeTrue();
expect(ctrl.find('InputCheckbox[name="C"]').at(1).prop('value')).toBeFalse();
expect(ctrl.find('InputCheckbox[name="a"]').at(0).prop('value')).toBeTrue();
expect(ctrl.find('InputCheckbox[name="a"]').at(1).prop('value')).toBeTrue();
expect(ctrl.find('InputCheckbox[name="r"]').at(0).prop('value')).toBeFalse();
expect(ctrl.find('InputCheckbox[name="r"]').at(1).prop('value')).toBeFalse();
});
it('change prop value', ()=>{
ctrl.setProps({value: [{
privilege_type: 'C',
privilege: true,
with_grant: true,
},{
privilege_type: 'r',
privilege: true,
with_grant: false,
}]});
expect(ctrl.find('InputText').prop('value')).toBe('C*r');
expect(ctrl.find('InputCheckbox[name="C"]').at(0).prop('value')).toBeTrue();
expect(ctrl.find('InputCheckbox[name="C"]').at(1).prop('value')).toBeTrue();
expect(ctrl.find('InputCheckbox[name="a"]').at(0).prop('value')).toBeFalse();
expect(ctrl.find('InputCheckbox[name="a"]').at(1).prop('value')).toBeFalse();
expect(ctrl.find('InputCheckbox[name="r"]').at(0).prop('value')).toBeTrue();
expect(ctrl.find('InputCheckbox[name="r"]').at(1).prop('value')).toBeFalse();
});
it('no prop value', ()=>{
ctrl.setProps({value: null});
expect(ctrl.find('InputText').prop('value')).toBe('');
expect(ctrl.find('InputCheckbox[name="C"]').at(0).prop('value')).toBeFalse();
expect(ctrl.find('InputCheckbox[name="C"]').at(1).prop('value')).toBeFalse();
expect(ctrl.find('InputCheckbox[name="a"]').at(0).prop('value')).toBeFalse();
expect(ctrl.find('InputCheckbox[name="a"]').at(1).prop('value')).toBeFalse();
expect(ctrl.find('InputCheckbox[name="r"]').at(0).prop('value')).toBeFalse();
expect(ctrl.find('InputCheckbox[name="r"]').at(1).prop('value')).toBeFalse();
});
it('with grant disabled', ()=>{
expect(ctrl.find('InputCheckbox[name="all"]').at(1).prop('disabled')).toBeTrue();
expect(ctrl.find('InputCheckbox[name="r"]').at(1).prop('disabled')).toBeTrue();
});
it('on check click', (done)=>{
onChange.calls.reset();
ctrl.find('InputCheckbox[name="C"]').at(0).find('input').
simulate('change', {target: {checked: false, name: 'C'}});
setTimeout(()=>{
expect(onChange).toHaveBeenCalledWith([{
privilege_type: 'a',
privilege: true,
with_grant: true,
}]);
done();
}, 500);
});
it('on new check click', (done)=>{
onChange.calls.reset();
ctrl.find('InputCheckbox[name="r"]').at(0).find('input').
simulate('change', {target: {checked: true, name: 'r'}});
setTimeout(()=>{
expect(onChange).toHaveBeenCalledWith([{
privilege_type: 'C',
privilege: true,
with_grant: false,
},{
privilege_type: 'a',
privilege: true,
with_grant: true,
},{
privilege_type: 'r',
privilege: true,
with_grant: false,
}]);
done();
}, 500);
});
it('on check grant click', (done)=>{
onChange.calls.reset();
ctrl.find('InputCheckbox[name="C"]').at(1).find('input').
simulate('change', {target: {checked: true, name: 'C'}});
setTimeout(()=>{
expect(onChange).toHaveBeenCalledWith([{
privilege_type: 'C',
privilege: true,
with_grant: true,
},{
privilege_type: 'a',
privilege: true,
with_grant: true,
}]);
done();
}, 500);
});
it('on all click', (done)=>{
ctrl.find('InputCheckbox[name="all"]').at(0).find('input').simulate('change', {target: {checked: true}});
setTimeout(()=>{
expect(onChange).toHaveBeenCalledWith([{
privilege_type: 'C',
privilege: true,
with_grant: false,
},{
privilege_type: 'a',
privilege: true,
with_grant: true,
},{
privilege_type: 'r',
privilege: true,
with_grant: false,
}]);
done();
}, 500);
});
it('on all with grant click', (done)=>{
ctrl.setProps({
value: [{
privilege_type: 'C',
privilege: true,
with_grant: false,
},{
privilege_type: 'a',
privilege: true,
with_grant: true,
},{
privilege_type: 'r',
privilege: true,
with_grant: false,
}]
});
ctrl.find('InputCheckbox[name="all"]').at(1).find('input').simulate('change', {target: {checked: true}});
setTimeout(()=>{
expect(onChange).toHaveBeenCalledWith([{
privilege_type: 'C',
privilege: true,
with_grant: true,
},{
privilege_type: 'a',
privilege: true,
with_grant: true,
},{
privilege_type: 'r',
privilege: true,
with_grant: true,
}]);
done();
}, 500);
});
});

View File

@ -0,0 +1,48 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import '../helper/enzyme.helper';
import { createMount } from '@material-ui/core/test-utils';
import { withTheme } from '../fake_theme';
import TabPanel from 'sources/components/TabPanel';
/* MUI Components need to be wrapped in Theme for theme vars */
describe('TabPanel', ()=>{
let mount, panelInst, ThemedPanel;
/* Use createMount so that material ui components gets the required context */
/* https://material-ui.com/guides/testing/#api */
beforeAll(()=>{
mount = createMount();
/* Need Mui Theme context as well */
ThemedPanel = withTheme(TabPanel);
});
afterAll(() => {
mount.cleanUp();
});
beforeEach(()=>{
jasmineEnzyme();
panelInst = mount(<ThemedPanel value={1} index={0}><h1>test</h1></ThemedPanel>);
});
it('init', ()=>{
expect(panelInst.getDOMNode().hidden).toBeTrue();
expect(panelInst.find('h1')).not.toBe(null);
});
it('tab select', ()=>{
panelInst.setProps({value: 0});
expect(panelInst.getDOMNode().hidden).toBeFalse();
});
});

View File

@ -9,6 +9,7 @@
define(function () {
return {
'id': 'pgadmin4@pgadmin.org',
'current_auth_source': 'internal'
};
});

View File

@ -0,0 +1,20 @@
//////////////////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////////////////
define('pgadmin.browser.messages',['sources/pgadmin'], function(pgAdmin) {
var pgBrowser = pgAdmin.Browser = pgAdmin.Browser || {};
if (pgBrowser.messages)
return pgBrowser.messages;
pgBrowser.messages = {
'CANNOT_BE_EMPTY': '\'%s\' cannot be empty.',
};
return pgBrowser;
});

View File

@ -0,0 +1,17 @@
//////////////////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////////////////
define(function () {
return [
{label: 'Greenplum Database', value: 'gpdb'},
{label: 'EDB Advanced Server', value: 'ppas'},
{label: 'PostgreSQL', value: 'pg'},
{label: 'Unknown', value: ''},
];
});

View File

@ -0,0 +1,9 @@
import React from 'react';
import Theme from 'sources/Theme';
export function withTheme(WrappedComp) {
let NewComp = (props)=>{
return <Theme><WrappedComp {...props}/></Theme>;
};
return NewComp;
}

View File

@ -0,0 +1,120 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import _ from 'lodash';
import '../helper/enzyme.helper';
import { createMount } from '@material-ui/core/test-utils';
import pgAdmin from 'sources/pgadmin';
import {messages} from '../fake_messages';
import SchemaView from '../../../pgadmin/static/js/SchemaView';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
import DatabaseSchema from '../../../pgadmin/browser/server_groups/servers/databases/static/js/database.ui';
class MockSchema extends BaseUISchema {
get baseFields() {
return [];
}
}
describe('DatabaseSchema', ()=>{
let mount;
let schemaObj = new DatabaseSchema(
()=>new MockSchema(),
()=>new MockSchema(),
{
role: ()=>[],
encoding: ()=>[],
template: ()=>[],
spcname: ()=>[],
datcollate: ()=>[],
datctype: ()=>[],
},
{
datowner: 'postgres',
}
);
let getInitData = ()=>Promise.resolve({});
/* Use createMount so that material ui components gets the required context */
/* https://material-ui.com/guides/testing/#api */
beforeAll(()=>{
mount = createMount();
});
afterAll(() => {
mount.cleanUp();
});
beforeEach(()=>{
jasmineEnzyme();
/* messages used by validators */
pgAdmin.Browser = pgAdmin.Browser || {};
pgAdmin.Browser.messages = pgAdmin.Browser.messages || messages;
pgAdmin.Browser.utils = pgAdmin.Browser.utils || {};
pgAdmin.Browser.utils.support_ssh_tunnel = true;
});
it('create', ()=>{
mount(<SchemaView
formType='dialog'
schema={schemaObj}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={false}
disableSqlHelp={false}
/>);
});
it('edit', ()=>{
mount(<SchemaView
formType='dialog'
schema={schemaObj}
getInitData={getInitData}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={false}
disableSqlHelp={false}
/>);
});
it('properties', ()=>{
mount(<SchemaView
formType='tab'
schema={schemaObj}
getInitData={getInitData}
viewHelperProps={{
mode: 'properties',
}}
onHelp={()=>{}}
onEdit={()=>{}}
/>);
});
it('schema_res depChange', ()=>{
let depChange = _.find(schemaObj.fields, (f)=>f.id=='schema_res').depChange;
depChange({schema_res: 'abc'});
expect(schemaObj.informText).toBe('Please refresh the Schemas node to make changes to the schema restriction take effect.');
});
});

View File

@ -0,0 +1,130 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import '../helper/enzyme.helper';
import { createMount } from '@material-ui/core/test-utils';
import pgAdmin from 'sources/pgadmin';
import {messages} from '../fake_messages';
import SchemaView from '../../../pgadmin/static/js/SchemaView';
import PrivilegeRoleSchema, {getNodePrivilegeRoleSchema} from '../../../pgadmin/browser/server_groups/servers/static/js/privilege.ui';
import {DefaultPrivSchema} from '../../../pgadmin/browser/server_groups/servers/databases/static/js/database.ui';
import * as nodeAjax from '../../../pgadmin/browser/static/js/node_ajax';
describe('PrivilegeSchema', ()=>{
let mount;
let schemaObj = new PrivilegeRoleSchema(
()=>[],
()=>[],
null,
{server: {user: {name: 'postgres'}}},
['X']
);
let getInitData = ()=>Promise.resolve({});
/* Use createMount so that material ui components gets the required context */
/* https://material-ui.com/guides/testing/#api */
beforeAll(()=>{
mount = createMount();
});
afterAll(() => {
mount.cleanUp();
});
beforeEach(()=>{
jasmineEnzyme();
/* messages used by validators */
pgAdmin.Browser = pgAdmin.Browser || {};
pgAdmin.Browser.messages = pgAdmin.Browser.messages || messages;
pgAdmin.Browser.utils = pgAdmin.Browser.utils || {};
pgAdmin.Browser.utils.support_ssh_tunnel = true;
});
it('create', ()=>{
mount(<SchemaView
formType='dialog'
schema={schemaObj}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={false}
disableSqlHelp={false}
/>);
});
it('edit', ()=>{
mount(<SchemaView
formType='dialog'
schema={schemaObj}
getInitData={getInitData}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={false}
disableSqlHelp={false}
/>);
});
it('properties', ()=>{
mount(<SchemaView
formType='tab'
schema={schemaObj}
getInitData={getInitData}
viewHelperProps={{
mode: 'properties',
}}
onHelp={()=>{}}
onEdit={()=>{}}
/>);
});
it('validate', ()=>{
let state = {};
let setError = jasmine.createSpy('setError');
schemaObj.validate(state, setError);
expect(setError).toHaveBeenCalledWith('privileges', 'At least one privilege should be selected.');
});
it('DefaultPrivSchema', ()=>{
spyOn(nodeAjax, 'getNodeListByName').and.returnValue([]);
let defPrivObj = new DefaultPrivSchema((privileges)=>getNodePrivilegeRoleSchema({}, {server: {user: {name: 'postgres'}}}, {}, privileges));
let ctrl = mount(<SchemaView
formType='dialog'
schema={defPrivObj}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={false}
disableSqlHelp={false}
/>);
/* Make sure you hit every corner */
ctrl.find('DataGridView').at(0).find('PgIconButton[data-test="add-row"]').find('button').simulate('click');
});
});

View File

@ -0,0 +1,139 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import '../helper/enzyme.helper';
import { createMount } from '@material-ui/core/test-utils';
import pgAdmin from 'sources/pgadmin';
import {messages} from '../fake_messages';
import SchemaView from '../../../pgadmin/static/js/SchemaView';
import ServerSchema from '../../../pgadmin/browser/server_groups/servers/static/js/server.ui';
describe('ServerSchema', ()=>{
let mount;
let schemaObj = new ServerSchema([{
label: 'Servers', value: 1,
}], {
user_id: 'jasmine',
});
let getInitData = ()=>Promise.resolve({});
/* Use createMount so that material ui components gets the required context */
/* https://material-ui.com/guides/testing/#api */
beforeAll(()=>{
mount = createMount();
});
afterAll(() => {
mount.cleanUp();
});
beforeEach(()=>{
jasmineEnzyme();
/* messages used by validators */
pgAdmin.Browser = pgAdmin.Browser || {};
pgAdmin.Browser.messages = pgAdmin.Browser.messages || messages;
pgAdmin.Browser.utils = pgAdmin.Browser.utils || {};
pgAdmin.Browser.utils.support_ssh_tunnel = true;
});
it('create', ()=>{
mount(<SchemaView
formType='dialog'
schema={schemaObj}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={false}
disableSqlHelp={false}
/>);
});
it('edit', ()=>{
mount(<SchemaView
formType='dialog'
schema={schemaObj}
getInitData={getInitData}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={false}
disableSqlHelp={false}
/>);
});
it('properties', ()=>{
mount(<SchemaView
formType='tab'
schema={schemaObj}
getInitData={getInitData}
viewHelperProps={{
mode: 'properties',
}}
onHelp={()=>{}}
onEdit={()=>{}}
/>);
});
it('validate', ()=>{
let state = {};
let setError = jasmine.createSpy('setError');
schemaObj.validate(state, setError);
expect(setError).toHaveBeenCalledWith('host', 'Either Host name, Address or Service must be specified.');
state.hostaddr = 'incorrectip';
schemaObj.validate(state, setError);
expect(setError).toHaveBeenCalledWith('hostaddr', 'Host address must be valid IPv4 or IPv6 address.');
state.host = '127.0.0.1';
state.hostaddr = null;
schemaObj.validate(state, setError);
expect(setError).toHaveBeenCalledWith('username', 'Username must be specified.');
state.username = 'postgres';
schemaObj.validate(state, setError);
expect(setError).toHaveBeenCalledWith('port', 'Port must be specified.');
state.port = 5432;
state.use_ssh_tunnel = true;
schemaObj.validate(state, setError);
expect(setError).toHaveBeenCalledWith('tunnel_host', 'SSH Tunnel host must be specified.');
state.service = 'pgservice';
state.tunnel_host = 'localhost';
schemaObj.validate(state, setError);
expect(setError).toHaveBeenCalledWith('tunnel_port', 'SSH Tunnel port must be specified.');
state.tunnel_port = 8080;
schemaObj.validate(state, setError);
expect(setError).toHaveBeenCalledWith('tunnel_username', 'SSH Tunnel username must be specified.');
state.tunnel_username = 'jasmine';
state.tunnel_authentication = true;
schemaObj.validate(state, setError);
expect(setError).toHaveBeenCalledWith('tunnel_identity_file', 'SSH Tunnel identity file must be specified.');
state.tunnel_identity_file = '/file/path/xyz.pem';
expect(schemaObj.validate(state, setError)).toBeFalse();
});
});

View File

@ -0,0 +1,90 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import '../helper/enzyme.helper';
import { createMount } from '@material-ui/core/test-utils';
import pgAdmin from 'sources/pgadmin';
import {messages} from '../fake_messages';
import SchemaView from '../../../pgadmin/static/js/SchemaView';
import ServerGroupSchema from '../../../pgadmin/browser/server_groups/static/js/server_group.ui';
describe('ServerGroupSchema', ()=>{
let mount;
let schemaObj = new ServerGroupSchema();
let getInitData = ()=>Promise.resolve({});
/* Use createMount so that material ui components gets the required context */
/* https://material-ui.com/guides/testing/#api */
beforeAll(()=>{
mount = createMount();
});
afterAll(() => {
mount.cleanUp();
});
beforeEach(()=>{
jasmineEnzyme();
/* messages used by validators */
pgAdmin.Browser = pgAdmin.Browser || {};
pgAdmin.Browser.messages = pgAdmin.Browser.messages || messages;
});
it('create', ()=>{
mount(<SchemaView
formType='dialog'
schema={schemaObj}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={false}
disableSqlHelp={false}
/>);
});
it('edit', ()=>{
mount(<SchemaView
formType='dialog'
schema={schemaObj}
getInitData={getInitData}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={false}
disableSqlHelp={false}
/>);
});
it('properties', ()=>{
mount(<SchemaView
formType='tab'
schema={schemaObj}
getInitData={getInitData}
viewHelperProps={{
mode: 'properties',
}}
onHelp={()=>{}}
onEdit={()=>{}}
/>);
});
});

View File

@ -0,0 +1,155 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2021, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import '../helper/enzyme.helper';
import { createMount } from '@material-ui/core/test-utils';
import pgAdmin from 'sources/pgadmin';
import {messages} from '../fake_messages';
import SchemaView from '../../../pgadmin/static/js/SchemaView';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
import VariableSchema, {getNodeVariableSchema} from '../../../pgadmin/browser/server_groups/servers/static/js/variable.ui';
import * as nodeAjax from '../../../pgadmin/browser/static/js/node_ajax';
/* Used to check collection mode */
class MockSchema extends BaseUISchema {
constructor(getVariableSchema) {
super();
this.getVariableSchema = getVariableSchema;
}
get baseFields() {
return [{
id: 'variables', label: '', type: 'collection',
schema: this.getVariableSchema(),
editable: false,
group: 'Parameters', mode: ['edit', 'create'],
canAdd: true, canEdit: false, canDelete: true, hasRole: true,
node: 'role',
}];
}
}
describe('PrivilegeSchema', ()=>{
let mount;
let schemaObj = new VariableSchema(
()=>[],
()=>[],
()=>[],
null
);
let getInitData = ()=>Promise.resolve({});
/* Use createMount so that material ui components gets the required context */
/* https://material-ui.com/guides/testing/#api */
beforeAll(()=>{
mount = createMount();
});
afterAll(() => {
mount.cleanUp();
});
beforeEach(()=>{
jasmineEnzyme();
/* messages used by validators */
pgAdmin.Browser = pgAdmin.Browser || {};
pgAdmin.Browser.messages = pgAdmin.Browser.messages || messages;
pgAdmin.Browser.utils = pgAdmin.Browser.utils || {};
pgAdmin.Browser.utils.support_ssh_tunnel = true;
});
it('create', ()=>{
mount(<SchemaView
formType='dialog'
schema={schemaObj}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={false}
disableSqlHelp={false}
/>);
});
it('edit', ()=>{
mount(<SchemaView
formType='dialog'
schema={schemaObj}
getInitData={getInitData}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={false}
disableSqlHelp={false}
/>);
});
it('properties', ()=>{
mount(<SchemaView
formType='tab'
schema={schemaObj}
getInitData={getInitData}
viewHelperProps={{
mode: 'properties',
}}
onHelp={()=>{}}
onEdit={()=>{}}
/>);
});
it('getValueFieldProps', ()=>{
expect(schemaObj.getValueFieldProps({vartype: 'bool'})).toBe('switch');
expect(schemaObj.getValueFieldProps({vartype: 'enum', enumvals: []})).toEqual(jasmine.objectContaining({
cell: 'select',
}));
expect(schemaObj.getValueFieldProps({vartype: 'integer'})).toBe('int');
expect(schemaObj.getValueFieldProps({vartype: 'real'})).toBe('number');
expect(schemaObj.getValueFieldProps({vartype: 'string'})).toBe('text');
expect(schemaObj.getValueFieldProps({})).toBe('');
});
it('variable collection', ()=>{
spyOn(nodeAjax, 'getNodeAjaxOptions').and.returnValue([]);
spyOn(nodeAjax, 'getNodeListByName').and.returnValue([]);
let varCollObj = new MockSchema(()=>getNodeVariableSchema({}, {server: {user: {name: 'postgres'}}}, {}, true, true));
let ctrl = mount(<SchemaView
formType='dialog'
schema={varCollObj}
viewHelperProps={{
mode: 'create',
}}
onSave={()=>{}}
onClose={()=>{}}
onHelp={()=>{}}
onEdit={()=>{}}
onDataChange={()=>{}}
confirmOnCloseReset={false}
hasSQL={false}
disableSqlHelp={false}
/>);
/* Make sure you hit every corner */
ctrl.find('DataGridView').at(0).find('PgIconButton[data-test="add-row"]').find('button').simulate('click');
});
});

View File

@ -152,7 +152,9 @@ var webpackShimConfig = {
'dagre': path.join(__dirname, 'node_modules/dagre'),
'graphlib': path.join(__dirname, 'node_modules/graphlib'),
'react': path.join(__dirname, 'node_modules/react'),
'react-dom': path.join(__dirname, 'node_modules/react-dom'),
'stylis': path.join(__dirname, 'node_modules/stylis'),
'popper.js': path.join(__dirname, 'node_modules/popper.js'),
// AciTree
'jquery.acitree': path.join(__dirname, './node_modules/acitree/js/jquery.aciTree.min'),

View File

@ -160,6 +160,8 @@ module.exports = {
'backgrid.filter': path.join(__dirname, './node_modules/backgrid-filter/backgrid-filter'),
'sources': sourcesDir + '/js',
'translations': regressionDir + '/javascript/fake_translations',
'pgadmin.browser.messages': regressionDir + '/javascript/fake_messages',
'pgadmin.server.supported_servers': regressionDir + '/javascript/fake_supported_servers',
'pgadmin.browser.endpoints': regressionDir + '/javascript/fake_endpoints',
'slickgrid': nodeModulesDir + '/slickgrid/',
'slickgrid.plugins': nodeModulesDir + '/slickgrid/plugins/',

File diff suppressed because it is too large Load Diff