Improved the extendability of the SchemaView and DataGridView. (#7876)

Restructured these modules for ease of maintenance and apply the single
responsibility principle (wherever applicable).

* SchemaView

 - Split the code based on the functionality and responsibility.
 - Introduced a new View 'InlineView' instead of using the
   'nextInline' configuration of the fields to have a better, and
   manageable view.
 - Using the separate class 'SchemaState' for managing the data and
   states of the SchemaView (separated from the 'useSchemaState'
   custom hook).
 - Introduced three new custom hooks 'useFieldValue',
   'useFieldOptions', 'useFieldError' for the individual control to
   use for each Schema Field.
 - Don't pass value as the parameter props, and let the
   'useFieldValue' and other custom hooks to decide, whether to
   rerender the control itself or the whole dialog/view. (single
   responsibility principle)
 - Introduced a new data store with a subscription facility.
 - Moving the field metadata (option) evaluation to a separate place
   for better management, and each option can be defined for a
   particular kind of field (for example - collection, row, cell,
   general, etc).
 - Allow to provide custom control for all kind of Schema field.

* DataGridView

 - Same as SchemaView, split the DataGridView call into smaller,
   manageable chunks. (For example - grid, row, mappedCell, etc).
 - Use context based approach for providing the row and table data
   instead of passing them as parameters to every component
   separately.
 - Have a facility to extend this feature separately in future.
   (for example - selectable cell, column grouping, etc.)
 - Separated the features like deletable, editable, reorder,
   expandable etc. cells using the above feature support.
 - Added ability to provide the CustomHeader, and CustomRow through the
   Schema field, which will extend the ability to customize better.
 - Removed the 'DataGridViewWithHeaderForm' as it has been achieved
   through providing 'CustomHeader', and also introduced
   'DataGridFormHeader' (a custom header) to achieve the same feature
   as 'DataGridViewWithHeaderForm'.
This commit is contained in:
Ashesh Vashi
2024-09-09 14:27:31 +05:30
committed by GitHub
parent e5012ea9c6
commit e9af0c3226
154 changed files with 5429 additions and 3058 deletions

View File

@@ -80,7 +80,7 @@ module.exports = [
'error',
'only-multiline',
],
'no-console': ['error', { allow: ['warn', 'error'] }],
'no-console': ['error', { allow: ['warn', 'error', 'trace'] }],
// We need to exclude below for RegEx case
'no-useless-escape': 'off',
'no-prototype-builtins': 'off',

View File

@@ -140,7 +140,7 @@
"react-leaflet": "^4.2.1",
"react-new-window": "^1.0.1",
"react-resize-detector": "^11.0.1",
"react-rnd": "^10.3.5",
"react-rnd": "^10.4.12",
"react-select": "^5.7.2",
"react-timer-hook": "^3.0.5",
"react-virtualized-auto-sizer": "^1.0.6",
@@ -152,7 +152,7 @@
"uplot-react": "^1.1.4",
"valid-filename": "^2.0.1",
"wkx": "^0.5.0",
"zustand": "^4.4.1"
"zustand": "^4.5.4"
},
"scripts": {
"linter": "yarn run eslint -c .eslintrc.js .",

View File

@@ -39,7 +39,9 @@ export class DomainConstSchema extends BaseUISchema {
id: 'convalidated', label: gettext('Validate?'), cell: 'checkbox',
type: 'checkbox',
readonly: function(state) {
let currCon = _.find(obj.top.origData.constraints, (con)=>con.conoid == state.conoid);
let currCon = _.find(
obj.top.origData.constraints, (con) => con.conoid == state.conoid
);
return !obj.isNew(state) && currCon.convalidated;
},
}

View File

@@ -9,7 +9,7 @@
import gettext from 'sources/gettext';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
import DataGridViewWithHeaderForm from 'sources/helpers/DataGridViewWithHeaderForm';
import { DataGridFormHeader } from 'sources/SchemaView/DataGridView';
import { isEmptyString } from '../../../../../../../../static/js/validators';
class TokenHeaderSchema extends BaseUISchema {
@@ -155,8 +155,8 @@ export default class FTSConfigurationSchema extends BaseUISchema {
group: gettext('Tokens'), mode: ['create','edit'],
editable: false, schema: this.tokColumnSchema,
headerSchema: this.tokHeaderSchema,
headerVisible: function() { return true;},
CustomControl: DataGridViewWithHeaderForm,
headerFormVisible: true,
GridHeader: DataGridFormHeader,
uniqueCol : ['token'],
canAdd: true, canEdit: false, canDelete: true,
}

View File

@@ -10,8 +10,8 @@ import gettext from 'sources/gettext';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
import _ from 'lodash';
import { isEmptyString } from 'sources/validators';
import { SCHEMA_STATE_ACTIONS } from '../../../../../../../../../../static/js/SchemaView';
import DataGridViewWithHeaderForm from '../../../../../../../../../../static/js/helpers/DataGridViewWithHeaderForm';
import { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView';
import { DataGridFormHeader } from 'sources/SchemaView/DataGridView';
import { getNodeAjaxOptions, getNodeListByName } from '../../../../../../../../../static/js/node_ajax';
import TableSchema from '../../../../static/js/table.ui';
import pgAdmin from 'sources/pgadmin';
@@ -342,10 +342,12 @@ export default class ExclusionConstraintSchema extends BaseUISchema {
group: gettext('Columns'), type: 'collection',
mode: ['create', 'edit', 'properties'],
editable: false, schema: this.exColumnSchema,
headerSchema: this.exHeaderSchema, headerVisible: (state)=>obj.isNew(state),
CustomControl: DataGridViewWithHeaderForm,
headerSchema: this.exHeaderSchema,
headerFormVisible: (state)=>obj.isNew(state),
GridHeader: DataGridFormHeader,
uniqueCol: ['column'],
canAdd: false, canDelete: function(state) {
canAdd: (state)=>obj.isNew(state),
canDelete: function(state) {
// We can't update columns of existing
return obj.isNew(state);
},

View File

@@ -11,11 +11,12 @@ import gettext from 'sources/gettext';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
import _ from 'lodash';
import { isEmptyString } from 'sources/validators';
import { SCHEMA_STATE_ACTIONS } from '../../../../../../../../../../static/js/SchemaView';
import DataGridViewWithHeaderForm from '../../../../../../../../../../static/js/helpers/DataGridViewWithHeaderForm';
import { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView';
import { DataGridFormHeader } from 'sources/SchemaView/DataGridView';
import { getNodeAjaxOptions, getNodeListByName } from '../../../../../../../../../static/js/node_ajax';
import TableSchema from '../../../../static/js/table.ui';
export function getNodeForeignKeySchema(treeNodeInfo, itemNodeData, pgBrowser, noColumns=false, initData={}) {
return new ForeignKeySchema({
local_column: noColumns ? [] : ()=>getNodeListByName('column', treeNodeInfo, itemNodeData),
@@ -58,12 +59,20 @@ class ForeignKeyHeaderSchema extends BaseUISchema {
}
addDisabled(state) {
return !(state.local_column && (state.references || this.origData.references) && state.referenced);
return !(
state.local_column && (
state.references || this.origData.references
) && state.referenced
);
}
/* Data to ForeignKeyColumnSchema will added using the header form */
getNewData(data) {
let references_table_name = _.find(this.refTables, (t)=>t.value==data.references || t.value == this.origData.references)?.label;
let references_table_name = _.find(
this.refTables,
(t) => t.value == data.references || t.value == this.origData.references
)?.label;
return {
local_column: data.local_column,
referenced: data.referenced,
@@ -229,7 +238,10 @@ export default class ForeignKeySchema extends BaseUISchema {
if(!obj.isNew(state)) {
let origData = {};
if(obj.inTable && obj.top) {
origData = _.find(obj.top.origData['foreign_key'], (r)=>r.cid == state.cid);
origData = _.find(
obj.top.origData['foreign_key'],
(r) => r.cid == state.cid
);
} else {
origData = obj.origData;
}
@@ -304,14 +316,14 @@ export default class ForeignKeySchema extends BaseUISchema {
group: gettext('Columns'), type: 'collection',
mode: ['create', 'edit', 'properties'],
editable: false, schema: this.fkColumnSchema,
headerSchema: this.fkHeaderSchema, headerVisible: (state)=>obj.isNew(state),
CustomControl: DataGridViewWithHeaderForm,
headerSchema: this.fkHeaderSchema,
headerFormVisible: (state)=>obj.isNew(state),
GridHeader: DataGridFormHeader,
uniqueCol: ['local_column', 'references', 'referenced'],
canAdd: false, canDelete: function(state) {
// We can't update columns of existing foreign key.
return obj.isNew(state);
},
readonly: obj.isReadonly, cell: ()=>({
canAdd: (state)=>obj.isNew(state),
canDelete: (state)=>obj.isNew(state),
readonly: obj.isReadonly,
cell: () => ({
cell: '',
controlProps: {
formatter: {
@@ -358,9 +370,10 @@ export default class ForeignKeySchema extends BaseUISchema {
}
}
if(actionObj.type == SCHEMA_STATE_ACTIONS.ADD_ROW) {
obj.fkHeaderSchema.origData.references = null;
// Set references value.
obj.fkHeaderSchema.origData.references = obj.fkHeaderSchema.sessData.references;
obj.fkHeaderSchema.origData.references = null;
obj.fkHeaderSchema.origData.references =
obj.fkHeaderSchema.sessData.references;
obj.fkHeaderSchema.origData._disable_references = true;
}
return {columns: currColumns};

View File

@@ -7,12 +7,13 @@
//
//////////////////////////////////////////////////////////////
import gettext from 'sources/gettext';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
import DataGridViewWithHeaderForm from '../../../../../../../../../static/js/helpers/DataGridViewWithHeaderForm';
import _ from 'lodash';
import { isEmptyString } from 'sources/validators';
import { DataGridFormHeader } from 'sources/SchemaView/DataGridView';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
import gettext from 'sources/gettext';
import pgAdmin from 'sources/pgadmin';
import { isEmptyString } from 'sources/validators';
function inSchema(node_info) {
@@ -23,8 +24,8 @@ class IndexColHeaderSchema extends BaseUISchema {
constructor(columns) {
super({
is_exp: true,
colname: undefined,
expression: undefined,
colname: '',
expression: '',
});
this.columns = columns;
@@ -90,10 +91,10 @@ class IndexColumnSchema extends BaseUISchema {
}
isEditable(state) {
let topObj = this._top;
let topObj = this.top;
if(this.inSchemaWithModelCheck(state)) {
return false;
} else if (topObj._sessData && topObj._sessData.amname === 'btree') {
} else if (topObj.sessData && topObj.sessData.amname === 'btree') {
state.is_sort_nulls_applicable = true;
return true;
} else {
@@ -155,9 +156,8 @@ class IndexColumnSchema extends BaseUISchema {
* to access method selected by user if not selected
* send btree related op_class options
*/
let amname = obj._top?._sessData ?
obj._top?._sessData.amname :
obj._top?.origData.amname;
let amname = obj.top?.sessData.amname ||
obj.top?.origData.amname;
if(_.isUndefined(amname))
return options;
@@ -573,10 +573,12 @@ export default class IndexSchema extends BaseUISchema {
group: gettext('Columns'), type: 'collection',
mode: ['create', 'edit', 'properties'],
editable: false, schema: this.indexColumnSchema,
headerSchema: this.indexHeaderSchema, headerVisible: (state)=>indexSchemaObj.isNew(state),
CustomControl: DataGridViewWithHeaderForm,
headerSchema: this.indexHeaderSchema,
headerFormVisible: (state)=>indexSchemaObj.isNew(state),
GridHeader: DataGridFormHeader,
uniqueCol: ['colname'],
canAdd: false, canDelete: function(state) {
canAdd: (state)=>indexSchemaObj.isNew(state),
canDelete: function(state) {
// We can't update columns of existing
return indexSchemaObj.isNew(state);
}, cell: ()=>({

View File

@@ -25,9 +25,11 @@ import { getNodePrivilegeRoleSchema } from '../../../../../static/js/privilege.u
import pgAdmin from 'sources/pgadmin';
export function getNodeTableSchema(treeNodeInfo, itemNodeData, pgBrowser) {
const spcname = ()=>getNodeListByName('tablespace', treeNodeInfo, itemNodeData, {}, (m)=>{
return (m.label != 'pg_global');
});
const spcname = () => getNodeListByName(
'tablespace', treeNodeInfo, itemNodeData, {}, (m) => {
return (m.label != 'pg_global');
}
);
let tableNode = pgBrowser.Nodes['table'];
@@ -48,9 +50,9 @@ export function getNodeTableSchema(treeNodeInfo, itemNodeData, pgBrowser) {
},
treeNodeInfo,
{
columns: ()=>getNodeColumnSchema(treeNodeInfo, itemNodeData, pgBrowser),
vacuum_settings: ()=>getNodeVacuumSettingsSchema(tableNode, treeNodeInfo, itemNodeData),
constraints: ()=>new ConstraintsSchema(
columns: () => getNodeColumnSchema(treeNodeInfo, itemNodeData, pgBrowser),
vacuum_settings: () => getNodeVacuumSettingsSchema(tableNode, treeNodeInfo, itemNodeData),
constraints: () => new ConstraintsSchema(
treeNodeInfo,
()=>getNodeForeignKeySchema(treeNodeInfo, itemNodeData, pgBrowser, true, {autoindex: false}),
()=>getNodeExclusionConstraintSchema(treeNodeInfo, itemNodeData, pgBrowser, true),
@@ -274,46 +276,47 @@ export class LikeSchema extends BaseUISchema {
id: 'like_default_value', label: gettext('With default values?'),
type: 'switch', mode: ['create'], deps: ['like_relation'],
disabled: this.isRelationDisable, depChange: (...args)=>obj.resetVals(...args),
inlineNext: true,
inlineGroup: 'like_relation',
},{
id: 'like_constraints', label: gettext('With constraints?'),
type: 'switch', mode: ['create'], deps: ['like_relation'],
disabled: this.isRelationDisable, depChange: (...args)=>obj.resetVals(...args),
inlineNext: true,
inlineGroup: 'like_relation',
},{
id: 'like_indexes', label: gettext('With indexes?'),
type: 'switch', mode: ['create'], deps: ['like_relation'],
disabled: this.isRelationDisable, depChange: (...args)=>obj.resetVals(...args),
inlineNext: true,
inlineGroup: 'like_relation',
},{
id: 'like_storage', label: gettext('With storage?'),
type: 'switch', mode: ['create'], deps: ['like_relation'],
disabled: this.isRelationDisable, depChange: (...args)=>obj.resetVals(...args),
inlineNext: true,
inlineGroup: 'like_relation',
},{
id: 'like_comments', label: gettext('With comments?'),
type: 'switch', mode: ['create'], deps: ['like_relation'],
disabled: this.isRelationDisable, depChange: (...args)=>obj.resetVals(...args),
inlineNext: true,
inlineGroup: 'like_relation',
},{
id: 'like_compression', label: gettext('With compression?'),
type: 'switch', mode: ['create'], deps: ['like_relation'],
disabled: this.isRelationDisable, depChange: (...args)=>obj.resetVals(...args),
min_version: 140000, inlineNext: true,
min_version: 140000, inlineGroup: 'like_relation',
},{
id: 'like_generated', label: gettext('With generated?'),
type: 'switch', mode: ['create'], deps: ['like_relation'],
disabled: this.isRelationDisable, depChange: (...args)=>obj.resetVals(...args),
min_version: 120000, inlineNext: true,
min_version: 120000, inlineGroup: 'like_relation',
},{
id: 'like_identity', label: gettext('With identity?'),
type: 'switch', mode: ['create'], deps: ['like_relation'],
disabled: this.isRelationDisable, depChange: (...args)=>obj.resetVals(...args),
inlineNext: true,
inlineGroup: 'like_relation',
},{
id: 'like_statistics', label: gettext('With statistics?'),
type: 'switch', mode: ['create'], deps: ['like_relation'],
disabled: this.isRelationDisable, depChange: (...args)=>obj.resetVals(...args),
inlineGroup: 'like_relation',
}
];
}
@@ -484,6 +487,12 @@ export default class TableSchema extends BaseUISchema {
}
};
}
},{
id: 'columns', type: 'group', label: gettext('Columns'),
},{
id: 'advanced', label: gettext('Advanced'), type: 'group',
},{
id: 'constraints', label: gettext('Constraints'), type: 'group',
},{
id: 'partition', type: 'group', label: gettext('Partitions'),
mode: ['edit', 'create'], min_version: 100000,
@@ -494,6 +503,12 @@ export default class TableSchema extends BaseUISchema {
// Always show in case of create mode
return (obj.isNew(state) || state.is_partitioned);
},
},{
type: 'group', id: 'parameters', label: gettext('Parameters'),
visible: !this.inErd,
},{
id: 'security_group', type: 'group', label: gettext('Security'),
visible: !this.inErd,
},{
id: 'is_partitioned', label:gettext('Partitioned table?'), cell: 'switch',
type: 'switch', mode: ['properties', 'create', 'edit'],
@@ -510,9 +525,12 @@ export default class TableSchema extends BaseUISchema {
mode: ['properties', 'create', 'edit'], disabled: this.inCatalog,
},{
id: 'coll_inherits', label: gettext('Inherited from table(s)'),
type: 'select', group: gettext('Columns'),
type: 'select', group: 'columns',
deps: ['typname', 'is_partitioned'], mode: ['create', 'edit'],
controlProps: { multiple: true, allowClear: false, placeholder: gettext('Select to inherit from...')},
controlProps: {
multiple: true, allowClear: false,
placeholder: gettext('Select to inherit from...')
},
options: this.fieldOptions.coll_inherits, visible: !this.inErd,
optionsLoaded: (res)=>obj.inheritedTableList=res,
disabled: (state)=>{
@@ -611,9 +629,6 @@ export default class TableSchema extends BaseUISchema {
}
});
},
},{
id: 'advanced', label: gettext('Advanced'), type: 'group',
visible: true,
},
{
id: 'rlspolicy', label: gettext('RLS Policy?'), cell: 'switch',
@@ -654,12 +669,9 @@ export default class TableSchema extends BaseUISchema {
},{
// Tab control for columns
id: 'columns', label: gettext('Columns'), type: 'collection',
group: gettext('Columns'),
schema: this.columnsSchema,
mode: ['create', 'edit'],
disabled: this.inCatalog,
deps: ['typname', 'is_partitioned'],
depChange: (state, source, topState, actionObj)=>{
group: 'columns', schema: this.columnsSchema, mode: ['create', 'edit'],
disabled: this.inCatalog, deps: ['typname', 'is_partitioned'],
depChange: (state, source, topState, actionObj) => {
if(source[0] === 'columns') {
/* In ERD, attnum is an imp let for setting the links
Here, attnum is set to max avail value.
@@ -718,7 +730,7 @@ export default class TableSchema extends BaseUISchema {
allowMultipleEmptyRow: false,
},{
// Here we will create tab control for constraints
type: 'nested-tab', group: gettext('Constraints'),
type: 'nested-tab', group: 'constraints',
mode: ['edit', 'create'],
schema: obj.constraintsObj,
},{
@@ -995,17 +1007,12 @@ export default class TableSchema extends BaseUISchema {
'</li></ul>',
].join(''),
min_version: 100000,
},{
type: 'group', id: 'parameters', label: gettext('Parameters'),
visible: !this.inErd,
},{
// Here - we will create tab control for storage parameters
// (auto vacuum).
type: 'nested-tab', group: 'parameters',
mode: ['edit', 'create'], deps: ['is_partitioned'],
schema: this.vacuumSettingsSchema, visible: !this.inErd,
},{
id: 'security_group', type: 'group', label: gettext('Security'), visible: !this.inErd,
},
{
id: 'relacl_str', label: gettext('Privileges'), disabled: this.inCatalog,

View File

@@ -1056,7 +1056,7 @@ class DataTypeSchema extends BaseUISchema {
}
},{
id: 'maxsize',
group: gettext('Definition'),
group: gettext('Data Type'),
label: gettext('Size'),
type: 'int',
deps: ['typtype'],

View File

@@ -7,6 +7,7 @@
//
//////////////////////////////////////////////////////////////
import _ from 'lodash';
import gettext from 'sources/gettext';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
import SecLabelSchema from '../../../../../static/js/sec_label.ui';
@@ -154,7 +155,10 @@ export default class MViewSchema extends BaseUISchema {
if (state.definition) {
obj.warningText = null;
if (obj.origData.oid !== undefined && state.definition !== obj.origData.definition) {
if (
!_.isUndefined(obj.origData.oid) &&
state.definition !== obj.origData.definition
) {
obj.warningText = gettext(
'Updating the definition will drop and re-create the materialized view. It may result in loss of information about its dependent objects.'
) + '<br><br><b>' + gettext('Do you want to continue?') + '</b>';

View File

@@ -7,6 +7,7 @@
//
//////////////////////////////////////////////////////////////
import _ from 'lodash';
import gettext from 'sources/gettext';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
import SecLabelSchema from '../../../../../static/js/sec_label.ui';
@@ -135,9 +136,10 @@ export default class ViewSchema extends BaseUISchema {
}
if (state.definition) {
if (!(obj.nodeInfo.server.server_type == 'pg' &&
if (!(
obj.nodeInfo.server.server_type == 'pg' &&
// No need to check this when creating a view
obj.origData.oid !== undefined
!_.isUndefined(obj.sessData.oid)
) || (
state.definition === obj.origData.definition
)) {
@@ -150,7 +152,7 @@ export default class ViewSchema extends BaseUISchema {
).split('FROM'),
new_def = [];
if (state.definition !== undefined) {
if (!_.isUndefined(state.definition)) {
new_def = state.definition.replace(
/\s/gi, ''
).split('FROM');

View File

@@ -129,7 +129,10 @@ export default class SubscriptionSchema extends BaseUISchema{
id: 'port', label: gettext('Port'), type: 'int', group: gettext('Connection'),
mode: ['properties', 'edit', 'create'], min: 1, max: 65535,
depChange: (state)=>{
if(obj.origData.port != state.port && !obj.isNew(state) && state.connected){
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.'
);
@@ -145,7 +148,10 @@ export default class SubscriptionSchema extends BaseUISchema{
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){
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.'
);

View File

@@ -28,7 +28,7 @@ export default class PrivilegeRoleSchema extends BaseUISchema {
super({
grantee: undefined,
grantor: nodeInfo?.server?.user?.name,
privileges: undefined,
privileges: [],
});
this.granteeOptions = granteeOptions;
this.grantorOptions = grantorOptions;
@@ -56,9 +56,12 @@ export default class PrivilegeRoleSchema extends BaseUISchema {
{
id: 'privileges', label: gettext('Privileges'),
type: 'text', group: null,
cell: ()=>({cell: 'privilege', controlProps: {
supportedPrivs: this.supportedPrivs,
}}),
cell: () => ({
cell: 'privilege',
controlProps: {
supportedPrivs: this.supportedPrivs,
}
}),
disabled : function(state) {
return !(
obj.nodeInfo &&

View File

@@ -136,7 +136,7 @@ export default class ServerSchema extends BaseUISchema {
id: 'shared_username', label: gettext('Shared Username'), type: 'text',
controlProps: { maxLength: 64},
mode: ['properties', 'create', 'edit'], deps: ['shared', 'username'],
readonly: (s)=>{
readonly: (s) => {
return !(!this.origData.shared && s.shared);
}, visible: ()=>{
return current_user.is_admin && pgAdmin.server_mode == 'True';

View File

@@ -148,7 +148,7 @@ export default class VariableSchema extends BaseUISchema {
editable: function(state) {
return obj.isNew(state) || !obj.allReadOnly;
},
cell: ()=>({
cell: () => ({
cell: 'select',
options: this.vnameOptions,
optionsLoaded: (options)=>{obj.setVarTypes(options);},

View File

@@ -10,6 +10,7 @@
import gettext from 'sources/gettext';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
export default class ServerGroupSchema extends BaseUISchema {
constructor() {
super({
@@ -28,7 +29,7 @@ export default class ServerGroupSchema extends BaseUISchema {
id: 'name', label: gettext('Name'), type: 'text', group: null,
mode: ['properties', 'edit', 'create'], noEmpty: true,
disabled: false,
}
},
];
}
}

View File

@@ -52,7 +52,8 @@ export default function ObjectNodeProperties({panelId, node, treeNodeInfo, nodeD
const treeNodeId = objToString(treeNodeInfo);
let schema = useMemo(
() => node.getSchema(treeNodeInfo, nodeData), [treeNodeId, isActive]
() => node.getSchema(treeNodeInfo, nodeData),
[treeNodeId]
);
// We only have two actionTypes, 'create' and 'edit' to initiate the dialog,

View File

@@ -87,6 +87,7 @@ const StyledBox = styled(Box)(({theme}) => ({
},
}));
class PreferencesSchema extends BaseUISchema {
constructor(initValues = {}, schemaFields = []) {
super({
@@ -100,8 +101,8 @@ class PreferencesSchema extends BaseUISchema {
return 'id';
}
setSelectedCategory(category) {
this.category = category;
categoryUpdated() {
this.state?.validate(this.sessData);
}
get baseFields() {
@@ -110,7 +111,8 @@ class PreferencesSchema extends BaseUISchema {
}
function RightPanel({ schema, ...props }) {
function RightPanel({ schema, refreshKey, ...props }) {
const schemaViewRef = React.useRef(null);
let initData = () => new Promise((resolve, reject) => {
try {
resolve(props.initValues);
@@ -118,20 +120,31 @@ function RightPanel({ schema, ...props }) {
reject(error instanceof Error ? error : Error(gettext('Something went wrong')));
}
});
useEffect(() => {
const timeID = setTimeout(() => {
const focusableElement = schemaViewRef.current?.querySelector(
'button, a, input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElement) focusableElement.focus();
}, 50);
return () => clearTimeout(timeID);
}, [refreshKey]);
return (
<SchemaView
formType={'dialog'}
getInitData={initData}
viewHelperProps={{ mode: 'edit' }}
schema={schema}
showFooter={false}
isTabView={false}
formClassName='PreferencesComponent-preferencesContainerBackground'
onDataChange={(isChanged, changedData) => {
props.onDataChange(changedData);
}}
/>
<div ref={schemaViewRef}>
<SchemaView
formType={'dialog'}
getInitData={initData}
viewHelperProps={{ mode: 'edit' }}
schema={schema}
showFooter={false}
isTabView={false}
formClassName='PreferencesComponent-preferencesContainerBackground'
onDataChange={(isChanged, changedData) => {
props.onDataChange(changedData);
}}
/>
</div>
);
}
@@ -144,6 +157,7 @@ RightPanel.propTypes = {
export default function PreferencesComponent({ ...props }) {
const [refreshKey, setRefreshKey] = React.useState(0);
const [disableSave, setDisableSave] = React.useState(true);
const prefSchema = React.useRef(new PreferencesSchema({}, []));
const prefChangedData = React.useRef({});
@@ -214,12 +228,17 @@ export default function PreferencesComponent({ ...props }) {
setPrefTreeData(preferencesTreeData);
setInitValues(preferencesValues);
// set Preferences schema
prefSchema.current = new PreferencesSchema(preferencesValues, preferencesData);
prefSchema.current = new PreferencesSchema(
preferencesValues, preferencesData,
);
}).catch((err) => {
pgAdmin.Browser.notifier.alert(err);
});
}, []);
function setPreferences(node, subNode, nodeData, preferencesValues, preferencesData) {
function setPreferences(
node, subNode, nodeData, preferencesValues, preferencesData
) {
let addBinaryPathNote = false;
subNode.preferences.forEach((element) => {
let note = '';
@@ -335,9 +354,10 @@ export default function PreferencesComponent({ ...props }) {
preferencesData.push(
{
id: _.uniqueId('note') + subNode.id,
type: 'note', text: note,
type: 'note',
text: note,
'parentId': nodeData['id'],
visible: false,
'parentId': nodeData['id']
},
);
}
@@ -351,28 +371,25 @@ export default function PreferencesComponent({ ...props }) {
}
useEffect(() => {
let initTreeTimeout = null;
let firstElement = null;
// Listen selected preferences tree node event and show the appropriate components in right panel.
pgAdmin.Browser.Events.on('preferences:tree:selected', (event, item) => {
if (item.type == FileType.File) {
prefSchema.current.setSelectedCategory(item._metadata.data.name);
prefSchema.current.schemaFields.forEach((field) => {
field.visible = field.parentId === item._metadata.data.id && !field?.hidden ;
field.visible = field.parentId === item._metadata.data.id &&
!field?.hidden ;
if(field.visible && _.isNull(firstElement)) {
firstElement = field;
}
field.labelTooltip = item._parent._metadata.data.name.toLowerCase() + ':' + item._metadata.data.name + ':' + field.name;
field.labelTooltip =
item._parent._metadata.data.name.toLowerCase() + ':' +
item._metadata.data.name + ':' + field.name;
});
setLoadTree(crypto.getRandomValues(new Uint16Array(1)));
initTreeTimeout = setTimeout(() => {
prefTreeInit.current = true;
if(firstElement) {
//set focus on first element on right side panel.
document.getElementsByName(firstElement.id.toString())[0].focus();
firstElement = '';
}
}, 10);
prefSchema.current.categoryUpdated(item._metadata.data.id);
setLoadTree(Date.now());
setRefreshKey(Date.now());
}
else {
selectChildNode(item, prefTreeInit);
@@ -386,10 +403,6 @@ export default function PreferencesComponent({ ...props }) {
// Listen added preferences tree node event to expand the newly added node on tree load.
pgAdmin.Browser.Events.on('preferences:tree:added', addPrefTreeNode);
/* Clear the initTreeTimeout timeout if unmounted */
return () => {
clearTimeout(initTreeTimeout);
};
}, []);
function addPrefTreeNode(event, item) {
@@ -655,32 +668,54 @@ export default function PreferencesComponent({ ...props }) {
<Box className='PreferencesComponent-treeContainer' >
<Box className='PreferencesComponent-tree' id={'treeContainer'} tabIndex={0}>
{
useMemo(() => (prefTreeData && props.renderTree(prefTreeData)), [prefTreeData])
useMemo(
() => (prefTreeData && props.renderTree(prefTreeData)),
[prefTreeData]
)
}
</Box>
</Box>
<Box className='PreferencesComponent-preferencesContainer'>
{
prefSchema.current && loadTree > 0 &&
<RightPanel schema={prefSchema.current} initValues={initValues} onDataChange={(changedData) => {
Object.keys(changedData).length > 0 ? setDisableSave(false) : setDisableSave(true);
prefChangedData.current = changedData;
}}></RightPanel>
<RightPanel
schema={prefSchema.current} initValues={initValues}
refreshKey={refreshKey}
onDataChange={(changedData) => {
Object.keys(changedData).length > 0 ?
setDisableSave(false) : setDisableSave(true);
prefChangedData.current = changedData;
}}
></RightPanel>
}
</Box>
</Box>
<Box className='PreferencesComponent-footer'>
<Box>
<PgIconButton data-test="dialog-help" onClick={onDialogHelp} icon={<HelpIcon />} title={gettext('Help for this dialog.')} />
<PgIconButton
data-test="dialog-help" onClick={onDialogHelp}
icon={<HelpIcon />} title={gettext('Help for this dialog.')}
/>
</Box>
<Box className='PreferencesComponent-actionBtn' marginLeft="auto">
<DefaultButton className='PreferencesComponent-buttonMargin' onClick={reset} startIcon={<SettingsBackupRestoreIcon />}>
<DefaultButton className='PreferencesComponent-buttonMargin'
onClick={reset} startIcon={<SettingsBackupRestoreIcon />}>
{gettext('Reset all preferences')}
</DefaultButton>
<DefaultButton className='PreferencesComponent-buttonMargin' onClick={() => { props.closeModal();}} startIcon={<CloseSharpIcon onClick={() => { props.closeModal();}} />}>
<DefaultButton className='PreferencesComponent-buttonMargin'
onClick={() => { props.closeModal();}}
startIcon={
<CloseSharpIcon onClick={() => { props.closeModal();}} />
}>
{gettext('Cancel')}
</DefaultButton>
<PrimaryButton className='PreferencesComponent-buttonMargin' startIcon={<SaveSharpIcon />} disabled={disableSave} onClick={() => { savePreferences(prefChangedData, initValues); }}>
<PrimaryButton
className='PreferencesComponent-buttonMargin'
startIcon={<SaveSharpIcon />}
disabled={disableSave}
onClick={() => {
savePreferences(prefChangedData, initValues);
}}>
{gettext('Save')}
</PrimaryButton>
</Box>

View File

@@ -1,625 +0,0 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
/* The DataGridView component is based on react-table component */
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Box } from '@mui/material';
import AddIcon from '@mui/icons-material/AddOutlined';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getExpandedRowModel,
flexRender,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import PropTypes from 'prop-types';
import _ from 'lodash';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import {HTML5Backend} from 'react-dnd-html5-backend';
import { usePgAdmin } from 'sources/BrowserComponent';
import { PgIconButton } from 'sources/components/Buttons';
import {
PgReactTable, PgReactTableBody, PgReactTableCell, PgReactTableHeader,
PgReactTableRow, PgReactTableRowContent, PgReactTableRowExpandContent,
getDeleteCell, getEditCell, getReorderCell
} from 'sources/components/PgReactTableStyled';
import CustomPropTypes from 'sources/custom_prop_types';
import { useIsMounted } from 'sources/custom_hooks';
import { InputText } from 'sources/components/FormComponents';
import gettext from 'sources/gettext';
import { evalFunc, requestAnimationAndFocus } from 'sources/utils';
import FormView from './FormView';
import { MappedCellControl } from './MappedControl';
import {
SCHEMA_STATE_ACTIONS, SchemaStateContext, getFieldMetaData,
isModeSupportedByField
} from './common';
import { StyleDataGridBox } from './StyledComponents';
function DataTableRow({
index, row, totalRows, isResizing, isHovered, schema, schemaRef, accessPath,
moveRow, setHoverIndex, viewHelperProps
}) {
const [key, setKey] = useState(false);
const schemaState = useContext(SchemaStateContext);
const rowRef = useRef(null);
const dragHandleRef = useRef(null);
/* 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 = [JSON.stringify(row.original)];
const externalDeps = useMemo(()=>{
let retVal = [];
/* Calculate the fields which depends on the current field
deps has info on fields which the current field depends on. */
schema.fields.forEach((field)=>{
(evalFunc(null, field.deps) || []).forEach((dep)=>{
let source = accessPath.concat(dep);
if(_.isArray(dep)) {
source = dep;
/* If its an array, then dep is from the top schema and external */
retVal.push(source);
}
});
});
return retVal;
}, []);
useEffect(()=>{
schemaRef.current.fields.forEach((field)=>{
/* Self change is also dep change */
if(field.depChange || field.deferredDepChange) {
schemaState?.addDepListener(accessPath.concat(field.id), accessPath.concat(field.id), field.depChange, field.deferredDepChange);
}
(evalFunc(null, field.deps) || []).forEach((dep)=>{
let source = accessPath.concat(dep);
if(_.isArray(dep)) {
source = dep;
}
if(field.depChange) {
schemaState?.addDepListener(source, accessPath.concat(field.id), field.depChange);
}
});
});
return ()=>{
/* Cleanup the listeners when unmounting */
schemaState?.removeDepListener(accessPath);
};
}, []);
const [{ handlerId }, drop] = useDrop({
accept: 'row',
collect(monitor) {
return {
handlerId: monitor.getHandlerId(),
};
},
hover(item, monitor) {
if (!rowRef.current) {
return;
}
item.hoverIndex = null;
// Don't replace items with themselves
if (item.index === index) {
return;
}
// Determine rectangle on screen
const hoverBoundingRect = rowRef.current?.getBoundingClientRect();
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
// Only perform the move when the mouse has crossed certain part of the items height
// Dragging downwards
if (item.index < index && hoverClientY < (hoverBoundingRect.bottom - hoverBoundingRect.top)/3) {
return;
}
// Dragging upwards
if (item.index > index && hoverClientY > ((hoverBoundingRect.bottom - hoverBoundingRect.top)*2/3)) {
return;
}
setHoverIndex(index);
item.hoverIndex = index;
},
});
const [, drag] = useDrag({
type: 'row',
item: () => {
return {index};
},
end: (item)=>{
// Time to actually perform the action
setHoverIndex(null);
if(item.hoverIndex >= 0) {
moveRow(item.index, item.hoverIndex);
}
}
});
/* External deps values are from top schema sess data */
depsMap = depsMap.concat(externalDeps.map((source)=>_.get(schemaRef.current.top?.sessData, source)));
depsMap = depsMap.concat([totalRows, row.getIsExpanded(), key, isResizing, isHovered]);
drag(dragHandleRef);
drop(rowRef);
return useMemo(()=>
<PgReactTableRowContent ref={rowRef} data-handler-id={handlerId}
className={isHovered ? 'DataGridView-tableRowHovered' : null}
data-test='data-table-row' style={{position: 'initial'}}>
{row.getVisibleCells().map((cell) => {
// Let's not render the cell, which are not supported in this mode.
if (cell.column.field && !isModeSupportedByField(
cell.column.field, viewHelperProps
)) return;
const content = flexRender(cell.column.columnDef.cell, {
key: cell.column.columnDef.cell?.type ?? cell.column.columnDef.id,
...cell.getContext(),
reRenderRow: ()=>{setKey((currKey)=>!currKey);}
});
return (
<PgReactTableCell cell={cell} row={row} key={cell.id}
ref={cell.column.id == 'btn-reorder' ? dragHandleRef : null}>
{content}
</PgReactTableCell>
);
})}
<div className='hover-overlay'></div>
</PgReactTableRowContent>, depsMap);
}
export function DataGridHeader({label, canAdd, onAddClick, canSearch, onSearchTextChange}) {
const [searchText, setSearchText] = useState('');
return (
<Box className='DataGridView-gridHeader'>
{ label &&
<Box className='DataGridView-gridHeaderText'>{label}</Box>
}
{ canSearch &&
<Box className='DataGridView-gridHeaderText' width={'100%'}>
<InputText value={searchText}
onChange={(value)=>{
onSearchTextChange(value);
setSearchText(value);
}}
placeholder={gettext('Search')}>
</InputText>
</Box>
}
<Box className='DataGridView-gridControls'>
{canAdd && <PgIconButton data-test="add-row" title={gettext('Add row')} onClick={()=>{
setSearchText('');
onSearchTextChange('');
onAddClick();
}} icon={<AddIcon />} className='DataGridView-gridControlsButton' />}
</Box>
</Box>
);
}
DataGridHeader.propTypes = {
label: PropTypes.string,
canAdd: PropTypes.bool,
onAddClick: PropTypes.func,
canSearch: PropTypes.bool,
onSearchTextChange: PropTypes.func,
};
function getMappedCell({
field,
schemaRef,
viewHelperProps,
accessPath,
dataDispatch
}) {
const Cell = ({row, ...other}) => {
const value = other.getValue();
/* Make sure to take the latest field info from schema */
field = _.find(schemaRef.current.fields, (f)=>f.id==field.id) || field;
let {editable, disabled, modeSupported} = getFieldMetaData(field, schemaRef.current, row.original || {}, viewHelperProps);
if(_.isUndefined(field.cell)) {
console.error('cell is required ', field);
}
return modeSupported && <MappedCellControl rowIndex={row.index} value={value}
row={row} {...field}
readonly={!editable}
disabled={disabled}
visible={true}
onCellChange={(changeValue)=>{
if(field.radioType) {
dataDispatch({
type: SCHEMA_STATE_ACTIONS.BULK_UPDATE,
path: accessPath,
value: changeValue,
id: field.id
});
}
dataDispatch({
type: SCHEMA_STATE_ACTIONS.SET_VALUE,
path: accessPath.concat([row.index, field.id]),
value: changeValue,
});
}}
reRenderRow={other.reRenderRow}
/>;
};
Cell.displayName = 'Cell';
Cell.propTypes = {
row: PropTypes.object.isRequired,
value: PropTypes.any,
onCellChange: PropTypes.func,
};
return Cell;
}
export default function DataGridView({
value, viewHelperProps, schema, accessPath, dataDispatch, containerClassName,
fixedRows, ...props
}) {
const schemaState = useContext(SchemaStateContext);
const checkIsMounted = useIsMounted();
const [hoverIndex, setHoverIndex] = useState();
const newRowIndex = useRef();
const pgAdmin = usePgAdmin();
const [searchVal, setSearchVal] = useState('');
/* Using ref so that schema variable is not frozen in columns closure */
const schemaRef = useRef(schema);
const columns = useMemo(
()=>{
let cols = [];
if(props.canReorder) {
let colInfo = {
header: <>&nbsp;</>,
id: 'btn-reorder',
accessorFn: ()=>{/*This is intentional (SonarQube)*/},
enableResizing: false,
enableSorting: false,
dataType: 'reorder',
size: 36,
maxSize: 26,
minSize: 26,
cell: getReorderCell(),
};
cols.push(colInfo);
}
if(props.canEdit) {
let colInfo = {
header: <>&nbsp;</>,
id: 'btn-edit',
accessorFn: ()=>{/*This is intentional (SonarQube)*/},
enableResizing: false,
enableSorting: false,
dataType: 'edit',
size: 26,
maxSize: 26,
minSize: 26,
cell: getEditCell({
isDisabled: (row)=>{
let canEditRow = true;
if(props.canEditRow) {
canEditRow = evalFunc(schemaRef.current, props.canEditRow, row.original || {});
}
return !canEditRow;
},
title: gettext('Edit row'),
})
};
cols.push(colInfo);
}
if(props.canDelete) {
let colInfo = {
header: <>&nbsp;</>,
id: 'btn-delete',
accessorFn: ()=>{/*This is intentional (SonarQube)*/},
enableResizing: false,
enableSorting: false,
dataType: 'delete',
size: 26,
maxSize: 26,
minSize: 26,
cell: getDeleteCell({
title: gettext('Delete row'),
isDisabled: (row)=>{
let canDeleteRow = true;
if(props.canDeleteRow) {
canDeleteRow = evalFunc(schemaRef.current, props.canDeleteRow, row.original || {});
}
return !canDeleteRow;
},
onClick: (row)=>{
const deleteRow = ()=> {
dataDispatch({
type: SCHEMA_STATE_ACTIONS.DELETE_ROW,
path: accessPath,
value: row.index,
});
return true;
};
if (props.onDelete){
props.onDelete(row.original || {}, deleteRow);
} else {
pgAdmin.Browser.notifier.confirm(
props.customDeleteTitle || gettext('Delete Row'),
props.customDeleteMsg || gettext('Are you sure you wish to delete this row?'),
deleteRow,
function() {
return true;
}
);
}
}
}),
};
cols.push(colInfo);
}
cols = cols.concat(
schemaRef.current.fields.filter((f) => (
_.isArray(props.columns) ? props.columns.indexOf(f.id) > -1 : true
)).sort((firstF, secondF) => (
_.isArray(props.columns) ? ((
props.columns.indexOf(firstF.id) <
props.columns.indexOf(secondF.id)
) ? -1 : 1) : 0
)).map((field) => {
let widthParms = {};
if(field.width) {
widthParms.size = field.width;
widthParms.minSize = field.width;
} else {
widthParms.size = 75;
widthParms.minSize = 75;
}
if(field.minWidth) {
widthParms.minSize = field.minWidth;
}
if(field.maxWidth) {
widthParms.maxSize = field.maxWidth;
}
widthParms.enableResizing =
_.isUndefined(field.enableResizing) ? true : Boolean(
field.enableResizing
);
let colInfo = {
header: field.label||<>&nbsp;</>,
accessorKey: field.id,
field: field,
enableResizing: true,
enableSorting: false,
...widthParms,
cell: getMappedCell({
field: field,
schemaRef: schemaRef,
viewHelperProps: viewHelperProps,
accessPath: accessPath,
dataDispatch: dataDispatch,
}),
};
return colInfo;
})
);
return cols;
},[props.canEdit, props.canDelete, props.canReorder]
);
const columnVisibility = useMemo(()=>{
const ret = {};
columns.forEach(column => {
ret[column.id] = isModeSupportedByField(column.field, viewHelperProps);
});
return ret;
}, [columns, viewHelperProps]);
const table = useReactTable({
columns,
data: value,
autoResetAll: false,
state: {
globalFilter: searchVal,
columnVisibility: columnVisibility,
},
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getExpandedRowModel: getExpandedRowModel(),
});
const rows = table.getRowModel().rows;
const onAddClick = useCallback(()=>{
if(!props.canAddRow) {
return;
}
let newRow = schemaRef.current.getNewData();
const current_macros = schemaRef.current?._top?._sessData?.macro || null;
if (current_macros){
newRow = schemaRef.current.getNewData(current_macros);
}
newRowIndex.current = props.addOnTop ? 0 : rows.length;
dataDispatch({
type: SCHEMA_STATE_ACTIONS.ADD_ROW,
path: accessPath,
value: newRow,
addOnTop: props.addOnTop
});
}, [props.canAddRow, rows?.length]);
useEffect(() => {
let rowsPromise = fixedRows;
// If fixedRows is defined, fetch the details.
if(typeof rowsPromise === 'function') {
rowsPromise = rowsPromise();
}
if(rowsPromise) {
Promise.resolve(rowsPromise)
.then((res) => {
/* If component unmounted, dont update state */
if(checkIsMounted()) {
schemaState.setUnpreparedData(accessPath, res);
}
});
}
}, []);
useEffect(()=>{
if(newRowIndex.current >= 0) {
virtualizer.scrollToIndex(newRowIndex.current);
// Try autofocus on newly added row.
setTimeout(() => {
const rowInput = tableRef.current?.querySelector(
`.pgrt-row[data-index="${newRowIndex.current}"] input`
);
if(!rowInput) return;
requestAnimationAndFocus(tableRef.current.querySelector(
`.pgrt-row[data-index="${newRowIndex.current}"] input`
));
props.expandEditOnAdd && props.canEdit &&
rows[newRowIndex.current]?.toggleExpanded(true);
newRowIndex.current = undefined;
}, 50);
}
}, [rows?.length]);
const tableRef = useRef();
const moveRow = (dragIndex, hoverIndex) => {
dataDispatch({
type: SCHEMA_STATE_ACTIONS.MOVE_ROW,
path: accessPath,
oldIndex: dragIndex,
newIndex: hoverIndex,
});
};
const isResizing = _.flatMap(table.getHeaderGroups(), headerGroup => headerGroup.headers.map(header=>header.column.getIsResizing())).includes(true);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableRef.current,
estimateSize: () => 42,
measureElement:
typeof window !== 'undefined' &&
navigator.userAgent.indexOf('Firefox') === -1
? element => element?.getBoundingClientRect().height
: undefined,
overscan: viewHelperProps.virtualiseOverscan ?? 10,
});
if(!props.visible) {
return <></>;
}
return (
<StyleDataGridBox className={containerClassName}>
<Box className='DataGridView-grid'>
{(props.label || props.canAdd) && <DataGridHeader label={props.label} canAdd={props.canAdd} onAddClick={onAddClick}
canSearch={props.canSearch}
onSearchTextChange={(value)=>{
setSearchVal(value || undefined);
}}
/>}
<DndProvider backend={HTML5Backend}>
<PgReactTable ref={tableRef} table={table} data-test="data-grid-view" tableClassName='DataGridView-table'>
<PgReactTableHeader table={table} />
<PgReactTableBody style={{ height: virtualizer.getTotalSize() + 'px'}}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
return <PgReactTableRow key={row.id} data-index={virtualRow.index}
ref={node => virtualizer.measureElement(node)}
style={{
transform: `translateY(${virtualRow.start}px)`, // this should always be a `style` as it changes on scroll
}}>
<DataTableRow index={virtualRow.index} row={row} totalRows={rows.length} isResizing={isResizing}
schema={schemaRef.current} schemaRef={schemaRef} accessPath={accessPath.concat([row.index])}
moveRow={moveRow} isHovered={virtualRow.index == hoverIndex} setHoverIndex={setHoverIndex} viewHelperProps={viewHelperProps}
measureElement={virtualizer.measureElement}
/>
{props.canEdit &&
<PgReactTableRowExpandContent row={row}>
<FormView value={row.original} viewHelperProps={viewHelperProps} dataDispatch={dataDispatch}
schema={schemaRef.current} accessPath={accessPath.concat([row.index])} isNested={true} className='DataGridView-expandedForm'
isDataGridForm={true} firstEleRef={(ele)=>{
requestAnimationAndFocus(ele);
}}/>
</PgReactTableRowExpandContent>
}
</PgReactTableRow>;
})}
</PgReactTableBody>
</PgReactTable>
</DndProvider>
</Box>
</StyleDataGridBox>
);
}
DataGridView.propTypes = {
label: PropTypes.string,
value: PropTypes.array,
viewHelperProps: PropTypes.object,
schema: CustomPropTypes.schemaUI,
accessPath: PropTypes.array.isRequired,
dataDispatch: PropTypes.func,
containerClassName: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
fixedRows: PropTypes.oneOfType([PropTypes.array, PropTypes.instanceOf(Promise), PropTypes.func]),
columns: PropTypes.array,
canEdit: PropTypes.bool,
canAdd: PropTypes.bool,
canDelete: PropTypes.bool,
canReorder: PropTypes.bool,
visible: PropTypes.bool,
canAddRow: PropTypes.oneOfType([
PropTypes.bool, PropTypes.func,
]),
canEditRow: PropTypes.oneOfType([
PropTypes.bool, PropTypes.func,
]),
canDeleteRow: PropTypes.oneOfType([
PropTypes.bool, PropTypes.func,
]),
expandEditOnAdd: PropTypes.bool,
customDeleteTitle: PropTypes.string,
customDeleteMsg: PropTypes.string,
canSearch: PropTypes.bool,
onDelete: PropTypes.func,
addOnTop: PropTypes.bool
};

View File

@@ -0,0 +1,47 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useContext } from 'react';
import {
SEARCH_INPUT_ALIGNMENT, SEARCH_INPUT_SIZE, SearchInputText,
} from 'sources/components/SearchInputText';
import { SchemaStateContext } from '../SchemaState';
import { DataGridContext } from './context';
import { GRID_STATE } from './utils';
export const SEARCH_STATE_PATH = [GRID_STATE, '__searchText'];
export function SearchBox() {
const schemaState = useContext(SchemaStateContext);
const {
accessPath, field, options: { canSearch }
} = useContext(DataGridContext);
if (!canSearch) return <></>;
const searchText = schemaState.state(accessPath.concat(SEARCH_STATE_PATH));
const searchTextChange = (value) => {
schemaState.setState(accessPath.concat(SEARCH_STATE_PATH), value);
};
const searchOptions = field.searchOptions || {
size: SEARCH_INPUT_SIZE.HALF,
alignment: SEARCH_INPUT_ALIGNMENT.RIGHT,
};
return (
<SearchInputText
{...searchOptions}
searchText={searchText || ''} onChange={searchTextChange}
/>
);
}

View File

@@ -0,0 +1,14 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { createContext } from 'react';
export const DataGridContext = createContext();
export const DataGridRowContext = createContext();

View File

@@ -0,0 +1,21 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
export const ACTION_COLUMN = {
header: <>&nbsp;</>,
accessorFn: ()=>{/*This is intentional (SonarQube)*/},
enableResizing: false,
enableSorting: false,
dataType: 'reorder',
size: 36,
maxSize: 26,
minSize: 26,
};

View File

@@ -0,0 +1,95 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import { getDeleteCell } from 'sources/components/PgReactTableStyled';
import { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView/SchemaState';
import gettext from 'sources/gettext';
import {
canAddOrDelete, evalIfNotDisabled, registerOptionEvaluator
} from '../../options';
import { SchemaStateContext } from '../../SchemaState';
import { useFieldOptions } from '../../hooks';
import { DataGridRowContext } from '../context';
import { ACTION_COLUMN } from './common';
import Feature from './feature';
// Register the 'canDelete' options for the collection
registerOptionEvaluator('canDelete', canAddOrDelete, false, ['collection']);
// Register the 'canDeleteRow' option for the table row
registerOptionEvaluator('canDeleteRow', evalIfNotDisabled, true, ['row']);
export default class DeletableRow extends Feature {
// Always add 'edit' column at the start of the columns list
// (but - not before the reorder column).
static priority = 50;
constructor() {
super();
this.canDelete = false;
}
generateColumns({pgAdmin, columns, columnVisibility, options}) {
this.canDelete = options.canDelete;
if (!this.canDelete) return;
const instance = this;
const field = instance.field;
const accessPath = instance.accessPath;
const dataDispatch = instance.dataDispatch;
columnVisibility['btn-delete'] = true;
columns.splice(0, 0, {
...ACTION_COLUMN,
id: 'btn-delete',
dataType: 'delete',
cell: getDeleteCell({
isDisabled: () => {
const schemaState = React.useContext(SchemaStateContext);
const { rowAccessPath } = React.useContext(DataGridRowContext);
const options = useFieldOptions(rowAccessPath, schemaState);
return !options.canDeleteRow;
},
title: gettext('Delete row'),
onClick: (row) => {
const deleteRow = () => {
dataDispatch({
type: SCHEMA_STATE_ACTIONS.DELETE_ROW,
path: accessPath,
value: row.index,
});
return true;
};
if (field.onDelete){
field.onDelete(row?.original || {}, deleteRow);
} else {
pgAdmin.Browser.notifier.confirm(
field.customDeleteTitle || gettext('Delete Row'),
field.customDeleteMsg || gettext(
'Are you sure you wish to delete this row?'
),
deleteRow,
function() { return true; }
);
}
},
}),
});
}
}

View File

@@ -0,0 +1,88 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import { getExpandedRowModel } from '@tanstack/react-table';
import { getEditCell } from 'sources/components/PgReactTableStyled';
import gettext from 'sources/gettext';
import FormView from 'sources/SchemaView/FormView';
import { SchemaStateContext } from '../../SchemaState';
import { useFieldOptions } from '../../hooks';
import { DataGridRowContext } from '../context';
import { ACTION_COLUMN } from './common';
import Feature from './feature';
export default class ExpandedFormView extends Feature {
// Always add 'edit' column at the start of the columns list
// (but - not before the reorder column).
static priority = 70;
constructor() {
super();
this.canEdit = false;
}
generateColumns({columns, columnVisibility, options}) {
this.canEdit = options.canEdit;
if (!this.canEdit) return;
columnVisibility['btn-edit'] = true;
columns.splice(0, 0, {
...ACTION_COLUMN,
id: 'btn-edit',
dataType: 'edit',
cell: getEditCell({
isDisabled: () => {
const schemaState = React.useContext(SchemaStateContext);
const { rowAccessPath } = React.useContext(DataGridRowContext);
const options = useFieldOptions(rowAccessPath, schemaState);
return !options.canEditRow;
},
title: gettext('Edit row'),
}),
});
}
onTable({table}) {
table.setOptions(prev => ({
...prev,
getExpandedRowModel: getExpandedRowModel(),
state: {
...prev.state,
}
}));
}
onRow({row, expandedRowContents, rowOptions}) {
const instance = this;
if (rowOptions.canEditRow && row?.getIsExpanded()) {
expandedRowContents.splice(
0, 0, <FormView
key={`expanded-form-row-${row.id}`}
value={row?.original}
viewHelperProps={instance.viewHelperProps}
dataDispatch={instance.dataDispatch}
schema={instance.schema}
accessPath={instance.accessPath.concat([row.index])}
isNested={true}
className='DataGridView-expandedForm'
isDataGridForm={true}
focusOnFirstInput={true}
/>
);
}
}
}

View File

@@ -0,0 +1,134 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
// Feature class
// Let's not expose the features directory.
let _featureClasses = [];
export default class Feature {
static priority = 1;
constructor() {
this.accessPath = this.field = this.schema = this.table = this.cols =
this.viewHelperProps = null;
}
setContext({
accessPath, field, schema, viewHelperProps, dataDispatch, schemaState
}) {
this.accessPath = accessPath;
this.field = field;
this.schema = schema;
this.dataDispatch = dataDispatch;
this.viewHelperProps = viewHelperProps;
this.schemaState = schemaState;
}
generateColumns(/* { pgAdmin, columns, columnVisibility, options } */) {}
onTable(/* { table, options, classList } */) {}
onRow(/* {
index, row, rowRef, classList, attributes,
expandedRowContents, rowOptions, tableOptions
} */) {}
}
function isValidFeatureClass(cls) {
// Check if provided class is direct decendent of the Feature class
try {
if (Reflect.getPrototypeOf(cls) != Feature) {
console.error(cls, 'Not a valid Feature class:');
console.trace();
return false;
}
} catch(err) {
console.trace();
console.error('Error while checking type:\n', err);
return false;
}
return true;
}
function addToSortedList(_list, _item, _comparator = (a, b) => (a < b)) {
// Insert the given feature class in sorted list based on the priority.
let idx = 0;
for (; idx < _list.length; idx++) {
if (_comparator(_item, _list[idx])) {
_list.splice(idx, 0, _item);
return;
}
}
_list.splice(idx, 0, _item);
}
const featurePriorityCompare = (f1, f2) => (f1.priorty < f2.priority);
export function register(cls) {
if (!isValidFeatureClass(cls)) return;
addToSortedList(_featureClasses, cls, featurePriorityCompare);
}
export class FeatureSet {
constructor() {
this.id = Date.now();
this.features = _featureClasses.map((cls) => new cls());
}
addFeatures(features) {
features.forEach((feature) => {
if (!(feature instanceof Feature)) {
console.error(feature, 'is not a valid feature!\n');
console.trace();
return;
}
addToSortedList(
this.features, feature, featurePriorityCompare
);
});
}
setContext({
accessPath, field, schema, viewHelperProps, dataDispatch, schemaState
}) {
this.features.forEach((feature) => {
feature.setContext({
accessPath, field, schema, viewHelperProps, dataDispatch, schemaState
});
});
}
generateColumns({pgAdmin, columns, columnVisibility, options}) {
this.features.forEach((feature) => {
feature.generateColumns({pgAdmin, columns, columnVisibility, options});
});
}
onTable({table, options, classList}) {
this.features.forEach((feature) => {
feature.onTable({table, options, classList});
});
}
onRow({
index, row, rowRef, classList, attributes, expandedRowContents,
rowOptions, tableOptions
}) {
this.features.forEach((feature) => {
feature.onRow({
index, row, rowRef, classList, attributes, expandedRowContents,
rowOptions, tableOptions
});
});
}
}

View File

@@ -0,0 +1,46 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { useContext, useEffect } from 'react';
import { useIsMounted } from 'sources/custom_hooks';
import { SchemaStateContext } from 'sources/SchemaView/SchemaState';
import Feature from './feature';
export default class FixedRows extends Feature {
onTable() {
const instance = this;
const schemaState = useContext(SchemaStateContext);
const checkIsMounted = useIsMounted();
useEffect(() => {
let rowsPromise = instance.field.fixedRows;
// Fixed rows is supported only in 'create' mode.
if (instance.viewHelperProps.mode !== 'create') return;
// If fixedRows is defined, fetch the details.
if(typeof rowsPromise === 'function') {
rowsPromise = rowsPromise();
}
if(rowsPromise) {
Promise.resolve(rowsPromise)
.then((res) => {
// If component is unmounted, don't update state.
if(checkIsMounted()) {
schemaState.setUnpreparedData(instance.accessPath, res);
}
});
}
}, []);
}
}

View File

@@ -0,0 +1,29 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
// The DataGridView component is feature support better extendability.
import { Feature, FeatureSet, register } from './feature';
import FixedRows from './fixedRows';
import Reorder from './reorder';
import ExpandedFormView from './expandabledFormView';
import DeletableRow from './deletable';
import GlobalSearch from './search';
register(FixedRows);
register(DeletableRow);
register(ExpandedFormView);
register(GlobalSearch);
register(Reorder);
export {
Feature,
FeatureSet,
register
};

View File

@@ -0,0 +1,160 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
import { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView/SchemaState';
import { booleanEvaluator, registerOptionEvaluator } from '../../options';
import { ACTION_COLUMN } from './common';
import Feature from './feature';
// Register the 'canReorder' options for the collection
registerOptionEvaluator('canReorder', booleanEvaluator, false, ['collection']);
export default class Reorder extends Feature {
// Always add reorder column at the start of the columns list.
static priority = 100;
constructor() {
super();
this.canReorder = false;
this.hoverIndex = null;
this.moveRow = null;
}
setHoverIndex(index) {
this.hoverIndex = index;
}
generateColumns({columns, columnVisibility, options}) {
this.canReorder = options.canReorder;
if (!this.canReorder) return;
columnVisibility['reorder-cell'] = true;
const Cell = function({row}) {
const dragHandleRef = row?.reorderDragHandleRef;
const handlerId = row?.dragHandlerId;
if (!dragHandleRef) return <></>;
return (
<div
className='reorder-cell'
data-handler-id={handlerId}
ref={dragHandleRef ? dragHandleRef : null}>
<DragIndicatorRoundedIcon fontSize="small" />
</div>
);
};
Cell.displayName = 'ReorderCell';
columns.splice(0, 0, {
...ACTION_COLUMN,
id: 'btn-reorder',
dataType: 'reorder',
cell: Cell,
});
}
onTable() {
if (this.canReorder) {
this.moveRow = (dragIndex, hoverIndex) => {
this.dataDispatch?.({
type: SCHEMA_STATE_ACTIONS.MOVE_ROW,
path: this.accessPath,
oldIndex: dragIndex,
newIndex: hoverIndex,
});
};
}
}
onRow({index, row, rowRef, classList}) {
const instance = this;
const reorderDragHandleRef = useRef(null);
const [{ handlerId }, drop] = useDrop({
accept: 'row',
collect(monitor) {
return {
handlerId: monitor.getHandlerId(),
};
},
hover(item, monitor) {
if (!rowRef.current) return;
item.hoverIndex = null;
// Don't replace items with themselves
if (item.index === index) return;
// Determine rectangle on screen
const hoverBoundry = rowRef.current?.getBoundingClientRect();
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset.y - hoverBoundry.top;
// Only perform the move when the mouse has crossed certain part of the
// items height dragging downwards.
if (
item.index < index &&
hoverClientY < (hoverBoundry.bottom - hoverBoundry.top)/3
) return;
// Dragging upwards
if (
item.index > index &&
hoverClientY > ((hoverBoundry.bottom - hoverBoundry.top) * 2 / 3)
) return;
instance.setHoverIndex(index);
item.hoverIndex = index;
},
});
const [, drag, preview] = useDrag({
type: 'row',
item: () => {
return {index};
},
end: (item) => {
// Time to actually perform the action
instance.setHoverIndex(null);
if(item.hoverIndex >= 0) {
instance.moveRow(item.index, item.hoverIndex);
}
}
});
if (!this.canReorder || !row) return;
if (row)
row.reorderDragHandleRef = reorderDragHandleRef;
drag(row.reorderDragHandleRef);
drop(rowRef);
preview(rowRef);
if (index == this.hoverIndex) {
classList?.append('DataGridView-tableRowHovered');
}
row.dragHandlerId = handlerId;
}
}

View File

@@ -0,0 +1,54 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import {
booleanEvaluator, registerOptionEvaluator
} from '../../options';
import Feature from './feature';
import { SEARCH_STATE_PATH } from '../SearchBox';
registerOptionEvaluator('canSearch', booleanEvaluator, false, ['collection']);
export default class GlobalSearch extends Feature {
constructor() {
super();
}
onTable({table, options}) {
if (!options.canSearch) {
const searchText = '';
table.setOptions((prev) => ({
...prev,
state: {
...prev.state,
globalFilter: searchText,
}
}));
return;
}
const searchText = this.schemaState.state(
this.accessPath.concat(SEARCH_STATE_PATH)
);
table.setOptions((prev) => ({
...prev,
state: {
...prev.state,
globalFilter: searchText,
}
}));
}
}

View File

@@ -0,0 +1,166 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, {
useCallback, useContext, useEffect, useRef, useState
} from 'react';
import { styled } from '@mui/material/styles';
import Box from '@mui/material/Box';
import { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView/SchemaState';
import { DefaultButton } from 'sources/components/Buttons';
import CustomPropTypes from 'sources/custom_prop_types';
import gettext from 'sources/gettext';
import { requestAnimationAndFocus } from 'sources/utils';
import { SchemaStateContext } from '../SchemaState';
import { booleanEvaluator, registerOptionEvaluator } from '../options';
import { View } from '../registry';
import { SearchBox, SEARCH_STATE_PATH } from './SearchBox';
import { DataGridContext } from './context';
// Register the 'headerFormVisible' options for the collection
registerOptionEvaluator(
'headerFormVisible', booleanEvaluator, false, ['collection']
);
const StyledBox = styled(Box)(({theme}) => ({
'& .DataGridFormHeader-border': {
...theme.mixins.panelBorder,
borderBottom: 0,
'& .DataGridFormHeader-body': {
padding: '0',
backgroundColor: theme.palette.grey[400],
'& .FormView-singleCollectionPanel': {
paddingBottom: 0,
},
'& .DataGridFormHeader-btn-group' :{
display: 'flex',
padding: theme.spacing(1),
paddingTop: 0,
'& .DataGridFormHeader-addBtn': {
marginLeft: 'auto',
},
},
'& [data-test="tabpanel"]': {
overflow: 'unset',
},
},
},
}));
export function DataGridFormHeader({tableEleRef}) {
const {
accessPath, field, dataDispatch, options, virtualizer, table,
viewHelperProps,
} = useContext(DataGridContext);
const {
addOnTop, canAddRow, canEdit, expandEditOnAdd, headerFormVisible
} = options;
const rows = table.getRowModel().rows;
const label = field.label || '';
const newRowIndex = useRef(-1);
const schemaState = useContext(SchemaStateContext);
const headerFormData = useRef({});
const [addDisabled, setAddDisabled] = useState(canAddRow);
const {headerSchema} = field;
const onAddClick = useCallback(() => {
if(!canAddRow) {
return;
}
let newRow = headerSchema.getNewData(headerFormData.current);
newRowIndex.current = addOnTop ? 0 : rows.length;
dataDispatch({
type: SCHEMA_STATE_ACTIONS.ADD_ROW,
path: accessPath,
value: newRow,
addOnTop: addOnTop
});
schemaState.setState(accessPath.concat(SEARCH_STATE_PATH), '');
headerSchema.state?.validate(headerSchema._defaults || {});
}, [canAddRow, rows?.length, addOnTop]);
useEffect(() => {
if (newRowIndex.current < -1) return;
virtualizer.scrollToIndex(newRowIndex.current);
// Try autofocus on newly added row.
setTimeout(() => {
const rowInput = tableEleRef.current?.querySelector(
`.pgrt-row[data-index="${newRowIndex.current}"] input`
);
if(!rowInput) return;
requestAnimationAndFocus(tableEleRef.current.querySelector(
`.pgrt-row[data-index="${newRowIndex.current}"] input`
));
expandEditOnAdd && canEdit &&
rows[newRowIndex.current]?.toggleExpanded(true);
newRowIndex.current = undefined;
}, 50);
}, [rows?.length]);
const SchemaView = View('SchemaView');
return (
<StyledBox>
<Box className='DataGridFormHeader-border'>
<Box className='DataGridView-gridHeader'>
{label && <Box className='DataGridView-gridHeaderText'>{label}</Box>}
<Box className='DataGridView-gridHeaderText' style={{flex: 1}}>
<SearchBox/>
</Box>
</Box>
{headerFormVisible &&
<Box className='DataGridFormHeader-body'>
<SchemaView
formType={'dialog'}
getInitData={()=>Promise.resolve({})}
schema={headerSchema}
viewHelperProps={viewHelperProps}
showFooter={false}
onDataChange={(isDataChanged, dataChanged)=>{
headerFormData.current = dataChanged;
setAddDisabled(
canAddRow && headerSchema.addDisabled(headerFormData.current)
);
}}
hasSQL={false}
isTabView={false}
/>
<Box className='DataGridFormHeader-btn-group'>
<DefaultButton
className='DataGridFormHeader-addBtn'
onClick={onAddClick} disabled={addDisabled}>
{gettext('Add')}
</DefaultButton>
</Box>
</Box>}
</Box>
</StyledBox>
);
}
DataGridFormHeader.propTypes = {
tableEleRef: CustomPropTypes.ref,
};

View File

@@ -0,0 +1,198 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, {
useContext, useEffect, useMemo, useRef, useState,
} from 'react';
import Box from '@mui/material/Box';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import _ from 'lodash';
import PropTypes from 'prop-types';
import { DndProvider } from 'react-dnd';
import {HTML5Backend} from 'react-dnd-html5-backend';
import { usePgAdmin } from 'sources/BrowserComponent';
import {
PgReactTable, PgReactTableBody, PgReactTableHeader,
PgReactTableRow,
} from 'sources/components/PgReactTableStyled';
import CustomPropTypes from 'sources/custom_prop_types';
import { StyleDataGridBox } from '../StyledComponents';
import { SchemaStateContext } from '../SchemaState';
import { useFieldOptions, useFieldValue } from '../hooks';
import { registerView } from '../registry';
import { listenDepChanges } from '../utils';
import { DataGridContext } from './context';
import { DataGridHeader } from './header';
import { DataGridRow } from './row';
import { FeatureSet } from './features';
import { createGridColumns, GRID_STATE } from './utils';
export default function DataGridView({
field, viewHelperProps, accessPath, dataDispatch, containerClassName
}) {
const pgAdmin = usePgAdmin();
const [refreshKey, setRefreshKey] = useState(0);
const schemaState = useContext(SchemaStateContext);
const options = useFieldOptions(
accessPath, schemaState, refreshKey, setRefreshKey
);
const value = useFieldValue(accessPath, schemaState);
const schema = field.schema;
const features = useRef();
// Update refresh key on changing the number of rows.
useFieldValue(
[...accessPath, 'length'], schemaState, refreshKey,
(newKey) => {
setRefreshKey(newKey);
}
);
useEffect(() => {
return schemaState.subscribe(
accessPath.concat(GRID_STATE),
() => setRefreshKey(Date.now()), 'states'
);
}, [refreshKey]);
listenDepChanges(accessPath, field, options.visible, schemaState);
if (!features.current) {
features.current = new FeatureSet();
};
features.current.setContext({
accessPath, field, schema: schema, dataDispatch, viewHelperProps,
schemaState,
});
const [columns, columnVisibility] = useMemo(() => {
const [columns, columnVisibility] = createGridColumns({
schema, field, accessPath, viewHelperProps, dataDispatch,
});
features.current?.generateColumns({
pgAdmin, columns, columnVisibility, options
});
return [columns, columnVisibility];
}, [options]);
const table = useReactTable({
columns: columns|| [],
data: value || [],
autoResetAll: false,
state: {
columnVisibility: columnVisibility || {},
},
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
const classList = [].concat(containerClassName);
features.current?.onTable({table, classList, options});
const rows = table.getRowModel().rows;
const tableEleRef = useRef();
const isResizing = _.flatMap(
table.getHeaderGroups(),
headerGroup => headerGroup.headers.map(
header => header.column.getIsResizing()
)
).includes(true);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableEleRef.current,
estimateSize: () => 50,
measureElement:
typeof window !== 'undefined' &&
navigator.userAgent.indexOf('Firefox') === -1
? element => element?.getBoundingClientRect().height
: undefined,
overscan: viewHelperProps.virtualiseOverscan ?? 10,
});
const GridHeader = field.GridHeader || DataGridHeader;
const GridRow = field.GridRow || DataGridRow;
if (!options.visible) return (<></>);
return (
<DataGridContext.Provider value={{
table, accessPath, virtualizer, field, dataDispatch, features, options,
viewHelperProps,
}}>
<StyleDataGridBox className={classList.join(' ')}>
<Box className='DataGridView-grid'>
<GridHeader tableEleRef={tableEleRef} />
<DndProvider backend={HTML5Backend}>
<PgReactTable
ref={tableEleRef} table={table} data-test="data-grid-view"
tableClassName='DataGridView-table'>
<PgReactTableHeader table={table} />
<PgReactTableBody style={{
height: virtualizer.getTotalSize() + 'px'
}}>
{
virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<PgReactTableRow
key={row.id}
data-index={virtualRow.index}
ref={node => virtualizer.measureElement(node)}
style={{
// This should always be a `style` as it changes on
// scroll.
transform: `translateY(${virtualRow.start}px)`,
}}
>
<GridRow rowId={row.id} isResizing={isResizing}/>
</PgReactTableRow>
);
})
}
</PgReactTableBody>
</PgReactTable>
</DndProvider>
</Box>
</StyleDataGridBox>
</DataGridContext.Provider>
);
}
DataGridView.propTypes = {
viewHelperProps: PropTypes.object,
schema: CustomPropTypes.schemaUI,
accessPath: PropTypes.array.isRequired,
dataDispatch: PropTypes.func,
containerClassName: PropTypes.oneOfType([
PropTypes.object, PropTypes.string
]),
field: PropTypes.object,
};
registerView(DataGridView, 'DataGridView');

View File

@@ -0,0 +1,103 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useCallback, useContext, useEffect, useRef } from 'react';
import Box from '@mui/material/Box';
import AddIcon from '@mui/icons-material/AddOutlined';
import { SCHEMA_STATE_ACTIONS } from 'sources/SchemaView/SchemaState';
import { PgIconButton } from 'sources/components/Buttons';
import CustomPropTypes from 'sources/custom_prop_types';
import gettext from 'sources/gettext';
import { requestAnimationAndFocus } from 'sources/utils';
import { SchemaStateContext } from '../SchemaState';
import { SearchBox, SEARCH_STATE_PATH } from './SearchBox';
import { DataGridContext } from './context';
export function DataGridHeader({tableEleRef}) {
const {
accessPath, field, dataDispatch, options, virtualizer, table,
} = useContext(DataGridContext);
const {
addOnTop, canAdd, canAddRow, canEdit, expandEditOnAdd
} = options;
const rows = table.getRowModel().rows;
const label = field.label || '';
const newRowIndex = useRef(-1);
const schemaState = useContext(SchemaStateContext);
const onAddClick = useCallback(() => {
if(!canAddRow) {
return;
}
const newRow = field.schema.getNewData();
newRowIndex.current = addOnTop ? 0 : rows.length;
dataDispatch({
type: SCHEMA_STATE_ACTIONS.ADD_ROW,
path: accessPath,
value: newRow,
addOnTop: addOnTop
});
schemaState.setState(accessPath.concat(SEARCH_STATE_PATH), '');
}, [canAddRow, rows?.length]);
useEffect(() => {
if (newRowIndex.current < -1) return;
virtualizer.scrollToIndex(newRowIndex.current);
// Try autofocus on newly added row.
setTimeout(() => {
const rowInput = tableEleRef.current?.querySelector(
`.pgrt-row[data-index="${newRowIndex.current}"] input`
);
if(!rowInput) return;
requestAnimationAndFocus(tableEleRef.current.querySelector(
`.pgrt-row[data-index="${newRowIndex.current}"] input`
));
expandEditOnAdd && canEdit &&
rows[newRowIndex.current]?.toggleExpanded(true);
newRowIndex.current = undefined;
}, 50);
}, [rows?.length]);
return (
<Box className='DataGridView-gridHeader'>
{label && <Box className='DataGridView-gridHeaderText'>{label}</Box>}
<Box className='DataGridView-gridHeader-middle'
style={{flex: 1, padding: 0}}>
<SearchBox />
</Box>
<Box className='DataGridView-gridControls'>
{ canAdd &&
<PgIconButton data-test="add-row" title={gettext('Add row')}
onClick={onAddClick}
icon={<AddIcon />} className='DataGridView-gridControlsButton'
/>
}
</Box>
</Box>
);
}
DataGridHeader.propTypes = {
tableEleRef: CustomPropTypes.ref,
};

View File

@@ -0,0 +1,24 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
/* The DataGridView component is based on react-table component */
import { DataGridFormHeader } from './formHeader.jsx';
import { DataGridHeader } from './header';
import { getMappedCell } from './mappedCell';
import DataGridView from './grid';
export default DataGridView;
export {
DataGridFormHeader,
DataGridHeader,
getMappedCell,
};

View File

@@ -0,0 +1,105 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useContext, useMemo, useState } from 'react';
import _ from 'lodash';
import PropTypes from 'prop-types';
import { evalFunc } from 'sources/utils';
import { MappedCellControl } from '../MappedControl';
import { SCHEMA_STATE_ACTIONS, SchemaStateContext } from '../SchemaState';
import { flatternObject } from '../common';
import { useFieldOptions, useFieldValue } from '../hooks';
import { listenDepChanges } from '../utils';
import { DataGridContext, DataGridRowContext } from './context';
export function getMappedCell({field}) {
const Cell = ({reRenderRow, getValue}) => {
const [key, setKey] = useState(0);
const schemaState = useContext(SchemaStateContext);
const { dataDispatch } = useContext(DataGridContext);
const { rowAccessPath, row } = useContext(DataGridRowContext);
const colAccessPath = schemaState.accessPath(rowAccessPath, field.id);
let colOptions = useFieldOptions(colAccessPath, schemaState, key, setKey);
let value = useFieldValue(colAccessPath, schemaState, key, setKey);
let rowValue = useFieldValue(rowAccessPath, schemaState);
listenDepChanges(colAccessPath, field, true, schemaState);
if (!field.id) {
console.error(`No id set for the field: ${field}`);
value = getValue();
rowValue = row.original;
colOptions = { disabled: true, readonly: true };
} else {
colOptions['readonly'] = !colOptions['editable'];
rowValue = value;
}
let cellProps = {};
if (_.isFunction(field.cell) && field.id) {
cellProps = evalFunc(null, field.cell, rowValue);
if (typeof (cellProps) !== 'object')
cellProps = {cell: cellProps};
}
const props = {
...field,
...cellProps,
...colOptions,
visible: true,
rowIndex: row.index,
value,
row,
dataDispatch,
onCellChange: (changeValue) => {
if (colOptions.disabled) return;
if(field.radioType) {
dataDispatch({
type: SCHEMA_STATE_ACTIONS.BULK_UPDATE,
path: rowAccessPath,
value: changeValue,
id: field.id
});
}
dataDispatch({
type: SCHEMA_STATE_ACTIONS.SET_VALUE,
path: colAccessPath,
value: changeValue,
});
},
reRenderRow: reRenderRow
};
if(_.isUndefined(field.cell)) {
console.error('cell is required ', field);
props.cell = 'unknown';
}
return useMemo(
() => <MappedCellControl {...props}/>,
[...flatternObject(colOptions), value, row.index]
);
};
Cell.displayName = 'Cell';
Cell.propTypes = {
reRenderRow: PropTypes.func,
getValue: PropTypes.func,
};
return Cell;
}

View File

@@ -0,0 +1,93 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useContext, useMemo, useRef } from 'react';
import { flexRender } from '@tanstack/react-table';
import {
PgReactTableCell, PgReactTableRowContent, PgReactTableRowExpandContent,
} from 'sources/components/PgReactTableStyled';
import { SchemaStateContext } from '../SchemaState';
import { useFieldOptions } from '../hooks';
import { DataGridContext, DataGridRowContext } from './context';
export function DataGridRow({rowId, isResizing}) {
const schemaState = useContext(SchemaStateContext);
const { accessPath, options, table, features } = useContext(
DataGridContext
);
const rowAccessPath = schemaState.accessPath(accessPath, rowId);
const rowOptions = useFieldOptions(rowAccessPath, schemaState);
const rowRef = useRef(null);
const row = table.getRowModel().rows[rowId];
/*
* 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 has not changed.
*/
let classList = [];
let attributes = {};
let expandedRowContents = [];
features.current?.onRow({
index: rowId, row, rowRef, classList, attributes, expandedRowContents,
rowOptions, tableOptions: options
});
let depsMap = [
rowId, row?.getIsExpanded(), isResizing, expandedRowContents.length
];
return useMemo(() => (
!row ? <></> :
<DataGridRowContext.Provider value={{ rowAccessPath, row }}>
<PgReactTableRowContent ref={rowRef}
className={classList.join[' ']}
data-test='data-table-row' style={{position: 'initial'}}
{...attributes}
>
{
row?.getVisibleCells().map((cell) => {
const columnDef = cell.column.columnDef;
const content = flexRender(
columnDef.cell, {
key: columnDef.cell?.type ?? columnDef.id,
row: row,
getValue: cell.getValue,
}
);
return (
<PgReactTableCell cell={cell} row={row} key={cell.id}>
{content}
</PgReactTableCell>
);
})
}
<div className='hover-overlay'></div>
</PgReactTableRowContent>
{
expandedRowContents.length ?
<PgReactTableRowExpandContent
row={row} key={`expanded-row-${row?.id}`}>
{expandedRowContents}
</PgReactTableRowExpandContent> : <></>
}
</DataGridRowContext.Provider>
), [...depsMap]);
}

View File

@@ -0,0 +1,68 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import { isModeSupportedByField } from 'sources/SchemaView/common';
import { getMappedCell } from '../mappedCell';
export function createGridColumns({schema, field, viewHelperProps}) {
const columns = field.columns;
const colunnFilterExp = _.isArray(columns) ?
((f) => (columns.indexOf(f.id) > -1)) : (() => true);
const sortExp = _.isArray(columns) ?
((firstF, secondF) => (
(columns.indexOf(firstF.id) < columns.indexOf(secondF.id)) ? -1 : 1
)) : (() => 0);
const columnVisibility = {};
const cols = schema.fields.filter(colunnFilterExp).sort(sortExp).map(
(field) => {
let widthParms = {};
if(field.width) {
widthParms.size = field.width;
widthParms.minSize = field.width;
} else {
widthParms.size = 75;
widthParms.minSize = 75;
}
if(field.minWidth) {
widthParms.minSize = field.minWidth;
}
if(field.maxWidth) {
widthParms.maxSize = field.maxWidth;
}
widthParms.enableResizing =
_.isUndefined(field.enableResizing) ? true : Boolean(
field.enableResizing
);
columnVisibility[field.id] = isModeSupportedByField(
field, viewHelperProps
);
return {
header: field.label||<>&nbsp;</>,
accessorKey: field.id,
field: field,
enableResizing: true,
enableSorting: false,
...widthParms,
cell: getMappedCell({field}),
};
}
);
return [cols, columnVisibility];
}

View File

@@ -0,0 +1,16 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { createGridColumns } from './createGridColumns';
export const GRID_STATE = '__gridState';
export {
createGridColumns,
};

View File

@@ -37,7 +37,10 @@ export class DepListener {
if(dataPath.length > 0) {
data = _.get(state, dataPath);
}
_.assign(data, listener.callback?.(data, listener.source, state, actionObj) || {});
_.assign(
data,
listener.callback?.(data, listener.source, state, actionObj) || {}
);
return state;
}
@@ -70,7 +73,10 @@ export class DepListener {
getDeferredDepChange(currPath, state, actionObj) {
let deferredList = [];
let allListeners = _.filter(this._depListeners, (entry)=>_.join(currPath, '|').startsWith(_.join(entry.source, '|')));
let allListeners = _.filter(this._depListeners, (entry) => _.join(
currPath, '|'
).startsWith(_.join(entry.source, '|')));
if(allListeners) {
for(const listener of allListeners) {
if(listener.defCallback) {

View File

@@ -0,0 +1,27 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useMemo } from 'react';
export const FieldControl = ({schemaId, item}) => {
const Control = item.control;
const props = item.controlProps;
const children = item.controls;
return useMemo(() =>
<Control {...props}>
{
children &&
children.map(
(child, idx) => <FieldControl key={idx} item={child}/>
)
}
</Control>, [schemaId, Control, props, children]
);
};

View File

@@ -7,156 +7,67 @@
//
//////////////////////////////////////////////////////////////
import React, { useContext, useEffect } from 'react';
import Grid from '@mui/material/Grid';
import _ from 'lodash';
import React, { useContext, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import FieldSet from 'sources/components/FieldSet';
import CustomPropTypes from 'sources/custom_prop_types';
import { evalFunc } from 'sources/utils';
import { MappedFormControl } from './MappedControl';
import {
getFieldMetaData, SCHEMA_STATE_ACTIONS, SchemaStateContext
} from './common';
import { FieldControl } from './FieldControl';
import { SchemaStateContext } from './SchemaState';
import { useFieldSchema, useFieldValue } from './hooks';
import { registerView } from './registry';
import { createFieldControls, listenDepChanges } from './utils';
const INLINE_COMPONENT_ROWGAP = '8px';
export default function FieldSetView({
value, schema={}, viewHelperProps, accessPath, dataDispatch,
controlClassName, isDataGridForm=false, label, visible
field, accessPath, dataDispatch, viewHelperProps, controlClassName,
}) {
const [key, setRefreshKey] = useState(0);
const schema = field.schema;
const schemaState = useContext(SchemaStateContext);
const value = useFieldValue(accessPath, schemaState);
const options = useFieldSchema(
field, accessPath, value, viewHelperProps, schemaState, key, setRefreshKey
);
const label = field.label;
useEffect(() => {
// Calculate the fields which depends on the current field.
if(!isDataGridForm && schemaState) {
schema.fields.forEach((field) => {
/* Self change is also dep change */
if(field.depChange || field.deferredDepChange) {
schemaState?.addDepListener(
accessPath.concat(field.id), accessPath.concat(field.id),
field.depChange, field.deferredDepChange
);
}
(evalFunc(null, field.deps) || []).forEach((dep) => {
let source = accessPath.concat(dep);
if(_.isArray(dep)) {
source = dep;
}
if(field.depChange) {
schemaState?.addDepListener(
source, accessPath.concat(field.id), field.depChange
);
}
});
});
}
}, []);
listenDepChanges(accessPath, field, options.visible, schemaState);
let viewFields = [];
let inlineComponents = [];
const fieldGroups = useMemo(
() => createFieldControls({
schema, schemaState, accessPath, viewHelperProps, dataDispatch
}),
[schema, schemaState, accessPath, viewHelperProps, dataDispatch]
);
if(!visible) {
// We won't show empty feldset too.
if(!options.visible || !fieldGroups.length) {
return <></>;
}
// Prepare the array of components based on the types.
for(const field of schema.fields) {
const {
visible, disabled, readonly, modeSupported
} = getFieldMetaData(field, schema, value, viewHelperProps);
if(!modeSupported) continue;
// Its a form control.
const hasError = (field.id === schemaState?.errors.name);
/*
* When there is a change, the dependent values can also change.
* Let's pass these changes to dependent for take them into effect to
* generate new values.
*/
const currentControl = <MappedFormControl
state={value}
key={field.id}
viewHelperProps={viewHelperProps}
name={field.id}
value={value[field.id]}
{...field}
readonly={readonly}
disabled={disabled}
visible={visible}
onChange={(changeValue)=>{
/* Get the changes on dependent fields as well */
dataDispatch({
type: SCHEMA_STATE_ACTIONS.SET_VALUE,
path: accessPath.concat(field.id),
value: changeValue,
});
}}
hasError={hasError}
className={controlClassName}
memoDeps={[
value[field.id],
readonly,
disabled,
visible,
hasError,
controlClassName,
...(evalFunc(null, field.deps) || []).map((dep)=>value[dep]),
]}
/>;
if(field.inlineNext) {
inlineComponents.push(React.cloneElement(currentControl, {
withContainer: false, controlGridBasis: 3
}));
} else if(inlineComponents?.length > 0) {
inlineComponents.push(React.cloneElement(currentControl, {
withContainer: false, controlGridBasis: 3
}));
viewFields.push(
<Grid container spacing={0} key={`ic-${inlineComponents[0].key}`}
className={controlClassName} rowGap={INLINE_COMPONENT_ROWGAP}>
{inlineComponents}
</Grid>
);
inlineComponents = [];
} else {
viewFields.push(currentControl);
}
}
if(inlineComponents?.length > 0) {
viewFields.push(
<Grid container spacing={0} key={`ic-${inlineComponents[0].key}`}
className={controlClassName} rowGap={INLINE_COMPONENT_ROWGAP}>
{inlineComponents}
</Grid>
);
}
return (
<FieldSet title={label} className={controlClassName}>
{viewFields}
{fieldGroups.map(
(fieldGroup, gidx) => (
<React.Fragment key={gidx}>
{fieldGroup.controls.map(
(item, idx) => <FieldControl
item={item} key={idx} schemaId={schema._id} />
)}
</React.Fragment>
)
)}
</FieldSet>
);
}
FieldSetView.propTypes = {
value: PropTypes.any,
schema: CustomPropTypes.schemaUI.isRequired,
viewHelperProps: PropTypes.object,
isDataGridForm: PropTypes.bool,
accessPath: PropTypes.array.isRequired,
dataDispatch: PropTypes.func,
controlClassName: CustomPropTypes.className,
label: PropTypes.string,
visible: PropTypes.oneOfType([
PropTypes.bool, PropTypes.func,
]),
field: PropTypes.object,
};
registerView(FieldSetView, 'FieldSetView');

View File

@@ -0,0 +1,30 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useContext, useEffect, useState, useMemo } from 'react';
import Loader from 'sources/components/Loader';
import { SchemaStateContext } from './SchemaState';
export const FormLoader = () => {
const [key, setKey] = useState(0);
const schemaState = useContext(SchemaStateContext);
const message = schemaState.loadingMessage;
useEffect(() => {
// Refresh on message changes.
return schemaState.subscribe(
['message'], () => setKey(Date.now()), 'states'
);
}, [key]);
return useMemo(() => <Loader message={message}/>, [message, key]);
};

View File

@@ -8,80 +8,117 @@
//////////////////////////////////////////////////////////////
import React, {
useContext, useEffect, useMemo, useRef, useState
useCallback, useContext, useEffect, useMemo, useRef, useState
} from 'react';
import { Box, Tab, Tabs, Grid } from '@mui/material';
import { Box, Tab, Tabs } from '@mui/material';
import _ from 'lodash';
import PropTypes from 'prop-types';
import { FormNote, InputSQL } from 'sources/components/FormComponents';
import {
FormFooterMessage, MESSAGE_TYPE, FormNote
} from 'sources/components/FormComponents';
import TabPanel from 'sources/components/TabPanel';
import { useOnScreen } from 'sources/custom_hooks';
import CustomPropTypes from 'sources/custom_prop_types';
import gettext from 'sources/gettext';
import { evalFunc } from 'sources/utils';
import DataGridView from './DataGridView';
import { MappedFormControl } from './MappedControl';
import FieldSetView from './FieldSetView';
import {
SCHEMA_STATE_ACTIONS, SchemaStateContext, getFieldMetaData
} from './common';
import { FieldControl } from './FieldControl';
import { SQLTab } from './SQLTab';
import { FormContentBox } from './StyledComponents';
import { SchemaStateContext } from './SchemaState';
import { useFieldSchema, useFieldValue } from './hooks';
import { registerView, View } from './registry';
import { createFieldControls, listenDepChanges } from './utils';
const ErrorMessageBox = () => {
const [key, setKey] = useState(0);
const schemaState = useContext(SchemaStateContext);
const onErrClose = useCallback(() => {
const err = { ...schemaState.errors, message: '' };
// Unset the error message, but not the name.
schemaState.setError(err);
}, [schemaState]);
const errors = schemaState.errors;
const message = errors?.message || '';
/* 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]);
useEffect(() => {
// Refresh on message changes.
return schemaState.subscribe(
['errors', 'message'], () => setKey(Date.now()), 'states'
);
}, [key]);
return <InputSQL
value={sql}
options={{
readOnly: true,
}}
readonly={true}
className='FormView-sqlTabInput'
return <FormFooterMessage
type={MESSAGE_TYPE.ERROR} message={message} onClose={onErrClose}
/>;
}
SQLTab.propTypes = {
active: PropTypes.bool,
getSQLValue: PropTypes.func.isRequired,
};
/* The first component of schema view form */
// The first component of schema view form.
export default function FormView({
value, schema={}, viewHelperProps, isNested=false, accessPath, dataDispatch, hasSQLTab,
getSQLValue, onTabChange, firstEleRef, className, isDataGridForm=false, isTabView=true, visible}) {
let defaultTab = gettext('General');
let tabs = {};
let tabsClassname = {};
const [tabValue, setTabValue] = useState(0);
accessPath, schema=null, isNested=false, dataDispatch, className,
hasSQLTab, getSQLValue, isTabView=true, viewHelperProps, field,
showError=false, resetKey, focusOnFirstInput=false
}) {
const [key, setKey] = useState(0);
const schemaState = useContext(SchemaStateContext);
const value = useFieldValue(accessPath, schemaState);
const { visible } = useFieldSchema(
field, accessPath, value, viewHelperProps, schemaState, key, setKey
);
const firstEleID = useRef();
const [tabValue, setTabValue] = useState(0);
const formRef = useRef();
const onScreenTracker = useRef(false);
let groupLabels = {};
const schemaRef = useRef(schema);
const schemaState = useContext(SchemaStateContext);
let isOnScreen = useOnScreen(formRef);
useEffect(()=>{
if (!schema) schema = field.schema;
// Set focus on the first focusable element.
useEffect(() => {
if (!focusOnFirstInput) return;
setTimeout(() => {
const formEle = formRef.current;
if (!formEle) return;
const activeTabElement = formEle.querySelector(
'[data-test="tabpanel"]:not([hidden])'
);
if (!activeTabElement) return;
// Find the first focusable input, which is either:
// * An editable Input element.
// * A select element, which is not disabled.
// * An href element.
// * Any element with 'tabindex', but - tabindex is not set to '-1'.
const firstFocussableElement = activeTabElement.querySelector([
'button:not([role="tab"])',
'[href]',
'input:not(disabled)',
'select:not(disabled)',
'textarea',
'[tabindex]:not([tabindex="-1"]):not([data-test="tabpanel"])',
].join(', '));
if (firstFocussableElement) firstFocussableElement.focus();
}, 200);
}, [tabValue]);
useEffect(() => {
// Refresh on message changes.
return schemaState.subscribe(
['errors', 'message'],
(newState, prevState) => {
if (_.isUndefined(newState) || _.isUndefined(prevState));
setKey(Date.now());
},
'states'
);
}, [key]);
useEffect(() => {
if (!visible) return;
if(isOnScreen) {
/* Don't do it when the form is alredy visible */
if(!onScreenTracker.current) {
@@ -93,347 +130,184 @@ export default function FormView({
onScreenTracker.current = false;
}
}, [isOnScreen]);
listenDepChanges(accessPath, field, visible, schemaState);
useEffect(()=>{
/* Calculate the fields which depends on the current field */
if(!isDataGridForm) {
schemaRef.current.fields.forEach((field)=>{
/* Self change is also dep change */
if(field.depChange || field.deferredDepChange) {
schemaState?.addDepListener(accessPath.concat(field.id), accessPath.concat(field.id), field.depChange, field.deferredDepChange);
}
(evalFunc(null, field.deps) || []).forEach((dep)=>{
// when dep is a string then prepend the complete accessPath
let source = accessPath.concat(dep);
// but when dep is an array, then the intention is to provide the exact accesspath
if(_.isArray(dep)) {
source = dep;
}
if(field.depChange || field.deferredDepChange) {
schemaState?.addDepListener(source, accessPath.concat(field.id), field.depChange, field.deferredDepChange);
}
if(field.depChange || field.deferredDepChange) {
schemaState?.addDepListener(source, accessPath.concat(field.id), field.depChange, field.deferredDepChange);
}
});
});
return ()=>{
/* Cleanup the listeners when unmounting */
schemaState?.removeDepListener(accessPath);
};
}
}, []);
/* Upon reset, set the tab to first */
useEffect(()=>{
if (schemaState?.isReady)
// Upon reset, set the tab to first.
useEffect(() => {
if (!visible || !resetKey) return;
if (resetKey) {
setTabValue(0);
}, [schemaState?.isReady]);
let fullTabs = [];
let inlineComponents = [];
let inlineCompGroup = null;
/* Prepare the array of components based on the types */
for(const field of schemaRef.current.fields) {
let {
visible, disabled, readonly, canAdd, canEdit, canDelete, canReorder,
canAddRow, modeSupported
} = getFieldMetaData(field, schema, value, viewHelperProps);
if(!modeSupported) continue;
let {group, CustomControl} = field;
if(field.type === 'group') {
groupLabels[field.id] = field.label;
if(!visible) {
schemaRef.current.filterGroups.push(field.label);
}
continue;
}
}, [resetKey]);
group = groupLabels[group] || group || defaultTab;
if(!tabs[group]) tabs[group] = [];
// Lets choose the path based on type.
if(field.type === 'nested-tab') {
/* Pass on the top schema */
if(isNested) {
field.schema.top = schemaRef.current.top;
} else {
field.schema.top = schema;
}
tabs[group].push(
<FormView key={`nested${tabs[group].length}`} value={value} viewHelperProps={viewHelperProps}
schema={field.schema} accessPath={accessPath} dataDispatch={dataDispatch} isNested={true} isDataGridForm={isDataGridForm}
{...field} visible={visible}/>
);
} else if(field.type === 'nested-fieldset') {
/* Pass on the top schema */
if(isNested) {
field.schema.top = schemaRef.current.top;
} else {
field.schema.top = schema;
}
tabs[group].push(
<FieldSetView key={`nested${tabs[group].length}`} value={value} viewHelperProps={viewHelperProps}
schema={field.schema} accessPath={accessPath} dataDispatch={dataDispatch} isNested={true} isDataGridForm={isDataGridForm}
controlClassName='FormView-controlRow'
{...field} visible={visible}/>
);
} else if(field.type === 'collection') {
/* If its a collection, let data grid view handle it */
/* Pass on the top schema */
if(isNested) {
field.schema.top = schemaRef.current.top;
} else {
field.schema.top = schemaRef.current;
}
if(!_.isUndefined(field.fixedRows)) {
canAdd = false;
canDelete = false;
}
const ctrlProps = {
key: field.id, ...field,
value: value[field.id] || [], viewHelperProps: viewHelperProps,
schema: field.schema, accessPath: accessPath.concat(field.id), dataDispatch: dataDispatch,
containerClassName: 'FormView-controlRow',
canAdd: canAdd, canReorder: canReorder,
canEdit: canEdit, canDelete: canDelete,
visible: visible, canAddRow: canAddRow, onDelete: field.onDelete, canSearch: field.canSearch,
expandEditOnAdd: field.expandEditOnAdd,
fixedRows: (viewHelperProps.mode == 'create' ? field.fixedRows : undefined),
addOnTop: Boolean(field.addOnTop)
};
if(CustomControl) {
tabs[group].push(<CustomControl {...ctrlProps}/>);
} else {
tabs[group].push(<DataGridView {...ctrlProps} />);
}
} else {
/* Its a form control */
const hasError = _.isEqual(
accessPath.concat(field.id), schemaState.errors?.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.
*/
if(field.isFullTab) {
tabsClassname[group] ='FormView-fullSpace';
fullTabs.push(group);
}
const id = field.id || `control${tabs[group].length}`;
if(visible && !disabled && !firstEleID.current) {
firstEleID.current = field.id;
}
let currentControl = <MappedFormControl
inputRef={(ele)=>{
if(firstEleRef && firstEleID.current === field.id) {
if(typeof firstEleRef == 'function') {
firstEleRef(ele);
} else {
firstEleRef.current = ele;
}
}
}}
state={value}
key={id}
viewHelperProps={viewHelperProps}
name={id}
value={value[id]}
{...field}
id={id}
readonly={readonly}
disabled={disabled}
visible={visible}
onChange={(changeValue)=>{
/* Get the changes on dependent fields as well */
dataDispatch({
type: SCHEMA_STATE_ACTIONS.SET_VALUE,
path: accessPath.concat(id),
value: changeValue,
});
}}
hasError={hasError}
className='FormView-controlRow'
noLabel={field.isFullTab}
memoDeps={[
value[id],
readonly,
disabled,
visible,
hasError,
'FormView-controlRow',
...(evalFunc(null, field.deps) || []).map((dep)=>value[dep]),
]}
/>;
if(field.isFullTab && field.helpMessage) {
currentControl = (<React.Fragment key={`coll-${field.id}`}>
<FormNote key={`note-${field.id}`} text={field.helpMessage}/>
{currentControl}
</React.Fragment>);
}
if(field.inlineNext) {
inlineComponents.push(React.cloneElement(currentControl, {
withContainer: false, controlGridBasis: 3
}));
inlineCompGroup = group;
} else if(inlineComponents?.length > 0) {
inlineComponents.push(React.cloneElement(currentControl, {
withContainer: false, controlGridBasis: 3
}));
tabs[group].push(
<Grid container spacing={0} key={`ic-${inlineComponents[0].key}`}
className='FormView-controlRow' rowGap="8px">
{inlineComponents}
</Grid>
);
inlineComponents = [];
inlineCompGroup = null;
} else {
tabs[group].push(currentControl);
}
}
}
if(inlineComponents?.length > 0) {
tabs[inlineCompGroup].push(
<Grid container spacing={0} key={`ic-${inlineComponents[0].key}`}
className='FormView-controlRow' rowGap="8px">
{inlineComponents}
</Grid>
);
}
let finalTabs = _.pickBy(
tabs, (v, tabName) => schemaRef.current.filterGroups.indexOf(tabName) <= -1
const finalGroups = useMemo(
() => createFieldControls({
schema, schemaState, accessPath, viewHelperProps, dataDispatch
}),
[schema, schemaState, accessPath, viewHelperProps, dataDispatch]
);
// Add the SQL tab (if required)
let sqlTabActive = false;
let sqlTabName = gettext('SQL');
if(hasSQLTab) {
sqlTabActive = (Object.keys(finalTabs).length === tabValue);
// Re-render and fetch the SQL tab when it is active.
finalTabs[sqlTabName] = [
<SQLTab key="sqltab" active={sqlTabActive} getSQLValue={getSQLValue} />,
];
tabsClassname[sqlTabName] = 'FormView-fullSpace';
fullTabs.push(sqlTabName);
}
useEffect(() => {
onTabChange?.(tabValue, Object.keys(tabs)[tabValue], sqlTabActive);
}, [tabValue]);
const isSingleCollection = useMemo(()=>{
// we can check if it is a single-collection.
// in that case, we could force virtualization of the collection.
if(isTabView) return false;
const visibleEle = Object.values(finalTabs)[0].filter(
(c) => c.props.visible
);
return visibleEle.length == 1 && visibleEle[0]?.type == DataGridView;
}, [isTabView, finalTabs]);
// Check whether form is kept hidden by visible prop.
if(!_.isUndefined(visible) && !visible) {
if(!finalGroups || (!_.isUndefined(visible) && !visible)) {
return <></>;
}
const isSingleCollection = () => {
const DataGridView = View('DataGridView');
return (
finalGroups.length == 1 &&
finalGroups[0].controls.length == 1 &&
finalGroups[0].controls[0].control == DataGridView
);
};
if(isTabView) {
return (
<FormContentBox height="100%" display="flex" flexDirection="column"
className={className} ref={formRef} data-test="form-view">
<Box>
<Tabs
value={tabValue}
onChange={(event, selTabValue) => { setTabValue(selTabValue); }}
variant="scrollable"
scrollButtons="auto"
action={(ref)=>ref?.updateIndicator()}
>
{Object.keys(finalTabs).map((tabName)=>{
return <Tab key={tabName} label={tabName} data-test={tabName}/>;
})}
</Tabs>
</Box>
{Object.keys(finalTabs).map((tabName, i)=>{
let contentClassName = [(
schemaState.errors?.message ? 'FormView-errorMargin': null
)];
<>
<FormContentBox height="100%" display="flex" flexDirection="column"
className={className} ref={formRef} data-test="form-view">
<Box>
<Tabs
value={tabValue}
onChange={(ev, nextTabIndex) => { setTabValue(nextTabIndex); }}
variant="scrollable"
scrollButtons="auto"
action={(ref) => ref?.updateIndicator()}
>{
finalGroups.map((tabGroup, idx) =>
<Tab
key={tabGroup.id}
label={tabGroup.label}
data-test={tabGroup.id}
className={
tabGroup.hasError &&
tabValue != idx ? 'tab-with-error' : ''
}
/>
)
}{hasSQLTab &&
<Tab
key={'sql-tab'}
label={gettext('SQL')}
data-test={'SQL'}
/>
}</Tabs>
</Box>
{
finalGroups.map((group, idx) => {
let contentClassName = [
group.isFullTab ?
'FormView-fullControl' : 'FormView-nestedControl',
schemaState.errors?.message ? 'FormView-errorMargin' : null
];
if(fullTabs.indexOf(tabName) == -1) {
contentClassName.push('FormView-nestedControl');
} else {
contentClassName.push('FormView-fullControl');
let id = group.id.replace(' ', '');
return (
<TabPanel
key={id}
value={tabValue}
index={idx}
classNameRoot={[
group.className,
(isNested ? 'FormView-nestedTabPanel' : null)
].join(' ')}
className={contentClassName.join(' ')}
data-testid={group.id}>
{
group.isFullTab && group.field?.helpMessage ?
<FormNote
key={`note-${group.field.id}`}
text={group.field.helpMessage}/> :
<></>
}
{
group.controls.map(
(item, idx) => <FieldControl
item={item} key={idx} schemaId={schema._id} />
)
}
</TabPanel>
);
})
}
return (
<TabPanel key={tabName} value={tabValue} index={i}
classNameRoot={[
tabsClassname[tabName],
(isNested ? 'FormView-nestedTabPanel' : null)
].join(' ')}
className={contentClassName.join(' ')} data-testid={tabName}>
{finalTabs[tabName]}
</TabPanel>
);
})}
</FormContentBox>
{
hasSQLTab &&
<TabPanel
key={'sql-tab'}
value={tabValue}
index={finalGroups.length}
classNameRoot={'FormView-fullSpace'}
data-testid={'SQL'}
>
<SQLTab
active={(Object.keys(finalGroups).length === tabValue)}
getSQLValue={getSQLValue}
/>
</TabPanel>
}
</FormContentBox>
{ showError && <ErrorMessageBox /> }
</>
);
} else {
let contentClassName = [
isSingleCollection ? 'FormView-singleCollectionPanelContent' :
isSingleCollection() ? 'FormView-singleCollectionPanelContent' :
'FormView-nonTabPanelContent',
(schemaState.errors?.message ? 'FormView-errorMargin' : null)
];
return (
<FormContentBox height="100%" display="flex" flexDirection="column" className={className} ref={formRef} data-test="form-view">
<TabPanel value={tabValue} index={0} classNameRoot={[isSingleCollection ? 'FormView-singleCollectionPanel' : 'FormView-nonTabPanel',className].join(' ')}
className={contentClassName.join(' ')}>
{Object.keys(finalTabs).map((tabName) => {
return (
<React.Fragment key={tabName}>
{finalTabs[tabName]}
</React.Fragment>
);
})}
</TabPanel>
</FormContentBox>
<>
<FormContentBox
height="100%" display="flex" flexDirection="column"
className={className}
ref={formRef}
data-test="form-view"
>
<TabPanel
value={tabValue} index={0}
classNameRoot={[
isSingleCollection() ?
'FormView-singleCollectionPanel' : 'FormView-nonTabPanel',
className
].join(' ')}
className={contentClassName.join(' ')}>
{
finalGroups.map((group, idx) =>
<React.Fragment key={idx}>{
group.controls.map(
(item, idx) => <FieldControl
item={item} key={idx} schemaId={schema._id}
/>
)
}</React.Fragment>
)
}
{
hasSQLTab && <SQLTab active={true} getSQLValue={getSQLValue} />
}
</TabPanel>
</FormContentBox>
{ showError && <ErrorMessageBox /> }
</>
);
}
}
FormView.propTypes = {
value: PropTypes.any,
schema: CustomPropTypes.schemaUI.isRequired,
schema: CustomPropTypes.schemaUI,
viewHelperProps: PropTypes.object,
isNested: PropTypes.bool,
isDataGridForm: PropTypes.bool,
isTabView: PropTypes.bool,
visible: PropTypes.oneOfType([
PropTypes.bool, PropTypes.func,
]),
accessPath: PropTypes.array.isRequired,
dataDispatch: PropTypes.func,
hasSQLTab: PropTypes.bool,
getSQLValue: PropTypes.func,
onTabChange: PropTypes.func,
firstEleRef: CustomPropTypes.ref,
className: CustomPropTypes.className,
field: PropTypes.object,
showError: PropTypes.bool,
};
registerView(FormView, 'FormView');

View File

@@ -0,0 +1,56 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useContext } from 'react';
import { Grid } from '@mui/material';
import _ from 'lodash';
import PropTypes from 'prop-types';
import { SchemaStateContext } from './SchemaState';
import { useFieldOptions } from './hooks';
import { registerView } from './registry';
import { listenDepChanges } from './utils';
// The first component of schema view form.
export default function InlineView({
accessPath, field, children, viewHelperProps
}) {
const { mode } = (viewHelperProps || {});
const isPropertyMode = mode === 'properties';
const schemaState = useContext(SchemaStateContext);
const { visible } =
accessPath ? useFieldOptions(accessPath, schemaState) : { visible: true };
if (!accessPath || isPropertyMode)
listenDepChanges(accessPath, field, visible, schemaState);
// Check whether form is kept hidden by visible prop.
// We don't support inline-view in 'property' mode
if((!_.isUndefined(visible) && !visible) || isPropertyMode) {
return <></>;
}
return (
<Grid container spacing={0} className='FormView-controlRow' rowGap="8px">
{children}
</Grid>
);
}
InlineView.propTypes = {
accessPath: PropTypes.array,
field: PropTypes.object,
children : PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
])
};
registerView(InlineView, 'InlineView');

View File

@@ -7,22 +7,38 @@
//
//////////////////////////////////////////////////////////////
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useContext, useMemo, useState } from 'react';
import _ from 'lodash';
import {
FormInputText, FormInputSelect, FormInputSwitch, FormInputCheckbox, FormInputColor,
FormInputFileSelect, FormInputToggle, InputSwitch, FormInputSQL, InputSQL, FormNote, FormInputDateTimePicker, PlainString,
InputSelect, InputText, InputCheckbox, InputDateTimePicker, InputFileSelect, FormInputKeyboardShortcut, FormInputQueryThreshold, FormInputSelectThemes, InputRadio, FormButton, InputTree
} from '../components/FormComponents';
import Privilege from '../components/Privilege';
import { evalFunc } from 'sources/utils';
import PropTypes from 'prop-types';
import CustomPropTypes from '../custom_prop_types';
import { SelectRefresh } from '../components/SelectRefresh';
import {
FormButton, FormInputCheckbox, FormInputColor, FormInputDateTimePicker,
FormInputFileSelect, FormInputKeyboardShortcut, FormInputQueryThreshold,
FormInputSQL, FormInputSelect, FormInputSelectThemes, FormInputSwitch,
FormInputText, FormInputToggle, FormNote, InputCheckbox, InputDateTimePicker,
InputFileSelect, InputRadio, InputSQL,InputSelect, InputSwitch, InputText,
InputTree, PlainString,
} from 'sources/components/FormComponents';
import { SelectRefresh } from 'sources/components/SelectRefresh';
import Privilege from 'sources/components/Privilege';
import { useIsMounted } from 'sources/custom_hooks';
import CustomPropTypes from 'sources/custom_prop_types';
import { evalFunc } from 'sources/utils';
import { SchemaStateContext } from './SchemaState';
import { isValueEqual } from './common';
import {
useFieldOptions, useFieldValue, useFieldError
} from './hooks';
import { listenDepChanges } from './utils';
/* Control mapping for form view */
function MappedFormControlBase({ type, value, id, onChange, className, visible, inputRef, noLabel, onClick, withContainer, controlGridBasis, ...props }) {
const name = id;
function MappedFormControlBase({
id, type, state, onChange, className, inputRef, visible,
withContainer, controlGridBasis, noLabel, ...props
}) {
let name = id;
const onTextChange = useCallback((e) => {
let val = e;
if(e?.target) {
@@ -30,6 +46,7 @@ function MappedFormControlBase({ type, value, id, onChange, className, visible,
}
onChange?.(val);
}, []);
const value = state;
const onSqlChange = useCallback((changedValue) => {
onChange?.(changedValue);
@@ -43,58 +60,114 @@ function MappedFormControlBase({ type, value, id, onChange, className, visible,
return <></>;
}
if (name && _.isNumber(name)) {
name = String('name');
}
/* The mapping uses Form* components as it comes with labels */
switch (type) {
case 'int':
return <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} type='int' />;
return <FormInputText
name={name} value={value} onChange={onTextChange} className={className}
inputRef={inputRef} {...props} type='int'
/>;
case 'numeric':
return <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} type='numeric' />;
return <FormInputText
name={name} value={value} onChange={onTextChange} className={className}
inputRef={inputRef} {...props} type='numeric'
/>;
case 'tel':
return <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} type='tel' />;
return <FormInputText
name={name} value={value} onChange={onTextChange} className={className}
inputRef={inputRef} {...props} type='tel'
/>;
case 'text':
return <FormInputText name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} />;
return <FormInputText
name={name} value={value} onChange={onTextChange} className={className}
inputRef={inputRef} {...props}
/>;
case 'multiline':
return <FormInputText name={name} value={value} onChange={onTextChange} className={className}
inputRef={inputRef} controlProps={{ multiline: true }} {...props} />;
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} />;
return <FormInputText
name={name} value={value} onChange={onTextChange} className={className}
type='password' inputRef={inputRef} {...props}
/>;
case 'select':
return <FormInputSelect name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} />;
return <FormInputSelect
name={name} value={value} onChange={onTextChange} className={className}
inputRef={inputRef} {...props}
/>;
case 'select-refresh':
return <SelectRefresh name={name} value={value} onChange={onTextChange} className={className} {...props} />;
return <SelectRefresh
name={name} value={value} onChange={onTextChange} className={className}
{...props}
/>;
case 'switch':
return <FormInputSwitch name={name} value={value}
onChange={(e) => onTextChange(e.target.checked, e.target.name)} className={className}
return <FormInputSwitch
name={name} value={value} className={className}
onChange={(e) => onTextChange(e.target.checked, e.target.name)}
withContainer={withContainer} controlGridBasis={controlGridBasis}
{...props} />;
{...props}
/>;
case 'checkbox':
return <FormInputCheckbox name={name} value={value}
onChange={(e) => onTextChange(e.target.checked, e.target.name)} className={className}
{...props} />;
return <FormInputCheckbox
name={name} value={value} className={className}
onChange={(e) => onTextChange(e.target.checked, e.target.name)}
{...props}
/>;
case 'toggle':
return <FormInputToggle name={name} value={value}
onChange={onTextChange} className={className} inputRef={inputRef}
{...props} />;
return <FormInputToggle
name={name} value={value} onChange={onTextChange} className={className}
inputRef={inputRef} {...props}
/>;
case 'color':
return <FormInputColor name={name} value={value} onChange={onTextChange} className={className} {...props} />;
return <FormInputColor
name={name} value={value} onChange={onTextChange} className={className}
{...props}
/>;
case 'file':
return <FormInputFileSelect name={name} value={value} onChange={onTextChange} className={className} inputRef={inputRef} {...props} />;
return <FormInputFileSelect
name={name} value={value} onChange={onTextChange} className={className}
inputRef={inputRef} {...props}
/>;
case 'sql':
return <FormInputSQL name={name} value={value} onChange={onSqlChange} className={className} noLabel={noLabel} inputRef={inputRef} {...props} />;
return <FormInputSQL
name={name} value={value} onChange={onSqlChange} className={className}
noLabel={noLabel} inputRef={inputRef} {...props}
/>;
case 'note':
return <FormNote className={className} {...props} />;
case 'datetimepicker':
return <FormInputDateTimePicker name={name} value={value} onChange={onTextChange} className={className} {...props} />;
return <FormInputDateTimePicker
name={name} value={value} onChange={onTextChange} className={className}
{...props}
/>;
case 'keyboardShortcut':
return <FormInputKeyboardShortcut name={name} value={value} onChange={onTextChange} {...props}/>;
return <FormInputKeyboardShortcut
name={name} value={value} onChange={onTextChange} {...props}
/>;
case 'threshold':
return <FormInputQueryThreshold name={name} value={value} onChange={onTextChange} {...props}/>;
return <FormInputQueryThreshold
name={name} value={value} onChange={onTextChange} {...props}
/>;
case 'theme':
return <FormInputSelectThemes name={name} value={value} onChange={onTextChange} {...props}/>;
return <FormInputSelectThemes
name={name} value={value} onChange={onTextChange} {...props}
/>;
case 'button':
return <FormButton name={name} value={value} className={className} onClick={onClick} {...props} />;
return <FormButton
name={name} value={value} className={className} onClick={props.onClick}
{...props}
/>;
case 'tree':
return <InputTree name={name} treeData={props.treeData} onChange={onTreeSelection} {...props}/>;
return <InputTree
name={name} treeData={props.treeData} onChange={onTreeSelection}
{...props}
/>;
default:
return <PlainString value={value} {...props} />;
}
@@ -105,7 +178,7 @@ MappedFormControlBase.propTypes = {
PropTypes.string, PropTypes.func,
]).isRequired,
value: PropTypes.any,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
onChange: PropTypes.func,
className: PropTypes.oneOfType([
PropTypes.string, PropTypes.object,
@@ -116,12 +189,17 @@ MappedFormControlBase.propTypes = {
onClick: PropTypes.func,
withContainer: PropTypes.bool,
controlGridBasis: PropTypes.number,
treeData: PropTypes.oneOfType([PropTypes.array, PropTypes.instanceOf(Promise), PropTypes.func]),
treeData: PropTypes.oneOfType([
PropTypes.array, PropTypes.instanceOf(Promise), PropTypes.func]
),
};
/* Control mapping for grid cell view */
function MappedCellControlBase({ cell, value, id, optionsLoaded, onCellChange, visible, reRenderRow, inputRef, ...props }) {
const name = id;
function MappedCellControlBase({
cell, value, id, optionsLoaded, onCellChange, visible, reRenderRow, inputRef,
...props
}) {
let name = id;
const onTextChange = useCallback((e) => {
let val = e;
if (e?.target) {
@@ -156,6 +234,10 @@ function MappedCellControlBase({ cell, value, id, optionsLoaded, onCellChange, v
return <></>;
}
if (name && _.isNumber(name)) {
name = String('name');
}
/* The mapping does not need Form* components as labels are not needed for grid cells */
switch(cell) {
case 'int':
@@ -211,8 +293,9 @@ const ALLOWED_PROPS_FIELD_COMMON = [
'mode', 'value', 'readonly', 'disabled', 'hasError', 'id',
'label', 'options', 'optionsLoaded', 'controlProps', 'schema', 'inputRef',
'visible', 'autoFocus', 'helpMessage', 'className', 'optionsReloadBasis',
'orientation', 'isvalidate', 'fields', 'radioType', 'hideBrowseButton', 'btnName', 'hidden',
'withContainer', 'controlGridBasis', 'hasCheckbox', 'treeData', 'labelTooltip'
'orientation', 'isvalidate', 'fields', 'radioType', 'hideBrowseButton',
'btnName', 'hidden', 'withContainer', 'controlGridBasis', 'hasCheckbox',
'treeData', 'labelTooltip'
];
const ALLOWED_PROPS_FIELD_FORM = [
@@ -220,50 +303,128 @@ const ALLOWED_PROPS_FIELD_FORM = [
];
const ALLOWED_PROPS_FIELD_CELL = [
'cell', 'onCellChange', 'row', 'reRenderRow', 'validate', 'disabled', 'readonly', 'radioType', 'hideBrowseButton', 'hidden'
'cell', 'onCellChange', 'reRenderRow', 'validate', 'disabled',
'readonly', 'radioType', 'hideBrowseButton', 'hidden', 'row',
];
export const StaticMappedFormControl = ({accessPath, field, ...props}) => {
const schemaState = useContext(SchemaStateContext);
const state = schemaState.value(accessPath);
const newProps = {
...props,
state,
noLabel: field.isFullTab,
...field,
onChange: () => { /* Do nothing */ },
};
const visible = evalFunc(null, field.visible, state);
export const MappedFormControl = ({memoDeps, ...props}) => {
let newProps = { ...props };
let typeProps = evalFunc(null, newProps.type, newProps.state);
if (typeof (typeProps) === 'object') {
if (visible === false) return <></>;
return useMemo(
() => <MappedFormControlBase
{
..._.pick(
newProps,
_.union(ALLOWED_PROPS_FIELD_COMMON, ALLOWED_PROPS_FIELD_FORM)
)
}
/>, []
);
};
export const MappedFormControl = ({
accessPath, dataDispatch, field, onChange, ...props
}) => {
const checkIsMounted = useIsMounted();
const [key, setKey] = useState(0);
const schemaState = useContext(SchemaStateContext);
const state = schemaState.data;
const avoidRenderingWhenNotMounted = (newKey) => {
if (checkIsMounted()) {
setKey(newKey);
}
};
const value = useFieldValue(
accessPath, schemaState, key, avoidRenderingWhenNotMounted
);
const options = useFieldOptions(
accessPath, schemaState, key, avoidRenderingWhenNotMounted
);
const { hasError } = useFieldError(
accessPath, schemaState, key, avoidRenderingWhenNotMounted
);
const origOnChange = onChange;
onChange = (changedValue) => {
if (!origOnChange || !checkIsMounted()) return;
// We don't want the 'onChange' to be executed for the same value to avoid
// rerendering of the control, top component may still be rerendered on the
// change of the value.
const currValue = schemaState.value(accessPath);
if (!isValueEqual(changedValue, currValue)) origOnChange(changedValue);
};
listenDepChanges(accessPath, field, options.visible, schemaState);
let newProps = {
...props,
state: value,
noLabel: field.isFullTab,
...field,
onChange: onChange,
dataDispatch: dataDispatch,
...options,
hasError,
};
if (typeof (field.type) === 'function') {
const typeProps = evalFunc(null, field.type, state);
newProps = {
...newProps,
...typeProps,
};
} else {
newProps.type = typeProps;
}
let origOnClick = newProps.onClick;
newProps.onClick = ()=>{
origOnClick?.();
/* Consider on click as change for button.
Just increase state val by 1 to inform the deps and self depChange */
newProps.onChange?.((newProps.state[props.id]||0)+1);
};
// FIXME:: Get this list from the option registry.
const memDeps = ['disabled', 'visible', 'readonly'].map(
option => options[option]
);
memDeps.push(value);
memDeps.push(hasError);
memDeps.push(key);
memDeps.push(JSON.stringify(accessPath));
/* Filter out garbage props if any using ALLOWED_PROPS_FIELD */
return useMemo(()=><MappedFormControlBase {..._.pick(newProps, _.union(ALLOWED_PROPS_FIELD_COMMON, ALLOWED_PROPS_FIELD_FORM))} />, memoDeps??[]);
// Filter out garbage props if any using ALLOWED_PROPS_FIELD.
return useMemo(
() => <MappedFormControlBase
{
..._.pick(
newProps,
_.union(ALLOWED_PROPS_FIELD_COMMON, ALLOWED_PROPS_FIELD_FORM)
)
}
/>, [...memDeps]
);
};
MappedFormControl.propTypes = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
};
export const MappedCellControl = (props) => {
let newProps = { ...props };
let cellProps = evalFunc(null, newProps.cell, newProps.row.original);
if (typeof (cellProps) === 'object') {
newProps = {
...newProps,
...cellProps,
};
} else {
newProps.cell = cellProps;
}
const newProps = _.pick(
props, _.union(ALLOWED_PROPS_FIELD_COMMON, ALLOWED_PROPS_FIELD_CELL)
);;
/* Filter out garbage props if any using ALLOWED_PROPS_FIELD */
return <MappedCellControlBase {..._.pick(newProps, _.union(ALLOWED_PROPS_FIELD_COMMON, ALLOWED_PROPS_FIELD_CELL))} />;
// Filter out garbage props if any using ALLOWED_PROPS_FIELD.
return <MappedCellControlBase {...newProps}/>;
};

View File

@@ -0,0 +1,42 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useContext, useEffect, useState } from 'react';
import { DefaultButton } from 'sources/components/Buttons';
import { SchemaStateContext } from './SchemaState';
export function ResetButton({label, Icon, onClick}) {
const [key, setKey] = useState(0);
const schemaState = useContext(SchemaStateContext);
const checkDisabled = (state) => (state.isSaving || !state.isDirty);
const currState = schemaState.state();
const isDisabled = checkDisabled(currState);
useEffect(() => {
if (!schemaState) return;
const refreshOnDisableStateChanged = (newState) => {
if (isDisabled !== checkDisabled(newState)) setKey(Date.now());
};
return schemaState.subscribe([], refreshOnDisableStateChanged, 'states');
}, [key]);
return (
<DefaultButton
data-test='Reset' onClick={onClick}
startIcon={Icon}
disabled={isDisabled}
className='Dialog-buttonMargin'>
{ label }
</DefaultButton>
);
}

View File

@@ -0,0 +1,45 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { InputSQL } from 'sources/components/FormComponents';
// Optional SQL tab.
export 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,
}}
readonly={true}
className='FormView-sqlTabInput'
/>;
}
SQLTab.propTypes = {
active: PropTypes.bool,
getSQLValue: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,50 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useContext, useEffect, useState } from 'react';
import { PrimaryButton } from 'sources/components/Buttons';
import { SchemaStateContext } from './SchemaState';
export function SaveButton({
label, Icon, checkDirtyOnEnableSave, onClick, mode,
}) {
const [key, setKey] = useState(0);
const schemaState = useContext(SchemaStateContext);
const checkDisabled = (state) => {
const {isDirty, isSaving, errors} = state;
return (
isSaving ||
!(mode === 'edit' || checkDirtyOnEnableSave ? isDirty : true) ||
Boolean(errors.name)
);
};
const currState = schemaState.state();
const isDisabled = checkDisabled(currState);
useEffect(() => {
if (!schemaState) return;
const refreshOnDisableStateChanged = (newState) => {
if (isDisabled !== checkDisabled(newState)) setKey(Date.now());
};
return schemaState.subscribe([], refreshOnDisableStateChanged, 'states');
}, [key]);
return (
<PrimaryButton
data-test='Save' onClick={onClick} startIcon={Icon}
disabled={isDisabled}
>
{label}
</PrimaryButton>
);
}

View File

@@ -7,9 +7,7 @@
//
//////////////////////////////////////////////////////////////
import React, {
useCallback, useEffect, useRef, useState,
} from 'react';
import React, { useEffect, useMemo } from 'react';
import CloseIcon from '@mui/icons-material/Close';
import DoneIcon from '@mui/icons-material/Done';
@@ -25,23 +23,21 @@ import PropTypes from 'prop-types';
import { parseApiError } from 'sources/api_instance';
import { usePgAdmin } from 'sources/BrowserComponent';
import Loader from 'sources/components/Loader';
import { useIsMounted } from 'sources/custom_hooks';
import {
PrimaryButton, DefaultButton, PgIconButton
DefaultButton, PgIconButton
} from 'sources/components/Buttons';
import {
FormFooterMessage, MESSAGE_TYPE
} from 'sources/components/FormComponents';
import CustomPropTypes from 'sources/custom_prop_types';
import gettext from 'sources/gettext';
import { FormLoader } from './FormLoader';
import FormView from './FormView';
import { ResetButton } from './ResetButton';
import { SaveButton } from './SaveButton';
import { SchemaStateContext } from './SchemaState';
import { StyledBox } from './StyledComponents';
import { useSchemaState } from './useSchemaState';
import {
getForQueryParams, SchemaStateContext
} from './common';
import { useSchemaState } from './hooks';
import { getForQueryParams } from './common';
/* If its the dialog */
@@ -50,30 +46,22 @@ export default function SchemaDialogView({
isTabView=true, checkDirtyOnEnableSave=false, ...props
}) {
// View helper properties
const { mode, keepCid } = viewHelperProps;
const onDataChange = props.onDataChange;
// Message to the user on long running operations.
const [loaderText, setLoaderText] = useState('');
// Schema data state manager
const {schemaState, dataDispatch, sessData, reset} = useSchemaState({
const {schemaState, dataDispatch, reset} = useSchemaState({
schema: schema, getInitData: getInitData, immutableData: {},
mode: mode, keepCid: keepCid, onDataChange: onDataChange,
});
const [{isNew, isDirty, isReady, errors}, updateSchemaState] = useState({
isNew: true, isDirty: false, isReady: false, errors: {}
viewHelperProps: viewHelperProps, onDataChange: onDataChange,
loadingText,
});
// Is saving operation in progress?
const [saving, setSaving] = useState(false);
const setSaving = (val) => schemaState.isSaving = val;
const setLoaderText = (val) => schemaState.setMessage(val);
// First element to be set by the FormView to set the focus after loading
// the data.
const firstEleRef = useRef();
const checkIsMounted = useIsMounted();
const [data, setData] = useState({});
// Notifier object.
const pgAdmin = usePgAdmin();
@@ -84,7 +72,6 @@ export default function SchemaDialogView({
* Docker on load focusses itself, so our focus should execute later.
*/
let focusTimeout = setTimeout(()=>{
firstEleRef.current?.focus();
}, 250);
// Clear the focus timeout if unmounted.
@@ -93,24 +80,13 @@ export default function SchemaDialogView({
};
}, []);
useEffect(() => {
setLoaderText(schemaState.message);
}, [schemaState.message]);
useEffect(() => {
setData(sessData);
updateSchemaState(schemaState);
}, [sessData.__changeId]);
useEffect(()=>{
if (!props.resetKey) return;
reset();
}, [props.resetKey]);
const onResetClick = () => {
const resetIt = () => {
firstEleRef.current?.focus();
reset();
return true;
};
@@ -128,7 +104,7 @@ export default function SchemaDialogView({
};
const save = (changeData) => {
props.onSave(isNew, changeData)
props.onSave(schemaState.isNew, changeData)
.then(()=>{
if(schema.informText) {
Notifier.alert(
@@ -151,20 +127,23 @@ export default function SchemaDialogView({
const onSaveClick = () => {
// Do nothing when there is no change or there is an error
if (!schemaState.changes || errors.name) return;
if (
!schemaState._changes || Object.keys(schemaState._changes) === 0 ||
schemaState.errors.name
) return;
setSaving(true);
setLoaderText('Saving...');
if (!schema.warningText) {
save(schemaState.Changes(true));
save(schemaState.changes(true));
return;
}
Notifier.confirm(
gettext('Warning'),
schema.warningText,
()=> { save(schemaState.Changes(true)); },
() => { save(schemaState.changes(true)); },
() => {
setSaving(false);
setLoaderText('');
@@ -173,29 +152,22 @@ export default function SchemaDialogView({
);
};
const onErrClose = useCallback(() => {
const err = { ...errors, message: '' };
// Unset the error message, but not the name.
schemaState.setError(err);
updateSchemaState({isNew, isDirty, isReady, errors: err});
});
const getSQLValue = () => {
// Called when SQL tab is active.
if(!isDirty) {
if(!schemaState.isDirty) {
return Promise.resolve('-- ' + gettext('No updates.'));
}
if(errors.name) {
if(schemaState.errors.name) {
return Promise.resolve('-- ' + gettext('Definition incomplete.'));
}
const changeData = schemaState.changes;
const changeData = schemaState._changes;
/*
* Call the passed incoming getSQLValue func to get the SQL
* return of getSQLValue should be a promise.
*/
return props.getSQLValue(isNew, getForQueryParams(changeData));
return props.getSQLValue(schemaState.isNew, getForQueryParams(changeData));
};
const getButtonIcon = () => {
@@ -207,29 +179,24 @@ export default function SchemaDialogView({
return <SaveIcon />;
};
const disableSaveBtn = saving ||
!isReady ||
!(mode === 'edit' || checkDirtyOnEnableSave ? isDirty : true) ||
Boolean(errors.name && errors.name !== 'apierror');
let ButtonIcon = getButtonIcon();
/* I am Groot */
return (
return useMemo(() =>
<StyledBox>
<SchemaStateContext.Provider value={schemaState}>
<Box className='Dialog-form'>
<Loader message={loaderText || loadingText}/>
<FormView value={data}
<FormLoader/>
<FormView
viewHelperProps={viewHelperProps}
schema={schema} accessPath={[]}
dataDispatch={dataDispatch}
hasSQLTab={props.hasSQL} getSQLValue={getSQLValue}
firstEleRef={firstEleRef} isTabView={isTabView}
className={props.formClassName} />
<FormFooterMessage
type={MESSAGE_TYPE.ERROR} message={errors?.message}
onClose={onErrClose} />
isTabView={isTabView}
className={props.formClassName}
showError={true} resetKey={props.resetKey}
focusOnFirstInput={true}
/>
</Box>
{showFooter &&
<Box className='Dialog-footer'>
@@ -237,13 +204,13 @@ export default function SchemaDialogView({
(!props.disableSqlHelp || !props.disableDialogHelp) &&
<Box>
<PgIconButton data-test='sql-help'
onClick={()=>props.onHelp(true, isNew)}
onClick={()=>props.onHelp(true, schemaState.isNew)}
icon={<InfoIcon />} disabled={props.disableSqlHelp}
className='Dialog-buttonMargin'
title={ gettext('SQL help for this object type.') }
/>
<PgIconButton data-test='dialog-help'
onClick={()=>props.onHelp(false, isNew)}
onClick={()=>props.onHelp(false, schemaState.isNew)}
icon={<HelpIcon />} disabled={props.disableDialogHelp}
title={ gettext('Help for this dialog.') }
/>
@@ -254,23 +221,21 @@ export default function SchemaDialogView({
startIcon={<CloseIcon />} className='Dialog-buttonMargin'>
{ gettext('Close') }
</DefaultButton>
<DefaultButton data-test='Reset' onClick={onResetClick}
startIcon={<SettingsBackupRestoreIcon />}
disabled={(!isDirty) || saving }
className='Dialog-buttonMargin'>
{ gettext('Reset') }
</DefaultButton>
<PrimaryButton data-test='Save' onClick={onSaveClick}
startIcon={ButtonIcon}
disabled={disableSaveBtn}>{
props.customSaveBtnName || gettext('Save')
}
</PrimaryButton>
<ResetButton
onClick={onResetClick}
icon={<SettingsBackupRestoreIcon />}
label={ gettext('Reset') }/>
<SaveButton
onClick={onSaveClick} Icon={ButtonIcon}
label={props.customSaveBtnName || gettext('Save')}
checkDirtyOnEnableSave={checkDirtyOnEnableSave}
mode={viewHelperProps.mode}
/>
</Box>
</Box>
}
</SchemaStateContext.Provider>
</StyledBox>
</StyledBox>, [schema._id, viewHelperProps.mode]
);
}

View File

@@ -7,7 +7,7 @@
//
//////////////////////////////////////////////////////////////
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo } from 'react';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import InfoIcon from '@mui/icons-material/InfoRounded';
@@ -16,179 +16,95 @@ import Box from '@mui/material/Box';
import Accordion from '@mui/material/Accordion';
import AccordionSummary from '@mui/material/AccordionSummary';
import AccordionDetails from '@mui/material/AccordionDetails';
import _ from 'lodash';
import PropTypes from 'prop-types';
import { usePgAdmin } from 'sources/BrowserComponent';
import gettext from 'sources/gettext';
import Loader from 'sources/components/Loader';
import { PgIconButton, PgButtonGroup } from 'sources/components/Buttons';
import CustomPropTypes from 'sources/custom_prop_types';
import DataGridView from './DataGridView';
import FieldSetView from './FieldSetView';
import { MappedFormControl } from './MappedControl';
import { useSchemaState } from './useSchemaState';
import { getFieldMetaData } from './common';
import { FieldControl } from './FieldControl';
import { FormLoader } from './FormLoader';
import { SchemaStateContext } from './SchemaState';
import { StyledBox } from './StyledComponents';
import { useSchemaState } from './hooks';
import { createFieldControls } from './utils';
/* If its the properties tab */
export default function SchemaPropertiesView({
getInitData, viewHelperProps, schema={}, updatedData, ...props
}) {
let defaultTab = 'General';
let tabs = {};
let tabsClassname = {};
let groupLabels = {};
const [loaderText, setLoaderText] = useState('');
const pgAdmin = usePgAdmin();
const Notifier = pgAdmin.Browser.notifier;
const { mode, keepCid } = viewHelperProps;
// Schema data state manager
const {schemaState, sessData} = useSchemaState({
const {schemaState} = useSchemaState({
schema: schema, getInitData: getInitData, immutableData: updatedData,
mode: mode, keepCid: keepCid, onDataChange: null,
viewHelperProps: viewHelperProps, onDataChange: null,
});
const [data, setData] = useState({});
useEffect(() => {
if (schemaState.errors?.response)
Notifier.pgRespErrorNotify(schemaState.errors.response);
}, [schemaState.errors?.name]);
useEffect(() => {
setData(sessData);
}, [sessData.__changeId]);
useEffect(() => {
setLoaderText(schemaState.message);
}, [schemaState.message]);
/* A simple loop to get all the controls for the fields */
schema.fields.forEach((field) => {
let {group} = field;
const {
visible, disabled, readonly, modeSupported
} = getFieldMetaData(field, schema, data, viewHelperProps);
group = group || defaultTab;
if(field.isFullTab) {
tabsClassname[group] = 'Properties-noPadding';
}
if(!modeSupported) return;
group = groupLabels[group] || group || defaultTab;
if (field.helpMessageMode?.indexOf(viewHelperProps.mode) == -1)
field.helpMessage = '';
if(!tabs[group]) tabs[group] = [];
if(field && field.type === 'nested-fieldset') {
tabs[group].push(
<FieldSetView
key={`nested${tabs[group].length}`}
value={data}
viewHelperProps={viewHelperProps}
schema={field.schema}
accessPath={[]}
controlClassName='Properties-controlRow'
{...field}
visible={visible}
/>
);
} else if(field.type === 'collection') {
tabs[group].push(
<DataGridView
key={field.id}
viewHelperProps={viewHelperProps}
name={field.id}
value={data[field.id] || []}
schema={field.schema}
accessPath={[field.id]}
containerClassName='Properties-controlRow'
canAdd={false}
canEdit={false}
canDelete={false}
visible={visible}
/>
);
} else if(field.type === 'group') {
groupLabels[field.id] = field.label;
if(!visible) {
schema.filterGroups.push(field.label);
}
} else {
tabs[group].push(
<MappedFormControl
key={field.id}
viewHelperProps={viewHelperProps}
state={sessData}
name={field.id}
value={data[field.id]}
{...field}
readonly={readonly}
disabled={disabled}
visible={visible}
className={field.isFullTab ? null :'Properties-controlRow'}
noLabel={field.isFullTab}
memoDeps={[
data[field.id],
'Properties-controlRow',
field.isFullTab
]}
/>
);
}
});
let finalTabs = _.pickBy(
tabs, (v, tabName) => schema.filterGroups.indexOf(tabName) <= -1
const finalTabs = useMemo(
() => createFieldControls({
schema, schemaState, viewHelperProps, dataDispatch: null, accessPath: []
}),
[schema._id, schemaState, viewHelperProps]
);
return (
<StyledBox>
<Loader message={loaderText}/>
<Box className='Properties-toolbar'>
<PgButtonGroup size="small">
<PgIconButton
data-test="help" onClick={() => props.onHelp(true, false)}
icon={<InfoIcon />} disabled={props.disableSqlHelp}
title="SQL help for this object type." />
<PgIconButton data-test="edit"
onClick={props.onEdit} icon={<EditIcon />}
title={gettext('Edit object...')} />
</PgButtonGroup>
</Box>
<Box className={'Properties-form'}>
<Box>
{Object.keys(finalTabs).map((tabName)=>{
let id = tabName.replace(' ', '');
return (
<Accordion key={id}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`${id}-content`}
id={`${id}-header`}
>
{tabName}
</AccordionSummary>
<AccordionDetails className={tabsClassname[tabName]}>
<Box style={{width: '100%'}}>
{finalTabs[tabName]}
</Box>
</AccordionDetails>
</Accordion>
);
})}
if (!finalTabs) return <></>;
return useMemo(
() => <StyledBox>
<SchemaStateContext.Provider value={schemaState}>
<FormLoader/>
<Box className='Properties-toolbar'>
<PgButtonGroup size="small">
<PgIconButton
data-test="help" onClick={() => props.onHelp(true, false)}
icon={<InfoIcon />} disabled={props.disableSqlHelp}
title="SQL help for this object type." />
<PgIconButton data-test="edit"
onClick={props.onEdit} icon={<EditIcon />}
title={gettext('Edit object...')} />
</PgButtonGroup>
</Box>
</Box>
</StyledBox>
<Box className={'Properties-form'}>
<Box>
{finalTabs.map((group)=>{
let id = group.id.replace(' ', '');
return (
<Accordion key={id}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`${id}-content`}
id={`${id}-header`}
>
{group.label}
</AccordionSummary>
<AccordionDetails className={group.className}>
<Box style={{width: '100%'}}>
{
group.controls.map(
(item, idx) => <FieldControl
item={item} key={idx} schemaId={schema._id}
/>
)
}
</Box>
</AccordionDetails>
</Accordion>
);
})}
</Box>
</Box>
</SchemaStateContext.Provider>
</StyledBox>,
[schema._id]
);
}

View File

@@ -0,0 +1,330 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import _ from 'lodash';
import { parseApiError } from 'sources/api_instance';
import gettext from 'sources/gettext';
import { prepareData } from '../common';
import { DepListener } from '../DepListener';
import { FIELD_OPTIONS, schemaOptionsEvalulator } from '../options';
import {
SCHEMA_STATE_ACTIONS,
flatPathGenerator,
getSchemaDataDiff,
validateSchema,
} from './common';
import { createStore } from './store';
export const LOADING_STATE = {
INIT: 'initialising',
LOADING: 'loading',
LOADED: 'loaded',
ERROR: 'Error'
};
const PATH_SEPARATOR = '/';
export class SchemaState extends DepListener {
constructor(
schema, getInitData, immutableData, onDataChange, viewHelperProps,
loadingText
) {
super();
////// Helper variables
// BaseUISchema instance
this.schema = schema;
this.viewHelperProps = viewHelperProps;
// Current mode of operation ('create', 'edit', 'properties')
this.mode = viewHelperProps.mode;
// Keep the 'cid' object during diff calculations.
this.keepcid = viewHelperProps.keepCid;
// Initialization callback
this.getInitData = getInitData;
// Data change callback
this.onDataChange = onDataChange;
////// State variables
// Diff between the current snapshot and initial data.
// Internal state for keeping the changes
this._changes = {};
// Current Loading state
this.loadingState = LOADING_STATE.INIT;
this.customLoadingText = loadingText;
////// Schema instance data
// Initial data after the ready state
this.initData = {};
// Immutable data
this.immutableData = immutableData;
// Pre-ready queue
this.preReadyQueue = [];
this.optionStore = createStore({});
this.dataStore = createStore({});
this.stateStore = createStore({
isNew: true, isDirty: false, isReady: false,
isSaving: false, errors: {},
message: '',
});
// Memoize the path using flatPathGenerator
this.__pathGenerator = flatPathGenerator(PATH_SEPARATOR);
this._id = Date.now();
}
updateOptions() {
let options = _.cloneDeep(this.optionStore.getState());
schemaOptionsEvalulator({
schema: this.schema, data: this.data, options: options,
viewHelperProps: this.viewHelperProps,
});
this.optionStore.setState(options);
}
setState(state, value) {
this.stateStore.set((prev) => _.set(prev, [].concat(state), value));
}
setError(err) {
this.setState('errors', err);
}
get errors() {
return this.stateStore.get(['errors']);
}
set errors(val) {
throw new Error('Property \'errors\' is readonly.', val);
}
get isReady() {
return this.stateStore.get(['isReady']);
}
setReady(val) {
this.setState('isReady', val);
}
get isSaving() {
return this.stateStore.get(['isSaving']);
}
set isSaving(val) {
this.setState('isSaving', val);
}
get loadingMessage() {
return this.stateStore.get(['message']);
}
setLoadingState(loadingState) {
this.loadingState = loadingState;
}
setMessage(msg) {
this.setState('message', msg);
}
// Initialise the data, and fetch the data from the backend (if required).
// 'force' flag can be used for reloading the data from the backend.
initialise(dataDispatch, force) {
let state = this;
// Don't attempt to initialize again (if it's already in progress).
if (
state.loadingState !== LOADING_STATE.INIT ||
(force && state.loadingState === LOADING_STATE.LOADING)
) return;
state.setLoadingState(LOADING_STATE.LOADING);
state.setMessage(state.customLoadingText || gettext('Loading...'));
/*
* Fetch the data using getInitData(..) callback.
* `getInitData(..)` must be present in 'edit' mode.
*/
if(state.mode === 'edit' && !state.getInitData) {
throw new Error('getInitData must be passed for edit');
}
const initDataPromise = state.getInitData?.() ||
Promise.resolve({});
initDataPromise.then((data) => {
data = data || {};
if(state.mode === 'edit') {
// Set the origData to incoming data, useful for comparing.
state.initData = prepareData({...data, ...state.immutableData});
} else {
// In create mode, merge with defaults.
state.initData = prepareData({
...state.schema.defaults, ...data, ...state.immutableData
}, true);
}
state.schema.initialise(state.initData);
dataDispatch({
type: SCHEMA_STATE_ACTIONS.INIT,
payload: state.initData,
});
state.setLoadingState(LOADING_STATE.LOADED);
state.setMessage('');
state.setReady(true);
state.setState('isNew', state.schema.isNew(state.initData));
}).catch((err) => {
state.setMessage('');
state.setError({
name: 'apierror',
response: err,
message: _.escape(parseApiError(err)),
});
state.setLoadingState(LOADING_STATE.ERROR);
state.setReady(true);
});
}
validate(sessData) {
let state = this,
schema = state.schema;
// If schema does not have the data or does not have any 'onDataChange'
// callback, there is no need to validate the current data.
if(!state.isReady) return;
if(
!validateSchema(schema, sessData, (path, message) => {
message && state.setError({
name: state.accessPath(path), message: _.escape(message)
});
})
) state.setError({});
state.data = sessData;
state._changes = state.changes();
state.updateOptions();
state.onDataChange && state.onDataChange(state.isDirty, state._changes);
}
changes(includeSkipChange=false) {
const state = this;
const sessData = state.data;
const schema = state.schema;
// Check if anything changed.
let dataDiff = getSchemaDataDiff(
schema, state.initData, sessData,
state.mode, state.keepCid, false, includeSkipChange
);
const isDirty = Object.keys(dataDiff).length > 0;
state.setState('isDirty', isDirty);
// Inform the callbacks about change in the data.
if(state.mode !== 'edit') {
// Merge the changed data with origData in 'create' mode.
dataDiff = _.assign({}, state.initData, dataDiff);
// Remove internal '__changeId' attribute.
delete dataDiff.__changeId;
// In case of 'non-edit' mode, changes are always there.
return dataDiff;
}
if (!isDirty) return {};
const idAttr = schema.idAttribute;
const idVal = state.initData[idAttr];
// Append 'idAttr' only if it actually exists
if (idVal) dataDiff[idAttr] = idVal;
return dataDiff;
}
get isNew() {
return this.stateStore.get(['isNew']);
}
set isNew(val) {
throw new Error('Property \'isNew\' is readonly.', val);
}
get isDirty() {
return this.stateStore.get(['isDirty']);
}
set isDirty(val) {
throw new Error('Property \'isDirty\' is readonly.', val);
}
get data() {
return this.dataStore.getState();
}
set data(_data) {
this.dataStore.setState(_data);
}
accessPath(path=[], key) {
return this.__pathGenerator.cached(
_.isUndefined(key) ? path : path.concat(key)
);
}
value(path) {
if (!path || !path.length) return this.data;
return _.get(this.data, path);
}
options(path) {
return this.optionStore.get(path.concat(FIELD_OPTIONS));
}
state(_state) {
return _state ?
this.stateStore.get([].concat(_state)) : this.stateStore.getState();
}
subscribe(path, listener, kind='options') {
switch(kind) {
case 'options':
return this.optionStore.subscribeForPath(
path.concat(FIELD_OPTIONS), listener
);
case 'states':
return this.stateStore.subscribeForPath(path, listener);
default:
return this.dataStore.subscribeForPath(path, listener);
}
}
subscribeOption(option, path, listener) {
return this.optionStore.subscribeForPath(
path.concat(FIELD_OPTIONS, option), listener
);
}
}

View File

@@ -11,13 +11,27 @@ import diffArray from 'diff-arrays-of-objects';
import _ from 'lodash';
import gettext from 'sources/gettext';
import { memoizeFn } from 'sources/utils';
import {
minMaxValidator, numberValidator, integerValidator, emptyValidator,
checkUniqueCol, isEmptyString
} from 'sources/validators';
import BaseUISchema from './base_schema.ui';
import { isModeSupportedByField, isObjectEqual, isValueEqual } from './common';
import BaseUISchema from '../base_schema.ui';
import { isModeSupportedByField, isObjectEqual, isValueEqual } from '../common';
export const SCHEMA_STATE_ACTIONS = {
INIT: 'init',
SET_VALUE: 'set_value',
ADD_ROW: 'add_row',
DELETE_ROW: 'delete_row',
MOVE_ROW: 'move_row',
RERENDER: 'rerender',
CLEAR_DEFERRED_QUEUE: 'clear_deferred_queue',
DEFERRED_DEPCHANGE: 'deferred_depchange',
BULK_UPDATE: 'bulk_update',
};
// Remove cid key added by prepareData
const cleanCid = (coll, keepCid=false) => (
@@ -276,9 +290,10 @@ export function validateSchema(
if(schema.idAttribute === field.id) {
continue;
}
// If the field is has nested schema, then validate the child schema.
if(field.schema && (field.schema instanceof BaseUISchema)) {
if (!field.schema.top) field.schema.top = schema;
// A collection is an array.
if(field.type === 'collection') {
if (validateCollectionSchema(field, sessData, accessPath, setError))
@@ -331,3 +346,40 @@ export function validateSchema(
sessData, (id, message) => setError(accessPath.concat(id), message)
);
}
export const getDepChange = (currPath, newState, oldState, action) => {
if(action.depChange) {
newState = action.depChange(currPath, newState, {
type: action.type,
path: action.path,
value: action.value,
oldState: _.cloneDeep(oldState),
listener: action.listener,
});
}
return newState;
};
// It will help us generating the flat path, and it will return the same
// object for the same path, which will help with the React componet rendering,
// as it uses `Object.is(...)` for the comparison of the arguments.
export const flatPathGenerator = (separator = '.' ) => {
const flatPathMap = new Map;
const setter = memoizeFn((path) => {
const flatPath = path.join(separator);
flatPathMap.set(flatPath, path);
return flatPath;
});
const getter = (flatPath) => {
return flatPathMap.get(flatPath);
};
return {
flatPath: setter,
path: getter,
// Get the same object every time.
cached: (path) => (getter(setter(path))),
};
};

View File

@@ -0,0 +1,12 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
export const SchemaStateContext = React.createContext();

View File

@@ -0,0 +1,21 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { SchemaState } from './SchemaState';
import { SchemaStateContext } from './context';
import { SCHEMA_STATE_ACTIONS } from './common';
import { sessDataReducer } from './reducer';
export {
SCHEMA_STATE_ACTIONS,
SchemaState,
SchemaStateContext,
sessDataReducer,
};

View File

@@ -0,0 +1,123 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import _ from 'lodash';
import {
SCHEMA_STATE_ACTIONS, getDepChange,
} from './common';
const getDeferredDepChange = (currPath, newState, oldState, action) => {
if(action.deferredDepChange) {
return action.deferredDepChange(currPath, newState, {
type: action.type,
path: action.path,
value: action.value,
depChange: action.depChange,
oldState: _.cloneDeep(oldState),
});
}
};
/*
* 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 '[]'.
*/
export const sessDataReducer = (state, action) => {
let data = _.cloneDeep(state);
let rows, cid, deferredList;
data.__deferred__ = data.__deferred__ || [];
switch(action.type) {
case SCHEMA_STATE_ACTIONS.INIT:
data = action.payload;
break;
case SCHEMA_STATE_ACTIONS.BULK_UPDATE:
rows = _.get(data, action.path) || [];
rows.forEach((row) => { row[action.id] = false; });
_.set(data, action.path, rows);
break;
case SCHEMA_STATE_ACTIONS.SET_VALUE:
_.set(data, action.path, action.value);
// If there is any dep listeners get the changes.
data = getDepChange(action.path, data, state, action);
deferredList = getDeferredDepChange(action.path, data, state, action);
data.__deferred__ = deferredList || [];
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;
if (action.addOnTop) {
rows = [].concat(action.value).concat(_.get(data, action.path) || []);
} else {
rows = (_.get(data, action.path) || []).concat(action.value);
}
_.set(data, action.path, rows);
// If there is any dep listeners get the changes.
data = getDepChange(action.path, data, state, action);
break;
case SCHEMA_STATE_ACTIONS.DELETE_ROW:
rows = _.get(data, action.path)||[];
rows.splice(action.value, 1);
_.set(data, action.path, rows);
// If there is any dep listeners get the changes.
data = getDepChange(action.path, data, state, action);
break;
case SCHEMA_STATE_ACTIONS.MOVE_ROW:
rows = _.get(data, action.path)||[];
var row = rows[action.oldIndex];
rows.splice(action.oldIndex, 1);
rows.splice(action.newIndex, 0, row);
_.set(data, action.path, rows);
break;
case SCHEMA_STATE_ACTIONS.CLEAR_DEFERRED_QUEUE:
data.__deferred__ = [];
return data;
case SCHEMA_STATE_ACTIONS.DEFERRED_DEPCHANGE:
data = getDepChange(action.path, data, state, action);
break;
}
data.__changeId = (data.__changeId || 0) + 1;
return data;
};

View File

@@ -0,0 +1,80 @@
import _ from 'lodash';
import { isValueEqual } from '../common';
import { flatPathGenerator } from './common';
export const createStore = (initialState) => {
let state = initialState;
const listeners = new Set();
const gen = flatPathGenerator('/');
const pathListeners = new Set();
// Exposed functions
// Don't attempt to manipulate the state directly.
const getState = () => state;
const setState = (nextState) => {
const prevState = state;
state = _.clone(nextState);
if (isValueEqual(state, prevState)) return;
listeners.forEach((listener) => {
listener();
});
const changeMemo = new Map();
pathListeners.forEach((pathListener) => {
const [ path, listener ] = pathListener;
const flatPath = gen.flatPath(path);
if (!changeMemo.has(flatPath)) {
const pathNextValue =
flatPath == '' ? nextState : _.get(nextState, path, undefined);
const pathPrevValue =
flatPath == '' ? prevState : _.get(prevState, path, undefined);
changeMemo.set(flatPath, [
isValueEqual(pathNextValue, pathPrevValue),
pathNextValue,
pathPrevValue,
]);
}
const [isSame, pathNextValue, pathPrevValue] = changeMemo.get(flatPath);
if (!isSame) {
listener(pathNextValue, pathPrevValue);
}
});
};
const get = (path = []) => (_.get(state, path));
const set = (arg) => {
let nextState = _.isFunction(arg) ? arg(_.cloneDeep(state)) : arg;
setState(nextState);
};
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const subscribeForPath = (path, listner) => {
const data = [path, listner];
pathListeners.add(data);
return () => {
return pathListeners.delete(data);
};
};
return {
getState,
setState,
get,
set,
subscribe,
subscribeForPath,
};
};

View File

@@ -15,6 +15,7 @@ import ErrorBoundary from 'sources/helpers/ErrorBoundary';
import SchemaDialogView from './SchemaDialogView';
import SchemaPropertiesView from './SchemaPropertiesView';
import { registerView } from './registry';
export default function SchemaView({formType, ...props}) {
@@ -32,3 +33,5 @@ export default function SchemaView({formType, ...props}) {
SchemaView.propTypes = {
formType: PropTypes.oneOf(['tab', 'dialog']),
};
registerView(SchemaView, 'SchemaView');

View File

@@ -41,7 +41,7 @@ export const StyledBox = styled(Box)(({theme}) => ({
padding: theme.spacing(1),
overflow: 'auto',
flexGrow: 1,
'& .Properties-controlRow': {
'& .Properties-controlRow:not(:last-child)': {
marginBottom: theme.spacing(1),
},
},

View File

@@ -9,6 +9,8 @@
import _ from 'lodash';
import { memoizeFn } from 'sources/utils';
/* This is the base schema class for SchemaView.
* A UI schema must inherit this to use SchemaView for UI.
*/
@@ -63,11 +65,11 @@ export default class BaseUISchema {
/*
* The session data, can be useful but setting this will not affect UI.
* this._sessData is set by SchemaView directly. set sessData should not be
* this.sessData is set by SchemaView directly. set sessData should not be
* allowed anywhere.
*/
get sessData() {
return this._sessData || {};
return this.state?.data;
}
set sessData(val) {
@@ -93,19 +95,28 @@ export default class BaseUISchema {
concat base fields with extraFields.
*/
get fields() {
return this.baseFields
.filter((field)=>{
let retval;
if (!this.__filteredFields) {
// Memoize the results
this.__filteredFields = memoizeFn(
(baseFields, keys, filterGroups) => baseFields.filter((field) => {
let retval;
/* If any groups are to be filtered */
retval = this.filterGroups.indexOf(field.group) == -1;
// If any groups are to be filtered.
retval = filterGroups.indexOf(field.group) == -1;
/* Select only keys, if specified */
if(this.keys) {
retval = retval && this.keys.indexOf(field.id) > -1;
}
return retval;
});
// Select only keys, if specified.
if(retval && keys) {
retval = keys.indexOf(field.id) > -1;
}
return retval;
})
);
}
return this.__filteredFields(
this.baseFields, this.keys, this.filterGroups
);
}
initialise() {
@@ -190,4 +201,8 @@ export default class BaseUISchema {
}
return res;
}
toJSON() {
return this._id;
}
}

View File

@@ -7,32 +7,16 @@
//
//////////////////////////////////////////////////////////////
import React from 'react';
import { evalFunc } from 'sources/utils';
export const SCHEMA_STATE_ACTIONS = {
INIT: 'init',
SET_VALUE: 'set_value',
ADD_ROW: 'add_row',
DELETE_ROW: 'delete_row',
MOVE_ROW: 'move_row',
RERENDER: 'rerender',
CLEAR_DEFERRED_QUEUE: 'clear_deferred_queue',
DEFERRED_DEPCHANGE: 'deferred_depchange',
BULK_UPDATE: 'bulk_update',
};
export const SchemaStateContext = React.createContext();
export function generateTimeBasedRandomNumberString() {
return new Date().getTime() + '' + Math.floor(Math.random() * 1000001);
}
export function isModeSupportedByField(field, helperProps) {
if (!field || !field.mode) return true;
return (field.mode.indexOf(helperProps.mode) > -1);
}
export const isModeSupportedByField = (field, helperProps) => (
!field.mode || field.mode.indexOf(helperProps.mode) > -1
);
export function getFieldMetaData(
field, schema, value, viewHelperProps
@@ -81,13 +65,14 @@ export function getFieldMetaData(
retData.editable = !(
viewHelperProps.inCatalog || (viewHelperProps.mode == 'properties')
);
if(retData.editable) {
retData.editable = evalFunc(
schema, (_.isUndefined(editable) ? true : editable), value
);
}
let {canAdd, canEdit, canDelete, canReorder, canAddRow } = field;
let {canAdd, canEdit, canDelete, canAddRow } = field;
retData.canAdd =
_.isUndefined(canAdd) ? retData.canAdd : evalFunc(schema, canAdd, value);
retData.canAdd = !retData.disabled && retData.canAdd;
@@ -99,10 +84,6 @@ export function getFieldMetaData(
schema, canDelete, value
);
retData.canDelete = !retData.disabled && retData.canDelete;
retData.canReorder =
_.isUndefined(canReorder) ? retData.canReorder : evalFunc(
schema, canReorder, value
);
retData.canAddRow =
_.isUndefined(canAddRow) ? retData.canAddRow : evalFunc(
schema, canAddRow, value
@@ -165,3 +146,38 @@ export function getForQueryParams(data) {
});
return retData;
}
export function prepareData(val, createMode=false) {
if(_.isPlainObject(val)) {
_.forIn(val, function (el) {
if (_.isObject(el)) {
prepareData(el, createMode);
}
});
} else if(_.isArray(val)) {
val.forEach(function(el) {
if (_.isPlainObject(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'] = createMode ? _.uniqueId('c') : _.uniqueId('nn');
prepareData(el, createMode);
}
});
}
return val;
}
export const flatternObject = (obj, base=[]) => Object.keys(obj).sort().reduce(
(r, k) => {
r = r.concat(k);
const value = obj[k];
if (_.isFunction(value)) return r;
if (_.isArray(value)) return r.concat(...value);
if (_.isPlainObject(value)) return flatternObject(value, r);
return r.concat(value);
}, base
);

View File

@@ -0,0 +1,23 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { useFieldError } from './useFieldError';
import { useFieldOptions } from './useFieldOptions';
import { useFieldValue } from './useFieldValue';
import { useSchemaState } from './useSchemaState';
import { useFieldSchema } from './useFieldSchema';
export {
useFieldError,
useFieldOptions,
useFieldValue,
useFieldSchema,
useSchemaState,
};

View File

@@ -0,0 +1,34 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { useEffect } from 'react';
export const useFieldError = (
path, schemaState, key, setRefreshKey
) => {
useEffect(() => {
if (!schemaState || !setRefreshKey) return;
const checkPathError = (newState, prevState) => {
if (prevState.name !== path && newState.name !== path) return;
// We don't need to redraw the control on message change.
if (prevState.name === newState.name) return;
setRefreshKey({id: Date.now()});
};
return schemaState.subscribe(['errors'], checkPathError, 'states');
}, [key, schemaState?._id]);
const errors = schemaState?.errors || {};
const error = errors.name === path ? errors.message : null;
return {hasError: !_.isNull(error), error};
};

View File

@@ -0,0 +1,25 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { useEffect } from 'react';
export const useFieldOptions = (
path, schemaState, key, setRefreshKey
) => {
useEffect(() => {
if (!schemaState) return;
return schemaState.subscribe(
path, () => setRefreshKey?.({id: Date.now()}), 'options'
);
}, [key, schemaState?._id]);
return schemaState?.options(path) || {visible: true};
};

View File

@@ -0,0 +1,56 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import _ from 'lodash';
import { useEffect } from 'react';
import { booleanEvaluator } from '../options';
export const useFieldSchema = (
field, accessPath, value, viewHelperProps, schemaState, key, setRefreshKey
) => {
useEffect(() => {
if (!schemaState || !field) return;
// It already has 'id', 'options' is already evaluated.
if (field.id)
return schemaState.subscribe(
accessPath, () => setRefreshKey?.({id: Date.now()}), 'options'
);
// There are no dependencies.
if (!_.isArray(field?.deps)) return;
// Subscribe to all the dependents.
const unsubscribers = field.deps.map((dep) => (
schemaState.subscribe(
accessPath.concat(dep), () => setRefreshKey?.({id: Date.now()}),
'value'
)
));
return () => {
unsubscribers.forEach(unsubscribe => unsubscribe());
};
}, [key, schemaState?._id]);
if (!field) return { visible: true };
if (field.id) return schemaState?.options(accessPath);
if (!field.schema) return { visible: true };
value = value || {};
return {
visible: booleanEvaluator({
schema: field.schema, field, option: 'visible', value, viewHelperProps,
defaultVal: true,
}),
};
};

View File

@@ -0,0 +1,25 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { useEffect } from 'react';
export const useFieldValue = (
path, schemaState, key, setRefreshKey
) => {
useEffect(() => {
if (!schemaState || !setRefreshKey) return;
return schemaState.subscribe(
path, () => setRefreshKey({id: Date.now()}), 'value'
);
}, [key, schemaState?._id]);
return schemaState?.value(path);
};

View File

@@ -0,0 +1,143 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { useEffect, useReducer } from 'react';
import _ from 'lodash';
import { prepareData } from '../common';
import {
SCHEMA_STATE_ACTIONS,
SchemaState,
sessDataReducer,
} from '../SchemaState';
export const useSchemaState = ({
schema, getInitData, immutableData, onDataChange, viewHelperProps,
loadingText,
}) => {
if (!schema)
return {
schemaState: null,
dataDispatch: null,
reset: null,
};
let state = schema.state;
if (!state) {
schema.state = state = new SchemaState(
schema, getInitData, immutableData, onDataChange, viewHelperProps,
loadingText,
);
state.updateOptions();
}
const [sessData, sessDispatch] = useReducer(
sessDataReducer, {...(_.cloneDeep(state.data)), __changeId: 0}
);
const sessDispatchWithListener = (action) => {
let dispatchPayload = {
...action,
depChange: (...args) => state.getDepChange(...args),
deferredDepChange: (...args) => state.getDeferredDepChange(...args),
};
/*
* All the session changes coming before init should be queued up.
* They will be processed later when form is ready.
*/
let preReadyQueue = state.preReadyQueue;
preReadyQueue ?
preReadyQueue.push(dispatchPayload) :
sessDispatch(dispatchPayload);
};
state.setUnpreparedData = (path, value) => {
if(path) {
let data = prepareData(value);
_.set(schema.initData, path, data);
sessDispatchWithListener({
type: SCHEMA_STATE_ACTIONS.SET_VALUE,
path: path,
value: data,
});
}
};
const resetData = () => {
const initData = _.cloneDeep(state.initData);
initData.__changeId = sessData.__changeId;
sessDispatch({
type: SCHEMA_STATE_ACTIONS.INIT,
payload: initData,
});
};
const reload = () => {
state.initialise(sessDispatch, true);
};
useEffect(() => {
state.initialise(sessDispatch);
}, [state.loadingState]);
useEffect(() => {
let preReadyQueue = state.preReadyQueue;
if (!state.isReady || !preReadyQueue) return;
for (const payload of preReadyQueue) {
sessDispatch(payload);
}
// Destroy the queue so that no one uses it.
state.preReadyQueue = null;
}, [state.isReady]);
useEffect(() => {
// Validate the schema on the change of the data.
if (state.isReady) state.validate(sessData);
}, [state.isReady, sessData.__changeId]);
useEffect(() => {
const items = sessData.__deferred__ || [];
if (items.length == 0) return;
sessDispatch({
type: SCHEMA_STATE_ACTIONS.CLEAR_DEFERRED_QUEUE,
});
items.forEach((item) => {
item.promise.then((resFunc) => {
sessDispatch({
type: SCHEMA_STATE_ACTIONS.DEFERRED_DEPCHANGE,
path: item.action.path,
depChange: item.action.depChange,
listener: {
...item.listener,
callback: resFunc,
},
});
});
});
}, [sessData.__deferred__?.length]);
state.reload = reload;
state.reset = resetData;
return {
schemaState: state,
dataDispatch: sessDispatchWithListener,
reset: resetData,
};
};

View File

@@ -7,43 +7,46 @@
//
//////////////////////////////////////////////////////////////
import BaseUISchema from './base_schema.ui';
import DataGridView from './DataGridView';
import FieldSetView from './FieldSetView';
import FormView from './FormView';
import InlineView from './InlineView';
import SchemaDialogView from './SchemaDialogView';
import SchemaPropertiesView from './SchemaPropertiesView';
import SchemaView from './SchemaView';
import BaseUISchema from './base_schema.ui';
import { useSchemaState } from './useSchemaState';
import { useSchemaState, useFieldState } from './hooks';
import {
generateTimeBasedRandomNumberString,
isValueEqual,
isObjectEqual,
getForQueryParams,
prepareData,
} from './common';
import {
SCHEMA_STATE_ACTIONS,
SchemaStateContext,
generateTimeBasedRandomNumberString,
isModeSupportedByField,
getFieldMetaData,
isValueEqual,
isObjectEqual,
getForQueryParams
} from './common';
} from './SchemaState';
export default SchemaView;
export {
SCHEMA_STATE_ACTIONS,
BaseUISchema,
DataGridView,
FieldSetView,
FormView,
InlineView,
SchemaDialogView,
SchemaPropertiesView,
SchemaView,
BaseUISchema,
useSchemaState,
SCHEMA_STATE_ACTIONS,
SchemaStateContext,
getForQueryParams,
generateTimeBasedRandomNumberString,
isModeSupportedByField,
getFieldMetaData,
isValueEqual,
isObjectEqual,
getForQueryParams
prepareData,
useFieldState,
useSchemaState,
};

View File

@@ -0,0 +1,40 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import _ from 'lodash';
import { evalFunc } from 'sources/utils';
export const FIELD_OPTIONS = '__fieldOptions';
export const booleanEvaluator = ({
schema, field, option, value, viewHelperProps, options, defaultVal,
}) => (
_.isUndefined(field?.[option]) ? defaultVal :
Boolean(evalFunc(schema, field[option], value, viewHelperProps, options))
);
export const evalIfNotDisabled = ({ options, ...params }) => (
!options.disabled &&
booleanEvaluator({ options, ...params })
);
export const canAddOrDelete = ({
options, viewHelperProps, field, ...params
}) => (
viewHelperProps?.mode != 'properties' &&
!(field?.fixedRow) &&
!options.disabled &&
booleanEvaluator({ options, viewHelperProps, field, ...params })
);
export const evalInNonPropertyMode = ({ viewHelperProps, ...params }) => (
viewHelperProps?.mode != 'properties' &&
booleanEvaluator({ viewHelperProps, ...params })
);

View File

@@ -0,0 +1,176 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { evalFunc } from 'sources/utils';
import {
booleanEvaluator,
canAddOrDelete,
evalIfNotDisabled,
evalInNonPropertyMode,
FIELD_OPTIONS
} from './common';
import {
evaluateFieldOptions,
evaluateFieldsOption,
registerOptionEvaluator,
schemaOptionsEvalulator,
} from './registry';
export {
FIELD_OPTIONS,
booleanEvaluator,
canAddOrDelete,
evaluateFieldOptions,
evaluateFieldsOption,
evalIfNotDisabled,
registerOptionEvaluator,
schemaOptionsEvalulator,
};
const VISIBLE = 'visible';
// Default evaluators
// 1. disabled
// 2. visible (It also checks for the supported mode)
// 3. readonly
registerOptionEvaluator('disabled');
registerOptionEvaluator(
VISIBLE,
// Evaluator
({schema, field, value, viewHelperProps}) => (
(
!field.mode || field.mode.indexOf(viewHelperProps.mode) > -1
) && (
// serverInfo not found
_.isUndefined(viewHelperProps.serverInfo) ||
// serverInfo found and it's within range
((
_.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)
))
) && (
_.isUndefined(field[VISIBLE]) ? true :
Boolean(evalFunc(schema, field[VISIBLE], value))
)),
);
registerOptionEvaluator(
'readonly',
// Evaluator
({viewHelperProps, ...args}) => (
viewHelperProps.inCatalog ||
viewHelperProps.mode === 'properties' ||
booleanEvaluator({viewHelperProps, ...args })
),
// Default value
false
);
// Collection evaluators
// 1. canAdd
// 2. canEdit
// 3. canAddRow
// 4. expandEditOnAdd
// 5. addOnTop
// 6. canSearch
registerOptionEvaluator(
'canAdd',
// Evaluator
canAddOrDelete,
// Default value
true,
['collection']
);
registerOptionEvaluator(
'canEdit',
// Evaluator
({viewHelperProps, options, ...args}) => (
!viewHelperProps.inCatalog &&
viewHelperProps.mode !== 'properties' &&
!options.disabled &&
booleanEvaluator({viewHelperProps, options, ...args })
),
// Default value
false,
['collection']
);
registerOptionEvaluator(
'canAddRow',
// Evaluator
({options, ...args}) => (
options.canAdd &&
booleanEvaluator({options, ...args })
),
// Default value
true,
['collection']
);
registerOptionEvaluator(
'expandEditOnAdd',
// Evaluator
evalInNonPropertyMode,
// Default value
false,
['collection']
);
registerOptionEvaluator(
'addOnTop',
// Evaluator
evalInNonPropertyMode,
// Default value
false,
['collection']
);
registerOptionEvaluator(
'canSearch',
// Evaluator
evalInNonPropertyMode,
// Default value
false,
['collection']
);
// Row evaluators
// 1. canEditRow
registerOptionEvaluator(
'canEditRow',
// Evaluator
evalInNonPropertyMode,
// Default value
true,
['row']
);
// Grid cell evaluatiors
// 1. editable
registerOptionEvaluator(
'editable',
// Evaluator
({viewHelperProps, ...args}) => (
!viewHelperProps.inCatalog &&
viewHelperProps.mode !== 'properties' &&
booleanEvaluator({viewHelperProps, ...args })
),
// Default value
true,
['cell']
);

View File

@@ -0,0 +1,156 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import _ from 'lodash';
import { isModeSupportedByField } from '../common';
import { FIELD_OPTIONS, booleanEvaluator } from './common';
const COMMON_OPTIONS = '__common';
const _optionEvaluators = { };
export function registerOptionEvaluator(option, evaluator, defaultVal, types) {
types = types || [COMMON_OPTIONS];
evaluator = evaluator || booleanEvaluator;
defaultVal = _.isUndefined(defaultVal) ? false : defaultVal;
types.forEach((type) => {
const evaluators = _optionEvaluators[type] =
(_optionEvaluators[type] || []);
evaluators.push([option, evaluator, defaultVal]);
});
}
export function evaluateFieldOption({
option, schema, value, viewHelperProps, field, options, parentOptions,
}) {
if (option && option in _optionEvaluators) {
const evaluators = _optionEvaluators[option];
evaluators?.forEach(([option, evaluator, defaultVal]) => {
options[option] = evaluator({
schema, field, option, value, viewHelperProps, options, defaultVal,
parentOptions
});
});
}
}
export function evaluateFieldOptions({
schema, value, viewHelperProps, field, options={}, parentOptions=null
}) {
evaluateFieldOption({
option: COMMON_OPTIONS, schema, value, viewHelperProps, field, options,
parentOptions
});
evaluateFieldOption({
option: field.type, schema, value, viewHelperProps, field, options,
parentOptions
});
}
export function schemaOptionsEvalulator({
schema, data, accessPath=[], viewHelperProps, options, parentOptions=null,
inGrid=false
}) {
schema?.fields?.forEach((field) => {
// We could have multiple entries of same `field.id` for each mode, hence -
// we should process the options only if the current field is support for
// the given mode.
if (!isModeSupportedByField(field, viewHelperProps)) return;
switch (field.type) {
case 'nested-tab':
case 'nested-fieldset':
case 'inline-groups':
{
if (!field.schema) return;
if (!field.schema.top) field.schema.top = schema.top || schema;
const path = field.id ? [...accessPath, field.id] : accessPath;
schemaOptionsEvalulator({
schema: field.schema, data, path, viewHelperProps, options,
parentOptions
});
}
break;
case 'collection':
{
if (!field.schema) return;
if (!field.schema.top) field.schema.top = schema.top || schema;
const fieldPath = [...accessPath, field.id];
const fieldOptionsPath = [...fieldPath, FIELD_OPTIONS];
const fieldOptions = _.get(options, fieldOptionsPath, {});
const rows = data[field.id];
evaluateFieldOptions({
schema, value: data, viewHelperProps, field,
options: fieldOptions, parentOptions,
});
_.set(options, fieldOptionsPath, fieldOptions);
const rowIndexes = [FIELD_OPTIONS];
rows?.forEach((row, idx) => {
const schemaPath = [...fieldPath, idx];
const schemaOptions = _.get(options, schemaPath, {});
_.set(options, schemaPath, schemaOptions);
schemaOptionsEvalulator({
schema: field.schema, data: row, accessPath: [],
viewHelperProps, options: schemaOptions,
parentOptions: fieldOptions, inGrid: true
});
const rowPath = [...schemaPath, FIELD_OPTIONS];
const rowOptions = _.get(options, rowPath, {});
_.set(options, rowPath, rowOptions);
evaluateFieldOption({
option: 'row', schema: field.schema, value: row, viewHelperProps,
field, options: rowOptions, parentOptions: fieldOptions
});
rowIndexes.push(idx);
});
}
break;
default:
{
const fieldPath = [...accessPath, field.id];
const fieldOptionsPath = [...fieldPath, FIELD_OPTIONS];
const fieldOptions = _.get(options, fieldOptionsPath, {});
evaluateFieldOptions({
schema, value: data, viewHelperProps, field, options: fieldOptions,
parentOptions,
});
if (inGrid) {
evaluateFieldOption({
option: 'cell', schema, value: data, viewHelperProps, field,
options: fieldOptions, parentOptions,
});
}
_.set(options, fieldOptionsPath, fieldOptions);
}
break;
}
});
}

View File

@@ -0,0 +1,42 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
/*
* Using the factory pattern (registry) to avoid circular imports of the views.
*/
const _views = {};
export function registerView(viewFunc, name) {
name = name || viewFunc.name;
if (name in _views) {
throw new Error(
`View type '${name}' is alredy registered.`
);
}
if (typeof viewFunc !== 'function') {
throw new Error(
`View '${name}' must be a function.`
);
}
_views[name] = viewFunc;
}
export function View(name) {
const view = _views[name];
if (view) return view;
throw new Error(`View ${name} is not found in the registry.`);
}
export function hasView(name) {
return (name in _views);
}

View File

@@ -1,489 +0,0 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useEffect, useReducer } from 'react';
import _ from 'lodash';
import { parseApiError } from 'sources/api_instance';
import gettext from 'sources/gettext';
import { DepListener } from './DepListener';
import {
getSchemaDataDiff,
validateSchema,
} from './schemaUtils';
export const SchemaStateContext = React.createContext();
export const SCHEMA_STATE_ACTIONS = {
INIT: 'init',
SET_VALUE: 'set_value',
ADD_ROW: 'add_row',
DELETE_ROW: 'delete_row',
MOVE_ROW: 'move_row',
RERENDER: 'rerender',
CLEAR_DEFERRED_QUEUE: 'clear_deferred_queue',
DEFERRED_DEPCHANGE: 'deferred_depchange',
BULK_UPDATE: 'bulk_update',
};
const getDepChange = (currPath, newState, oldState, action) => {
if(action.depChange) {
newState = action.depChange(currPath, newState, {
type: action.type,
path: action.path,
value: action.value,
oldState: _.cloneDeep(oldState),
listener: action.listener,
});
}
return newState;
};
const getDeferredDepChange = (currPath, newState, oldState, action) => {
if(action.deferredDepChange) {
return action.deferredDepChange(currPath, newState, {
type: action.type,
path: action.path,
value: action.value,
depChange: action.depChange,
oldState: _.cloneDeep(oldState),
});
}
};
/*
* 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, deferredList;
data.__deferred__ = data.__deferred__ || [];
switch(action.type) {
case SCHEMA_STATE_ACTIONS.INIT:
data = action.payload;
break;
case SCHEMA_STATE_ACTIONS.BULK_UPDATE:
rows = (_.get(data, action.path)||[]);
rows.forEach((row) => { row[action.id] = false; });
_.set(data, action.path, rows);
break;
case SCHEMA_STATE_ACTIONS.SET_VALUE:
_.set(data, action.path, action.value);
// If there is any dep listeners get the changes.
data = getDepChange(action.path, data, state, action);
deferredList = getDeferredDepChange(action.path, data, state, action);
data.__deferred__ = deferredList || [];
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;
if (action.addOnTop) {
rows = [].concat(action.value).concat(_.get(data, action.path)||[]);
} else {
rows = (_.get(data, action.path)||[]).concat(action.value);
}
_.set(data, action.path, rows);
// If there is any dep listeners get the changes.
data = getDepChange(action.path, data, state, action);
break;
case SCHEMA_STATE_ACTIONS.DELETE_ROW:
rows = _.get(data, action.path)||[];
rows.splice(action.value, 1);
_.set(data, action.path, rows);
// If there is any dep listeners get the changes.
data = getDepChange(action.path, data, state, action);
break;
case SCHEMA_STATE_ACTIONS.MOVE_ROW:
rows = _.get(data, action.path)||[];
var row = rows[action.oldIndex];
rows.splice(action.oldIndex, 1);
rows.splice(action.newIndex, 0, row);
_.set(data, action.path, rows);
break;
case SCHEMA_STATE_ACTIONS.CLEAR_DEFERRED_QUEUE:
data.__deferred__ = [];
return data;
case SCHEMA_STATE_ACTIONS.DEFERRED_DEPCHANGE:
data = getDepChange(action.path, data, state, action);
break;
}
data.__changeId = (data.__changeId || 0) + 1;
return data;
};
function prepareData(val, createMode=false) {
if(_.isPlainObject(val)) {
_.forIn(val, function (el) {
if (_.isObject(el)) {
prepareData(el, createMode);
}
});
} else if(_.isArray(val)) {
val.forEach(function(el) {
if (_.isPlainObject(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'] = createMode ? _.uniqueId('c') : _.uniqueId('nn');
prepareData(el, createMode);
}
});
}
return val;
}
const LOADING_STATE = {
INIT: 'initializing',
LOADING: 'loading',
LOADED: 'loaded',
ERROR: 'Error'
};
export class SchemaState extends DepListener {
constructor(
schema, getInitData, immutableData, mode, keepCid, onDataChange
) {
super();
////// Helper variables
// BaseUISchema instance
this.schema = schema;
// Current mode of operation ('create', 'edit', 'properties')
this.mode = mode;
// Keep the 'cid' object during diff calculations.
this.keepcid = keepCid;
// Initialization callback
this.getInitData = getInitData;
// Data change callback
this.onDataChange = onDataChange;
////// State variables
// Is is ready to be consumed?
this.isReady = false;
// Diff between the current snapshot and initial data.
this.changes = null;
// Loading message (if any)
this.message = null;
// Current Loading state
this.loadingState = LOADING_STATE.INIT;
this.hasChanges = false;
////// Schema instance data
// Initial data after the ready state
this.initData = {};
// Current state of the data
this.data = {};
// Immutable data
this.immutableData = immutableData;
// Current error
this.errors = {};
// Pre-ready queue
this.preReadyQueue = [];
this._id = Date.now();
}
setError(err) {
this.errors = err;
}
setReady(state) {
this.isReady = state;
}
setLoadingState(loadingState) {
this.loadingState = loadingState;
}
setLoadingMessage(msg) {
this.message = msg;
}
// Initialise the data, and fetch the data from the backend (if required).
// 'force' flag can be used for reloading the data from the backend.
initialise(dataDispatch, force) {
let state = this;
// Don't attempt to initialize again (if it's already in progress).
if (
state.loadingState !== LOADING_STATE.INIT ||
(force && state.loadingState === LOADING_STATE.LOADING)
) return;
state.setLoadingState(LOADING_STATE.LOADING);
state.setLoadingMessage(gettext('Loading...'));
/*
* Fetch the data using getInitData(..) callback.
* `getInitData(..)` must be present in 'edit' mode.
*/
if(state.mode === 'edit' && !state.getInitData) {
throw new Error('getInitData must be passed for edit');
}
const initDataPromise = state.getInitData?.() ||
Promise.resolve({});
initDataPromise.then((data) => {
data = data || {};
if(state.mode === 'edit') {
// Set the origData to incoming data, useful for comparing.
state.initData = prepareData({...data, ...state.immutableData});
} else {
// In create mode, merge with defaults.
state.initData = prepareData({
...state.schema.defaults, ...data, ...state.immutableData
}, true);
}
state.schema.initialise(state.initData);
dataDispatch({
type: SCHEMA_STATE_ACTIONS.INIT,
payload: state.initData,
});
state.setLoadingState(LOADING_STATE.LOADED);
state.setLoadingMessage('');
state.setReady(true);
}).catch((err) => {
state.setLoadingMessage('');
state.setError({
name: 'apierror',
response: err,
message: _.escape(parseApiError(err)),
});
state.setLoadingState(LOADING_STATE.ERROR);
state.setReady(true);
});
}
validate(sessData) {
let state = this,
schema = state.schema;
// If schema does not have the data or does not have any 'onDataChange'
// callback, there is no need to validate the current data.
if(!state.isReady) return;
if(
!validateSchema(schema, sessData, (path, message) => {
message && state.setError({ name: path, message: _.escape(message) });
})
) state.setError({});
state.data = sessData;
state.changes = state.Changes();
state.onDataChange && state.onDataChange(state.hasChanges, state.changes);
}
Changes(includeSkipChange=false) {
const state = this;
const sessData = this.data;
const schema = state.schema;
// Check if anything changed.
let dataDiff = getSchemaDataDiff(
schema, state.initData, sessData,
state.mode, state.keepCid, false, includeSkipChange
);
state.hasChanges = Object.keys(dataDiff).length > 0;
// Inform the callbacks about change in the data.
if(state.mode !== 'edit') {
// Merge the changed data with origData in 'create' mode.
dataDiff = _.assign({}, state.initData, dataDiff);
// Remove internal '__changeId' attribute.
delete dataDiff.__changeId;
// In case of 'non-edit' mode, changes are always there.
return dataDiff;
} else if (state.hasChanges) {
const idAttr = schema.idAttribute;
const idVal = state.initData[idAttr];
// Append 'idAttr' only if it actually exists
if (idVal) dataDiff[idAttr] = idVal;
return dataDiff;
}
return {};
}
get isNew() {
return this.schema.isNew(this.initData);
}
set isNew(val) {
throw new Error('Property \'isNew\' is readonly.', val);
}
get isDirty() {
return this.hasChanges;
}
set isDirty(val) {
throw new Error('Property \'isDirty\' is readonly.', val);
}
}
export const useSchemaState = ({
schema, getInitData, immutableData, mode, keepCid, onDataChange,
}) => {
let schemaState = schema.state;
if (!schemaState) {
schemaState = new SchemaState(
schema, getInitData, immutableData, mode, keepCid, onDataChange
);
schema.state = schemaState;
}
const [sessData, sessDispatch] = useReducer(
sessDataReducer, {...(_.cloneDeep(schemaState.data)), __changeId: 0}
);
const sessDispatchWithListener = (action) => {
let dispatchPayload = {
...action,
depChange: (...args) => schemaState.getDepChange(...args),
deferredDepChange: (...args) => schemaState.getDeferredDepChange(...args),
};
/*
* All the session changes coming before init should be queued up.
* They will be processed later when form is ready.
*/
let preReadyQueue = schemaState.preReadyQueue;
preReadyQueue ?
preReadyQueue.push(dispatchPayload) :
sessDispatch(dispatchPayload);
};
schemaState.setUnpreparedData = (path, value) => {
if(path) {
let data = prepareData(value);
_.set(schema.initData, path, data);
sessDispatchWithListener({
type: SCHEMA_STATE_ACTIONS.SET_VALUE,
path: path,
value: data,
});
}
};
const resetData = () => {
const initData = _.cloneDeep(schemaState.initData);
initData.__changeId = sessData.__changeId;
sessDispatch({
type: SCHEMA_STATE_ACTIONS.INIT,
payload: initData,
});
};
const reload = () => {
schemaState.initialise(sessDispatch, true);
};
useEffect(() => {
schemaState.initialise(sessDispatch);
}, [schemaState.loadingState]);
useEffect(() => {
let preReadyQueue = schemaState.preReadyQueue;
if (!schemaState.isReady || !preReadyQueue) return;
for (const payload of preReadyQueue) {
sessDispatch(payload);
}
// Destroy the queue so that no one uses it.
schemaState.preReadyQueue = null;
}, [schemaState.isReady]);
useEffect(() => {
// Validate the schema on the change of the data.
schemaState.validate(sessData);
}, [schemaState.isReady, sessData.__changeId]);
useEffect(() => {
const items = sessData.__deferred__ || [];
if (items.length == 0) return;
sessDispatch({
type: SCHEMA_STATE_ACTIONS.CLEAR_DEFERRED_QUEUE,
});
items.forEach((item) => {
item.promise.then((resFunc) => {
sessDispatch({
type: SCHEMA_STATE_ACTIONS.DEFERRED_DEPCHANGE,
path: item.action.path,
depChange: item.action.depChange,
listener: {
...item.listener,
callback: resFunc,
},
});
});
});
}, [sessData.__deferred__?.length]);
schemaState.reload = reload;
schemaState.reset = resetData;
return {
schemaState,
dataDispatch: sessDispatchWithListener,
sessData,
reset: resetData,
};
};

View File

@@ -0,0 +1,205 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import _ from 'lodash';
import gettext from 'sources/gettext';
import { SCHEMA_STATE_ACTIONS } from '../SchemaState';
import { isModeSupportedByField } from '../common';
import { View, hasView } from '../registry';
import { StaticMappedFormControl, MappedFormControl } from '../MappedControl';
const DEFAULT_TAB = 'general';
export const createFieldControls = ({
schema, schemaState, accessPath, viewHelperProps, dataDispatch
}) => {
const { mode } = (viewHelperProps || {});
const isPropertyMode = mode === 'properties';
const groups = [];
const groupsById = {};
let currentGroup = null;
const createGroup = (id, label, visible, field, isFullTab) => {
const group = {
id: id,
label: label,
visible: visible,
field: field,
className: isFullTab ? (
isPropertyMode ? 'Properties-noPadding' : 'FormView-fullSpace'
) : '',
controls: [],
inlineGroups: {},
isFullTab: isFullTab
};
groups.push(group);
groupsById[id] = group;
return group;
};
// Create default group - 'General'.
createGroup(DEFAULT_TAB, gettext('General'), true);
schema?.fields?.forEach((field) => {
if (!isModeSupportedByField(field, viewHelperProps)) return;
let inlineGroup = null;
const inlineGroupId = field[inlineGroup];
if(field.type === 'group') {
if (!field.id || (field.id in groups)) {
throw new Error('Group-id must be unique within a schema.');
}
const { visible } = schemaState.options(accessPath.concat(field.id));
createGroup(field.id, field.label, visible, field);
return;
}
if (field.isFullTab) {
if (field.type === inlineGroup)
throw new Error('Inline group can not be full tab control');
const { visible } = schemaState.options(accessPath.concat(field.id));
currentGroup = createGroup(
field.id, field.label, visible, field, true
);
} else {
const { group } = field;
currentGroup = groupsById[group || DEFAULT_TAB];
if (!currentGroup) {
const newGroup = createGroup(group, group, true);
currentGroup = newGroup;
}
// Generate inline-view if necessary, or use existing one.
if (inlineGroupId) {
inlineGroup = currentGroup.inlineGroups[inlineGroupId];
if (!inlineGroup) {
inlineGroup = currentGroup.inlineGroups[inlineGroupId] = {
control: View('InlineView'),
controlProps: {
viewHelperProps: viewHelperProps,
field: null,
},
controls: [],
};
currentGroup.controls.push(inlineGroup);
}
}
}
if (field.type === inlineGroup) {
if (inlineGroupId) {
throw new Error('inline-group can not be created within inline-group');
}
inlineGroup = currentGroup.inlineGroups[inlineGroupId];
if (inlineGroup) {
throw new Error('inline-group must be unique-id within a tab group');
}
inlineGroup = currentGroup.inlineGroups[inlineGroupId] = {
control: View('InlineView'),
controlProps: {
accessPath: schemaState.accessPath(accessPath, field.id),
viewHelperProps: viewHelperProps,
field: field,
},
controls: [],
};
currentGroup.controls.push(inlineGroup);
return;
}
let control = null;
const controlProps = {
key: field.id,
accessPath: schemaState.accessPath(accessPath, field.id),
viewHelperProps: viewHelperProps,
dataDispatch: dataDispatch,
field: field,
};
switch (field.type) {
case 'nested-tab':
// We don't support nested-tab in 'properties' mode.
if (isPropertyMode) return;
control = View('FormView');
controlProps['isNested'] = true;
break;
case 'nested-fieldset':
control = View('FieldSetView');
controlProps['controlClassName'] =
isPropertyMode ? 'Properties-controlRow' : 'FormView-controlRow';
break;
case 'collection':
control = View('DataGridView');
controlProps['containerClassName'] =
isPropertyMode ? 'Properties-controlRow' : 'FormView-controlRow';
break;
default:
{
control = (
hasView(field.type) ? View(field.type) : (
field.id ? MappedFormControl : StaticMappedFormControl
)
);
if (inlineGroup) {
controlProps['withContainer'] = false;
controlProps['controlGridBasis'] = 3;
}
controlProps['className'] = field.isFullTab ? '' : (
isPropertyMode ? 'Properties-controlRow' : 'FormView-controlRow'
);
if (field.id) {
controlProps['id'] = field.id;
controlProps['onChange'] = (changeValue) => {
// Get the changes on dependent fields as well.
dataDispatch?.({
type: SCHEMA_STATE_ACTIONS.SET_VALUE,
path: controlProps.accessPath,
value: changeValue,
});
};
}
}
break;
}
// Use custom control over the standard one.
if (field.CustomControl) {
control = field.CustomControl;
}
if (isPropertyMode) field.helpMessage = '';
// Its a form control.
if (_.isEqual(accessPath.concat(field.id), schemaState.errors?.name))
currentGroup.hasError = true;
(inlineGroup || currentGroup).controls.push({control, controlProps});
});
return groups.filter(
(group) => (group.visible && group.controls.length)
);
};

View File

@@ -0,0 +1,17 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { createFieldControls } from './createFieldControls';
import { listenDepChanges } from './listenDepChanges';
export {
createFieldControls,
listenDepChanges,
};

View File

@@ -0,0 +1,57 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { useEffect } from 'react';
import _ from 'lodash';
import { evalFunc } from 'sources/utils';
export const listenDepChanges = (accessPath, field, visible, schemaState) => {
useEffect(() => {
if (!visible || !schemaState || !field) return;
if(field.depChange || field.deferredDepChange) {
schemaState.addDepListener(
accessPath, accessPath,
field.depChange, field.deferredDepChange
);
}
if (field.deps) {
const parentPath = [...accessPath];
// Remove the last element.
if (field.id && field.id === parentPath[parentPath.length - 1]) {
parentPath.pop();
}
(evalFunc(null, field.deps) || []).forEach((dep) => {
// When dep is a string then prepend the complete accessPath,
// but - when dep is an array, then the intention is to provide
// the exact accesspath.
let source = _.isArray(dep) ? dep : parentPath.concat(dep);
if(field.depChange || field.deferredDepChange) {
schemaState.addDepListener(
source, accessPath, field.depChange, field.deferredDepChange
);
}
});
}
return () => {
// Cleanup the listeners when unmounting.
schemaState.removeDepListener(accessPath);
};
}, []);
};

View File

@@ -80,6 +80,9 @@ const Root = styled('div')(({theme}) => ({
backgroundColor: theme.otherVars.borderColor,
padding: theme.spacing(1),
},
'& .Form-plainstring': {
padding: theme.spacing(0.5),
}
}));
@@ -351,12 +354,23 @@ export const InputText = forwardRef(({
cid, helpid, readonly, disabled, value, onChange, controlProps, type, size, inputStyle, ...props }, ref) => {
const maxlength = typeof(controlProps?.maxLength) != 'undefined' ? controlProps.maxLength : 255;
const patterns = {
'numeric': '^-?[0-9]\\d*\\.?\\d*$',
'int': '^-?[0-9]\\d*$',
};
let onChangeFinal = (e) => {
let finalValue = (_.isNull(value) || _.isUndefined(value)) ? '' : value;
if (controlProps?.formatter) {
finalValue = controlProps.formatter.fromRaw(finalValue);
}
if (_.isNull(finalValue) || _.isUndefined(finalValue)) finalValue = '';
const [val, setVal] = useState(finalValue);
useEffect(() => setVal(finalValue), [finalValue]);
const onChangeFinal = (e) => {
let changeVal = e.target.value;
/* For type number, we set type as tel with number regex to get validity.*/
@@ -368,14 +382,10 @@ export const InputText = forwardRef(({
if (controlProps?.formatter) {
changeVal = controlProps.formatter.toRaw(changeVal);
}
setVal(changeVal);
onChange?.(changeVal);
};
let finalValue = (_.isNull(value) || _.isUndefined(value)) ? '' : value;
if (controlProps?.formatter) {
finalValue = controlProps.formatter.fromRaw(finalValue);
}
const filteredProps = _.pickBy(props, (_v, key)=>(
/* When used in ButtonGroup, following props should be skipped */
@@ -403,7 +413,7 @@ export const InputText = forwardRef(({
disabled={Boolean(disabled)}
rows={4}
notched={false}
value={(_.isNull(finalValue) || _.isUndefined(finalValue)) ? '' : finalValue}
value={val}
onChange={onChangeFinal}
{
...(controlProps?.onKeyDown && { onKeyDown: controlProps.onKeyDown })
@@ -626,7 +636,6 @@ export function InputRadio({ helpid, value, onChange, controlProps, readonly, la
inputProps={{ 'aria-label': value, 'aria-describedby': helpid }}
style={{ padding: 0 }}
disableRipple
{...props}
/>
}
label={controlProps.label}
@@ -1110,7 +1119,9 @@ export function PlainString({ controlProps, value }) {
if (controlProps?.formatter) {
finalValue = controlProps.formatter.fromRaw(finalValue);
}
return <span>{finalValue}</span>;
return <Root>
<div className="Form-plainstring">{finalValue}</div>
</Root>;
}
PlainString.propTypes = {
controlProps: PropTypes.object,

View File

@@ -14,7 +14,6 @@ import PropTypes from 'prop-types';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import { PgIconButton } from './Buttons';
@@ -441,18 +440,6 @@ export function getCheckboxHeaderCell({title}) {
return Cell;
}
export function getReorderCell() {
const Cell = () => {
return <div className='reorder-cell'>
<DragIndicatorRoundedIcon fontSize="small" />
</div>;
};
Cell.displayName = 'ReorderCell';
return Cell;
}
export function getEditCell({isDisabled, title}) {
const Cell = ({ row }) => {
return <PgIconButton data-test="expand-row" title={title} icon={<EditRoundedIcon fontSize="small" />} className='pgrt-cell-button'

View File

@@ -8,7 +8,10 @@
//////////////////////////////////////////////////////////////
import React, { useMemo, useRef } from 'react';
import _ from 'lodash';
import Box from '@mui/material/Box';
import { styled } from '@mui/material/styles';
import {
useReactTable,
getCoreRowModel,
@@ -24,29 +27,29 @@ import {
keepPreviousData,
} from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { styled } from '@mui/material/styles';
import PropTypes from 'prop-types';
import { InputText } from './FormComponents';
import _ from 'lodash';
import {
BaseUISchema, FormView, SchemaStateContext, useSchemaState, prepareData,
} from 'sources/SchemaView';
import gettext from 'sources/gettext';
import SchemaView from '../SchemaView';
import EmptyPanelMessage from './EmptyPanelMessage';
import { InputText } from './FormComponents';
import { PgReactTable, PgReactTableBody, PgReactTableCell, PgReactTableHeader, PgReactTableRow, PgReactTableRowContent, PgReactTableRowExpandContent, getCheckboxCell, getCheckboxHeaderCell } from './PgReactTableStyled';
import { Box } from '@mui/material';
const ROW_HEIGHT = 30;
function TableRow({ index, style, schema, row, measureElement}) {
const [expandComplete, setExpandComplete] = React.useState(false);
function TableRow({index, style, schema, row, measureElement}) {
const rowRef = React.useRef();
React.useEffect(() => {
if (rowRef.current) {
if (!expandComplete && rowRef.current.style.height == `${ROW_HEIGHT}px`) {
return;
}
if (rowRef.current.style.height == `${ROW_HEIGHT}px`) return;
measureElement(rowRef.current);
}
}, [row.getIsExpanded(), expandComplete]);
}, [row.getIsExpanded()]);
return (
<PgReactTableRow data-index={index} ref={rowRef} style={style}>
@@ -62,12 +65,10 @@ function TableRow({ index, style, schema, row, measureElement}) {
})}
</PgReactTableRowContent>
<PgReactTableRowExpandContent row={row}>
<SchemaView
getInitData={() => Promise.resolve(row.original)}
viewHelperProps={{ mode: 'properties' }}
<FormView
accessPath={['data', index]}
schema={schema}
showFooter={false}
onDataChange={() => { setExpandComplete(true); }}
viewHelperProps={{ mode: 'properties' }}
/>
</PgReactTableRowExpandContent>
</PgReactTableRow>
@@ -81,7 +82,43 @@ TableRow.propTypes = {
measureElement: PropTypes.func,
};
export function Table({ columns, data, hasSelectRow, schema, sortOptions, tableProps, searchVal, loadNextPage, ...props }) {
class TableUISchema extends BaseUISchema {
constructor(rowSchema) {
super();
this.rowSchema = rowSchema;
}
get baseFields() {
return [{
id: 'data', type: 'collection', mode: ['properties'],
schema: this.rowSchema,
}];
}
}
const getTableSchema = (schema) => {;
if (!schema) return null;
if (!schema.top) schema.top = new TableUISchema(schema);
return schema.top;
};
export function Table({
columns, data, hasSelectRow, schema, sortOptions, tableProps, searchVal,
loadNextPage, ...props
}) {
const { schemaState } = useSchemaState({
schema: getTableSchema(schema),
getInitData: null,
viewHelperProps: {mode: 'properties'},
});
// We don't care about validation in static table, hence - initialising the
// data directly.
if (data.length && schemaState) {
schemaState.initData = schemaState.data = prepareData({'data': data});
}
const defaultColumn = React.useMemo(
() => ({
size: 150,
@@ -103,11 +140,15 @@ export function Table({ columns, data, hasSelectRow, schema, sortOptions, tableP
enableResizing: false,
maxSize: 35,
}] : []).concat(
columns.filter((c)=>_.isUndefined(c.enableVisibility) ? true : c.enableVisibility).map((c)=>({
columns.filter(
(c) => _.isUndefined(c.enableVisibility) ? true : c.enableVisibility
).map((c) => ({
...c,
// if data is null then global search doesn't work
// Use accessorFn to return empty string if data is null.
accessorFn: c.accessorFn ?? (c.accessorKey ? (row)=>row[c.accessorKey] ?? '' : undefined),
accessorFn: c.accessorFn ?? (
c.accessorKey ? (row) => row[c.accessorKey] ?? '' : undefined
),
}))
), [hasSelectRow, columns]);
@@ -118,24 +159,24 @@ export function Table({ columns, data, hasSelectRow, schema, sortOptions, tableP
let totalFetched = 0;
let totalDBRowCount = 0;
//Infinite scrolling
const { _data, fetchNextPage, isFetching } =
useInfiniteQuery({
queryKey: ['logs'],
queryFn: async () => {
const fetchedData = loadNextPage ? await loadNextPage() : [];
return fetchedData;
},
initialPageParam: 0,
getNextPageParam: (_lastGroup, groups) => groups.length,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});
// Infinite scrolling
const { _data, fetchNextPage, isFetching } = useInfiniteQuery({
queryKey: ['logs'],
queryFn: async () => {
const fetchedData = loadNextPage ? await loadNextPage() : [];
return fetchedData;
},
initialPageParam: 0,
getNextPageParam: (_lastGroup, groups) => groups.length,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});
flatData = _data || [];
totalFetched = flatData.length;
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
// Called on scroll and possibly on mount to fetch more data as the user
// scrolls and reaches bottom of table.
fetchMoreOnBottomReached = React.useCallback(
(containerRefElement = HTMLDivElement | null) => {
if (containerRefElement) {
@@ -194,22 +235,31 @@ export function Table({ columns, data, hasSelectRow, schema, sortOptions, tableP
});
return (
<PgReactTable ref={tableRef} table={table} onScrollFunc={loadNextPage?fetchMoreOnBottomReached: null }>
<PgReactTableHeader table={table} />
{rows.length == 0 ?
<EmptyPanelMessage text={gettext('No rows found')} style={{height:'auto'}} /> :
<PgReactTableBody style={{ height: virtualizer.getTotalSize() + 'px'}}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
return <TableRow index={virtualRow.index} key={virtualRow.index} row={row} schema={schema}
measureElement={virtualizer.measureElement}
style={{
transform: `translateY(${virtualRow.start}px)`, //this should always be a `style` as it changes on scroll
}}
/>;
})}
</PgReactTableBody>}
</PgReactTable>
<SchemaStateContext.Provider value={schemaState}>
<PgReactTable
ref={tableRef} table={table}
onScrollFunc={loadNextPage?fetchMoreOnBottomReached: null }
>
<PgReactTableHeader table={table} />
{rows.length == 0 ? <EmptyPanelMessage
text={gettext('No rows found')} style={{height:'auto'}}/> :
<PgReactTableBody
style={{ height: virtualizer.getTotalSize() + 'px'}}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
return <TableRow
index={virtualRow.index} key={virtualRow.index}
row={row} schema={schema}
measureElement={virtualizer.measureElement}
style={{
// This should always be a `style` as it changes on scroll.
transform: `translateY(${virtualRow.start}px)`,
}}
/>;
})}
</PgReactTableBody>}
</PgReactTable>
</SchemaStateContext.Provider>
);
}
Table.propTypes = {

View File

@@ -10,7 +10,10 @@
import React, { useEffect, useMemo, useRef } from 'react';
import ReactDOMServer from 'react-dom/server';
import PropTypes from 'prop-types';
import { checkTrojanSource } from '../../../utils';
import { useIsMounted } from 'sources/custom_hooks';
import { checkTrojanSource } from 'sources/utils';
import usePreferences from '../../../../../preferences/static/js/store';
import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded';
import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded';
@@ -147,9 +150,12 @@ const defaultExtensions = [
];
export default function Editor({
currEditor, name, value, options, onCursorActivity, onChange, readonly, disabled, autocomplete = false,
breakpoint = false, onBreakPointChange, showActiveLine=false,
keepHistory = true, cid, helpid, labelledBy, customKeyMap, language='pgsql'}) {
currEditor, name, value, options, onCursorActivity, onChange, readonly,
disabled, autocomplete = false, breakpoint = false, onBreakPointChange,
showActiveLine=false, keepHistory = true, cid, helpid, labelledBy,
customKeyMap, language='pgsql'
}) {
const checkIsMounted = useIsMounted();
const editorContainerRef = useRef();
const editor = useRef();
@@ -166,6 +172,7 @@ export default function Editor({
const editableConfig = useRef(new Compartment());
useEffect(() => {
if (!checkIsMounted()) return;
const finalOptions = { ...defaultOptions, ...options };
const finalExtns = [
(language == 'json') ? json() : sql({dialect: PgSQL}),
@@ -248,6 +255,7 @@ export default function Editor({
}, []);
useMemo(() => {
if (!checkIsMounted()) return;
if(editor.current) {
if(value != editor.current.getValue()) {
if(!_.isEmpty(value)) {
@@ -259,14 +267,19 @@ export default function Editor({
}
}, [value]);
useEffect(()=>{
const keys = keymap.of([customKeyMap??[], defaultKeymap, closeBracketsKeymap, historyKeymap, foldKeymap, completionKeymap].flat());
useEffect(() => {
if (!checkIsMounted()) return;
const keys = keymap.of([
customKeyMap??[], defaultKeymap, closeBracketsKeymap, historyKeymap,
foldKeymap, completionKeymap
].flat());
editor.current?.dispatch({
effects: shortcuts.current.reconfigure(keys)
});
}, [customKeyMap]);
useEffect(() => {
if (!checkIsMounted()) return;
let pref = preferencesStore.getPreferencesForModule('sqleditor');
let newConfigExtn = [];
@@ -361,6 +374,7 @@ export default function Editor({
}, [preferencesStore]);
useMemo(() => {
if (!checkIsMounted()) return;
if (editor.current) {
if (value != editor.current.getValue()) {
editor.current.dispatch({
@@ -371,6 +385,7 @@ export default function Editor({
}, [value]);
useEffect(() => {
if (!checkIsMounted()) return;
editor.current?.dispatch({
effects: editableConfig.current.reconfigure([
EditorView.editable.of(editable),
@@ -379,7 +394,7 @@ export default function Editor({
});
}, [readonly, disabled, keepHistory]);
return useMemo(()=>(
return useMemo(() => (
<div style={{ height: '100%' }} ref={editorContainerRef} name={name}></div>
), []);
}

View File

@@ -0,0 +1,49 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import PropTypes from 'prop-types';
import { InputText } from 'sources/components/FormComponents';
import gettext from 'sources/gettext';
export const SEARCH_INPUT_SIZE = {
FULL: 'full',
HALF: 'half',
};
export const SEARCH_INPUT_ALIGNMENT = {
LEFT: 'left',
RIGHT: 'right'
};
export const SearchInputText = ({
searchText, onChange, placeholder, size, alignment
}) => {
const props = {
placeholder: placeholder || gettext('Search'),
style: {
width: size == SEARCH_INPUT_SIZE.FULL ? '100%' : '50%',
float: alignment == SEARCH_INPUT_ALIGNMENT.RIGHT ? 'right' : 'left',
},
value: searchText,
onChange,
};
return <InputText {...props}/>;
};
SearchInputText.propTypes = {
searchText: PropTypes.string.isRequired,
onChange: PropTypes.func,
placeholder: PropTypes.string,
size: PropTypes.oneOf(Object.values(SEARCH_INPUT_SIZE)),
alignment: PropTypes.oneOf(Object.values(SEARCH_INPUT_ALIGNMENT)),
};

View File

@@ -1,103 +0,0 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { styled } from '@mui/material/styles';
import { Box } from '@mui/material';
import DataGridView, { DataGridHeader } from '../SchemaView/DataGridView';
import SchemaView, { SCHEMA_STATE_ACTIONS } from '../SchemaView';
import { DefaultButton } from '../components/Buttons';
import { evalFunc } from '../utils';
import PropTypes from 'prop-types';
import CustomPropTypes from '../custom_prop_types';
import _ from 'lodash';
const StyledBox = styled(Box)(({theme}) => ({
'& .DataGridViewWithHeaderForm-border': {
...theme.mixins.panelBorder,
borderBottom: 0,
'& .DataGridViewWithHeaderForm-body': {
padding: '0.25rem',
'& .DataGridViewWithHeaderForm-addBtn': {
marginLeft: 'auto',
}
},
},
}));
export default function DataGridViewWithHeaderForm(props) {
let {containerClassName, headerSchema, headerVisible, ...otherProps} = props;
const headerFormData = useRef({});
const schemaRef = useRef(otherProps.schema);
const [addDisabled, setAddDisabled] = useState(true);
const [headerFormResetKey, setHeaderFormResetKey] = useState(0);
const onAddClick = useCallback(()=>{
if(!otherProps.canAddRow) {
return;
}
let newRow = headerSchema.getNewData(headerFormData.current);
otherProps.dataDispatch({
type: SCHEMA_STATE_ACTIONS.ADD_ROW,
path: otherProps.accessPath,
value: newRow,
});
setHeaderFormResetKey((preVal)=>preVal+1);
}, []);
useEffect(()=>{
headerSchema.top = schemaRef.current.top;
}, []);
let state = schemaRef.current.top ? _.get(schemaRef.current.top.sessData, _.slice(otherProps.accessPath, 0, -1))
: _.get(schemaRef.current.sessData);
headerVisible = headerVisible && evalFunc(null, headerVisible, state);
return (
<StyledBox className={containerClassName}>
<Box className='DataGridViewWithHeaderForm-border'>
{props.label && <DataGridHeader label={props.label} />}
{headerVisible && <Box className='DataGridViewWithHeaderForm-body'>
<SchemaView
formType={'dialog'}
getInitData={()=>Promise.resolve({})}
schema={headerSchema}
viewHelperProps={props.viewHelperProps}
showFooter={false}
onDataChange={(isDataChanged, dataChanged)=>{
headerFormData.current = dataChanged;
setAddDisabled(headerSchema.addDisabled(headerFormData.current));
}}
hasSQL={false}
isTabView={false}
resetKey={headerFormResetKey}
/>
<Box display="flex">
<DefaultButton className='DataGridViewWithHeaderForm-addBtn' onClick={onAddClick} disabled={addDisabled}>Add</DefaultButton>
</Box>
</Box>}
</Box>
<DataGridView {...otherProps} label="" canAdd={false}/>
</StyledBox>
);
}
DataGridViewWithHeaderForm.propTypes = {
label: PropTypes.string,
value: PropTypes.array,
viewHelperProps: PropTypes.object,
formErr: PropTypes.object,
headerSchema: CustomPropTypes.schemaUI.isRequired,
headerVisible: PropTypes.func,
schema: CustomPropTypes.schemaUI,
accessPath: PropTypes.array.isRequired,
dataDispatch: PropTypes.func.isRequired,
containerClassName: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
};

View File

@@ -23,7 +23,7 @@ export default function withStandardTabInfo(Component, tabId) {
const [isActive, setIsActive] = React.useState(false);
const layoutDocker = useContext(LayoutDockerContext);
useEffect(()=>{
useEffect(() => {
const i = pgAdmin.Browser.tree?.selected();
if(i) {
setNodeInfo([true, i, pgAdmin.Browser.tree.itemData(i)]);
@@ -38,22 +38,24 @@ export default function withStandardTabInfo(Component, tabId) {
}
}, 100);
const onUpdate = (item, data)=>{
setNodeInfo([true, item, data]);
const onUpdate = () => {
// Only use the selected tree node item.
const item = pgAdmin.Browser.tree?.selected();
setNodeInfo([
true, item, item && pgAdmin.Browser.tree.itemData(item)
]);
};
let destroyTree = pgAdmin.Browser.Events.on('pgadmin-browser:tree:destroyed', onUpdate);
let deregisterTree = pgAdmin.Browser.Events.on('pgadmin-browser:node:selected', onUpdate);
let deregisterTreeUpdate = pgAdmin.Browser.Events.on('pgadmin-browser:tree:updated', onUpdate);
let deregisterDbConnected = pgAdmin.Browser.Events.on('pgadmin:database:connected', onUpdate);
let deregisterServerConnected = pgAdmin.Browser.Events.on('pgadmin:server:connected', (_sid, item, data)=>{
setNodeInfo([true, item, data]);
});
let deregisterServerConnected = pgAdmin.Browser.Events.on('pgadmin:server:connected', onUpdate);
let deregisterActive = layoutDocker.eventBus.registerListener(LAYOUT_EVENTS.ACTIVE, onTabActive);
// if there is any dock changes to the tab and it appears to be active/inactive
let deregisterChange = layoutDocker.eventBus.registerListener(LAYOUT_EVENTS.CHANGE, onTabActive);
return ()=>{
return () => {
onTabActive?.cancel();
destroyTree();
deregisterTree();

View File

@@ -677,3 +677,62 @@ export function getChartColor(index, theme='light', colorPalette=CHART_THEME_COL
// loop back if out of index;
return palette[index % palette.length];
}
// Using this function instead of 'btoa' directly.
// https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
function stringToBase64(str) {
return btoa(
Array.from(
new TextEncoder().encode(str),
(byte) => String.fromCodePoint(byte),
).join('')
);
}
/************************************
*
* Memoization of a function.
*
* NOTE: Please don't use the function, when:
* - One of the parameter in the arguments could have a 'circular' dependency.
* NOTE: We use `JSON.stringify(...)` for all the arguments.`You could
* introduce 'Object.prototype.toJSON(...)' function for the object
* with circular dependency, which should return a JSON object without
* it.
* - It returns a Promise object (asynchronous functions).
*
* Consider to use 'https://github.com/sindresorhus/p-memoize' for an
* asychronous functions.
*
**/
export const memoizeFn = fn => new Proxy(fn, {
cache: new Map(),
apply (target, thisArg, argsList) {
let cacheKey = stringToBase64(JSON.stringify(argsList));
if(!this.cache.has(cacheKey)) {
this.cache.set(cacheKey, target.apply(thisArg, argsList));
}
return this.cache.get(cacheKey);
}
});
export const memoizeTimeout = (fn, time) => new Proxy(fn, {
cache: new Map(),
apply (target, thisArg, argsList) {
const cacheKey = stringToBase64(JSON.stringify(argsList));
const cached = this.cache.get(cacheKey);
const timeoutId = setTimeout(() => (this.cache.delete(cacheKey)), time);
if (cached) {
clearInterval(cached.timeoutId);
cached.timeoutId = timeoutId;
return cached.result;
}
const result = target.apply(thisArg, argsList);
this.cache.set(cacheKey, {result, timeoutId});
return result;
}
});

View File

@@ -40,7 +40,7 @@ export class SectionSchema extends BaseUISchema {
state.only_tablespaces ||
state.only_roles;
},
inlineNext: true,
inlineGroup: 'section',
}, {
id: 'data',
label: gettext('Data'),
@@ -53,6 +53,7 @@ export class SectionSchema extends BaseUISchema {
state.only_tablespaces ||
state.only_roles;
},
inlineGroup: 'section',
}, {
id: 'post_data',
label: gettext('Post-data'),
@@ -65,6 +66,7 @@ export class SectionSchema extends BaseUISchema {
state.only_tablespaces ||
state.only_roles;
},
inlineGroup: 'section',
}
];
}
@@ -105,7 +107,7 @@ export class TypeObjSchema extends BaseUISchema {
state.only_tablespaces ||
state.only_roles;
},
inlineNext: true,
inlineGroup: 'type_of_objects',
}, {
id: 'only_schema',
label: gettext('Only schemas'),
@@ -121,7 +123,7 @@ export class TypeObjSchema extends BaseUISchema {
state.only_tablespaces ||
state.only_roles;
},
inlineNext: true,
inlineGroup: 'type_of_objects',
}, {
id: 'only_tablespaces',
label: gettext('Only tablespaces'),
@@ -137,8 +139,8 @@ export class TypeObjSchema extends BaseUISchema {
state.only_schema ||
state.only_roles;
},
visible: isVisibleForObjectBackup(obj?._top?.backupType),
inlineNext: true,
visible: isVisibleForObjectBackup(obj?.top?.backupType),
inlineGroup: 'type_of_objects',
}, {
id: 'only_roles',
label: gettext('Only roles'),
@@ -146,6 +148,7 @@ export class TypeObjSchema extends BaseUISchema {
group: gettext('Type of objects'),
deps: ['pre_data', 'data', 'post_data', 'only_data', 'only_schema',
'only_tablespaces'],
inlineGroup: 'type_of_objects',
disabled: function(state) {
return state.pre_data ||
state.data ||
@@ -154,14 +157,15 @@ export class TypeObjSchema extends BaseUISchema {
state.only_schema ||
state.only_tablespaces;
},
visible: isVisibleForObjectBackup(obj?._top?.backupType)
visible: isVisibleForObjectBackup(obj?.top?.backupType)
}, {
id: 'blobs',
label: gettext('Blobs'),
type: 'switch',
group: gettext('Type of objects'),
inlineGroup: 'type_of_objects',
visible: function(state) {
if (!isVisibleForServerBackup(obj?._top?.backupType)) {
if (!isVisibleForServerBackup(obj?.top?.backupType)) {
state.blobs = false;
return false;
}
@@ -200,43 +204,43 @@ export class SaveOptSchema extends BaseUISchema {
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'do_not_save',
}, {
id: 'dns_no_role_passwords',
label: gettext('Role passwords'),
type: 'switch',
disabled: false,
group: gettext('Do not save'),
visible: isVisibleForObjectBackup(obj?._top?.backupType),
inlineNext: true,
visible: isVisibleForObjectBackup(obj?.top?.backupType),
inlineGroup: 'do_not_save',
}, {
id: 'dns_privilege',
label: gettext('Privileges'),
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'do_not_save',
}, {
id: 'dns_tablespace',
label: gettext('Tablespaces'),
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'do_not_save',
}, {
id: 'dns_unlogged_tbl_data',
label: gettext('Unlogged table data'),
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'do_not_save',
}, {
id: 'dns_comments',
label: gettext('Comments'),
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'do_not_save',
min_version: 110000
}, {
id: 'dns_publications',
@@ -244,7 +248,7 @@ export class SaveOptSchema extends BaseUISchema {
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'do_not_save',
min_version: 110000
}, {
id: 'dns_subscriptions',
@@ -252,7 +256,7 @@ export class SaveOptSchema extends BaseUISchema {
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'do_not_save',
min_version: 110000
}, {
id: 'dns_security_labels',
@@ -260,7 +264,7 @@ export class SaveOptSchema extends BaseUISchema {
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'do_not_save',
min_version: 110000
}, {
id: 'dns_toast_compression',
@@ -268,7 +272,7 @@ export class SaveOptSchema extends BaseUISchema {
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'do_not_save',
min_version: 140000
}, {
id: 'dns_table_access_method',
@@ -276,7 +280,7 @@ export class SaveOptSchema extends BaseUISchema {
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'do_not_save',
min_version: 150000
}];
}
@@ -322,13 +326,14 @@ export class DisabledOptionSchema extends BaseUISchema {
disabled: function(state) {
return !(state.only_data);
},
inlineNext: true,
inlineGroup: 'disable',
}, {
id: 'disable_quoting',
label: gettext('$ quoting'),
type: 'switch',
disabled: false,
group: gettext('Disable'),
inlineGroup: 'disable',
}];
}
}
@@ -364,28 +369,29 @@ export class MiscellaneousSchema extends BaseUISchema {
type: 'switch',
disabled: false,
group: gettext('Miscellaneous'),
inlineNext: true,
inlineGroup: 'miscellaneous',
}, {
id: 'dqoute',
label: gettext('Force double quote on identifiers'),
type: 'switch',
disabled: false,
group: gettext('Miscellaneous'),
inlineNext: true,
inlineGroup: 'miscellaneous',
}, {
id: 'use_set_session_auth',
label: gettext('Use SET SESSION AUTHORIZATION'),
type: 'switch',
disabled: false,
group: gettext('Miscellaneous'),
inlineNext: true,
inlineGroup: 'miscellaneous',
}, {
id: 'exclude_schema',
label: gettext('Exclude schema'),
type: 'select',
disabled: false,
group: gettext('Miscellaneous'),
visible: isVisibleForServerBackup(obj?._top?.backupType),
inlineGroup: 'miscellaneous',
visible: isVisibleForServerBackup(obj?.top?.backupType),
controlProps: { multiple: true, allowClear: false, creatable: true, noDropdown: true, placeholder: ' ' }
}, {
id: 'exclude_database',
@@ -394,7 +400,7 @@ export class MiscellaneousSchema extends BaseUISchema {
disabled: false,
min_version: 160000,
group: gettext('Miscellaneous'),
visible: isVisibleForObjectBackup(obj?._top?.backupType),
visible: isVisibleForObjectBackup(obj?.top?.backupType),
controlProps: { multiple: true, allowClear: false, creatable: true, noDropdown: true, placeholder: ' ' }
}, {
id: 'extra_float_digits',
@@ -440,7 +446,7 @@ export class ExcludePatternsSchema extends BaseUISchema {
type: 'select',
disabled: false,
group: gettext('Table Options'),
visible: isVisibleForServerBackup(obj?._top?.backupType),
visible: isVisibleForServerBackup(obj?.top?.backupType),
controlProps: { multiple: true, allowClear: false, creatable: true, noDropdown: true, placeholder: ' ' }
}, {
id: 'exclude_table_data',
@@ -448,7 +454,7 @@ export class ExcludePatternsSchema extends BaseUISchema {
type: 'select',
disabled: false,
group: gettext('Table Options'),
visible: isVisibleForServerBackup(obj?._top?.backupType),
visible: isVisibleForServerBackup(obj?.top?.backupType),
controlProps: { multiple: true, allowClear: false, creatable: true, noDropdown: true, placeholder: ' ' }
}, {
id: 'exclude_table_and_children',
@@ -457,7 +463,7 @@ export class ExcludePatternsSchema extends BaseUISchema {
disabled: false,
group: gettext('Table Options'),
min_version: 160000,
visible: isVisibleForServerBackup(obj?._top?.backupType),
visible: isVisibleForServerBackup(obj?.top?.backupType),
controlProps: { multiple: true, allowClear: false, creatable: true, noDropdown: true, placeholder: ' ' }
}, {
id: 'exclude_table_data_and_children',
@@ -466,7 +472,7 @@ export class ExcludePatternsSchema extends BaseUISchema {
disabled: false,
group: gettext('Table Options'),
min_version: 160000,
visible: isVisibleForServerBackup(obj?._top?.backupType),
visible: isVisibleForServerBackup(obj?.top?.backupType),
controlProps: { multiple: true, allowClear: false, creatable: true, noDropdown: true, placeholder: ' ' }
}];
}
@@ -638,7 +644,7 @@ export default class BackupSchema extends BaseUISchema {
state.on_conflict_do_nothing = false;
return true;
},
inlineNext: obj.backupType !== 'server',
inlineGroup: 'miscellaneous',
}, {
id: 'include_create_database',
label: gettext('Include CREATE DATABASE statement'),
@@ -646,7 +652,7 @@ export default class BackupSchema extends BaseUISchema {
disabled: false,
group: gettext('Query Options'),
visible: isVisibleForServerBackup(obj.backupType),
inlineNext: true,
inlineGroup: 'miscellaneous',
}, {
id: 'include_drop_database',
label: gettext('Include DROP DATABASE statement'),
@@ -660,7 +666,7 @@ export default class BackupSchema extends BaseUISchema {
}
return false;
},
inlineNext: true,
inlineGroup: 'miscellaneous',
}, {
id: 'if_exists',
label: gettext('Include IF EXISTS clause'),
@@ -674,6 +680,7 @@ export default class BackupSchema extends BaseUISchema {
state.if_exists = false;
return true;
},
inlineGroup: 'miscellaneous',
}, {
id: 'use_column_inserts',
label: gettext('Use Column INSERTS'),

View File

@@ -818,9 +818,9 @@ export default function DebuggerArgumentComponent({ debuggerInfo, restartDebug,
onDataChange={(isChanged, changedData) => {
let isValid = false;
let skipStep = false;
if ('_sessData' in debuggerArgsSchema.current) {
if ('sessData' in debuggerArgsSchema.current) {
isValid = true;
debuggerArgsSchema.current._sessData.aregsCollection.forEach((data) => {
debuggerArgsSchema.current.sessData.aregsCollection.forEach((data) => {
if (skipStep) { return; }

View File

@@ -37,7 +37,7 @@ export class VacuumSchema extends BaseUISchema {
deps: ['op'],
type: 'switch',
label: gettext('FULL'),
inlineNext: true,
inlineGroup: 'operations',
visible: function(state) {
return obj.isApplicableForVacuum(state);
},
@@ -53,7 +53,7 @@ export class VacuumSchema extends BaseUISchema {
deps: ['op'],
type: 'switch',
label: gettext('FREEZE'),
inlineNext: true,
inlineGroup: 'operations',
visible: function(state) {
return obj.isApplicableForVacuum(state);
},
@@ -69,7 +69,7 @@ export class VacuumSchema extends BaseUISchema {
deps: ['op'],
type: 'switch',
label: gettext('ANALYZE'),
inlineNext: true,
inlineGroup: 'operations',
visible: function(state) {
return obj.isApplicableForVacuum(state);
},
@@ -85,7 +85,7 @@ export class VacuumSchema extends BaseUISchema {
deps: ['op', 'vacuum_full'],
type: 'switch',
label: gettext('DISABLE PAGE SKIPPING'),
inlineNext: true,
inlineGroup: 'operations',
disabled: function(state) {
if (!obj.isApplicableForVacuum(state) || state.vacuum_full) {
state.vacuum_disable_page_skipping = false;
@@ -101,7 +101,7 @@ export class VacuumSchema extends BaseUISchema {
deps: ['op'],
type: 'switch',
label: gettext('SKIP LOCKED'),
inlineNext: true,
inlineGroup: 'operations',
visible: function(state) {
return state?.op ? (state.op == 'VACUUM' || state.op == 'ANALYZE') : false;
},
@@ -118,7 +118,7 @@ export class VacuumSchema extends BaseUISchema {
deps: ['op', 'vacuum_full'],
type: 'switch',
label: gettext('TRUNCATE'),
inlineNext: true,
inlineGroup: 'operations',
disabled: function(state) {
if (!obj.isApplicableForVacuum(state) || state.vacuum_full) {
state.vacuum_truncate = false;
@@ -135,7 +135,7 @@ export class VacuumSchema extends BaseUISchema {
deps: ['op'],
type: 'switch',
label: gettext('PROCESS TOAST'),
inlineNext: true,
inlineGroup: 'operations',
visible: function(state) {
return obj.isApplicableForVacuum(state);
},
@@ -152,7 +152,7 @@ export class VacuumSchema extends BaseUISchema {
deps: ['op'],
type: 'switch',
label: gettext('PROCESS MAIN'),
inlineNext: true,
inlineGroup: 'operations',
visible: function(state) {
return obj.isApplicableForVacuum(state);
},
@@ -169,7 +169,7 @@ export class VacuumSchema extends BaseUISchema {
deps: ['op'],
type: 'switch',
label: gettext('SKIP DATABASE STATS'),
inlineNext: true,
inlineGroup: 'operations',
visible: function(state) {
return obj.isApplicableForVacuum(state);
},
@@ -186,7 +186,7 @@ export class VacuumSchema extends BaseUISchema {
deps: ['op'],
type: 'switch',
label: gettext('ONLY DATABASE STATS'),
inlineNext: true,
inlineGroup: 'operations',
visible: function(state) {
return obj.isApplicableForVacuum(state);
},
@@ -202,6 +202,7 @@ export class VacuumSchema extends BaseUISchema {
id: 'vacuum_index_cleanup',
deps: ['op', 'vacuum_full'],
type: 'select',
inlineGroup: 'operations',
label: gettext('INDEX CLEANUP'),
controlProps: { allowClear: false, width: '100%' },
options: function () {
@@ -277,7 +278,7 @@ export class VacuumSchema extends BaseUISchema {
return obj.isApplicableForReindex(state);
},
disabled: function(state) {
if (!obj.isApplicableForReindex(state) || obj?._top?.nodeInfo?.schema) {
if (!obj.isApplicableForReindex(state) || obj?.top?.nodeInfo?.schema) {
state.reindex_system = false;
return true;
}

View File

@@ -42,7 +42,7 @@ export class RestoreSectionSchema extends BaseUISchema {
label: gettext('Pre-data'),
type: 'switch',
group: gettext('Sections'),
inlineNext: true,
inlineGroup: 'sections',
deps: ['only_data', 'only_schema'],
disabled: function(state) {
return obj.isDisabled(state);
@@ -52,7 +52,7 @@ export class RestoreSectionSchema extends BaseUISchema {
label: gettext('Data'),
type: 'switch',
group: gettext('Sections'),
inlineNext: true,
inlineGroup: 'sections',
deps: ['only_data', 'only_schema'],
disabled: function(state) {
return obj.isDisabled(state);
@@ -62,6 +62,7 @@ export class RestoreSectionSchema extends BaseUISchema {
label: gettext('Post-data'),
type: 'switch',
group: gettext('Sections'),
inlineGroup: 'sections',
deps: ['only_data', 'only_schema'],
disabled: function(state) {
return obj.isDisabled(state);
@@ -97,7 +98,7 @@ export class RestoreTypeObjSchema extends BaseUISchema {
label: gettext('Only data'),
type: 'switch',
group: gettext('Type of objects'),
inlineNext: true,
inlineGroup: 'types_of_data',
deps: ['pre_data', 'data', 'post_data', 'only_schema'],
disabled: function(state) {
if(obj.selectedNodeType == 'table') {
@@ -115,6 +116,7 @@ export class RestoreTypeObjSchema extends BaseUISchema {
label: gettext('Only schema'),
type: 'switch',
group: gettext('Type of objects'),
inlineGroup: 'types_of_data',
deps: ['pre_data', 'data', 'post_data', 'only_data'],
disabled: function(state) {
if(obj.selectedNodeType == 'index' || obj.selectedNodeType == 'function') {
@@ -159,28 +161,28 @@ export class RestoreSaveOptSchema extends BaseUISchema {
label: gettext('Owner'),
type: 'switch',
disabled: false,
inlineNext: true,
inlineGroup: 'save_options',
group: gettext('Do not save'),
}, {
id: 'dns_privilege',
label: gettext('Privileges'),
type: 'switch',
disabled: false,
inlineNext: true,
inlineGroup: 'save_options',
group: gettext('Do not save'),
}, {
id: 'dns_tablespace',
label: gettext('Tablespaces'),
type: 'switch',
disabled: false,
inlineNext: true,
inlineGroup: 'save_options',
group: gettext('Do not save'),
}, {
id: 'dns_comments',
label: gettext('Comments'),
type: 'switch',
disabled: false,
inlineNext: true,
inlineGroup: 'save_options',
group: gettext('Do not save'),
min_version: 110000
}, {
@@ -189,7 +191,7 @@ export class RestoreSaveOptSchema extends BaseUISchema {
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'save_options',
min_version: 110000
}, {
id: 'dns_subscriptions',
@@ -197,7 +199,7 @@ export class RestoreSaveOptSchema extends BaseUISchema {
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'save_options',
min_version: 110000
}, {
id: 'dns_security_labels',
@@ -205,7 +207,7 @@ export class RestoreSaveOptSchema extends BaseUISchema {
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'save_options',
min_version: 110000
}, {
id: 'dns_table_access_method',
@@ -213,7 +215,7 @@ export class RestoreSaveOptSchema extends BaseUISchema {
type: 'switch',
disabled: false,
group: gettext('Do not save'),
inlineNext: true,
inlineGroup: 'save_options',
min_version: 150000
}];
}
@@ -419,7 +421,7 @@ export default class RestoreSchema extends BaseUISchema {
label: gettext('Clean before restore'),
type: 'switch',
group: gettext('Query Options'),
inlineNext: true,
inlineGroup: 'clean',
disabled: function(state) {
if(obj.selectedNodeType === 'function' || obj.selectedNodeType === 'trigger_function') {
state.clean = true;
@@ -431,6 +433,7 @@ export default class RestoreSchema extends BaseUISchema {
label: gettext('Include IF EXISTS clause'),
type: 'switch',
group: gettext('Query Options'),
inlineGroup: 'clean',
deps: ['clean'],
disabled: function(state) {
if (state.clean) {

View File

@@ -27,7 +27,9 @@ class MacrosCollection extends BaseUISchema {
}
/* Returns the new data row for the schema based on defaults and input */
getNewData(current_macros, data={}) {
getNewData(data={}) {
const current_macros = this?.top?.sessData.macro;
let newRow = {};
this.fields.forEach((field)=>{
newRow[field.id] = this.defaults[field.id];
@@ -36,7 +38,8 @@ class MacrosCollection extends BaseUISchema {
...newRow,
...data,
};
if (current_macros){
if (current_macros) {
// Extract an array of existing names from the 'macro' collection
const existingNames = current_macros.map(macro => macro.name);
const newName = getRandomName(existingNames);

View File

@@ -161,7 +161,7 @@ function showFilterDialog(pgBrowser, item, queryToolMod, transId,
let helpUrl = url_for('help.static', {'filename': 'viewdata_filter.html'});
let okCallback = function() {
queryToolMod.launch(transId, gridUrl, false, queryToolTitle, {sql_filter: schema._sessData.filter_sql});
queryToolMod.launch(transId, gridUrl, false, queryToolTitle, {sql_filter: schema.sessData.filter_sql});
};
pgBrowser.Events.trigger('pgadmin:utility:show', item,

View File

@@ -166,8 +166,8 @@ class UserManagementCollection extends BaseUISchema {
}
if (state.auth_source != AUTH_METHODS['INTERNAL']) {
if (obj.isNew(state) && obj.top?._sessData?.userManagement) {
for (let user of obj.top._sessData.userManagement) {
if (obj.isNew(state) && obj.top?.sessData?.userManagement) {
for (let user of obj.top.sessData.userManagement) {
if (user?.id &&
user.username.toLowerCase() == state.username.toLowerCase() &&
user.auth_source == state.auth_source) {
@@ -193,8 +193,8 @@ class UserManagementCollection extends BaseUISchema {
setError('email', null);
}
if (obj.isNew(state) && obj.top?._sessData?.userManagement) {
for (let user of obj.top._sessData.userManagement) {
if (obj.isNew(state) && obj.top?.sessData?.userManagement) {
for (let user of obj.top.sessData.userManagement) {
if (user?.id &&
user.email?.toLowerCase() == state.email?.toLowerCase()) {
msg = gettext('Email address \'%s\' already exists', state.email);
@@ -303,6 +303,7 @@ class UserManagementSchema extends BaseUISchema {
},
{
id: 'refreshBrowserTree', visible: false, type: 'switch',
mode: ['non_supported'],
deps: ['userManagement'], depChange: ()=> {
return { refreshBrowserTree: this.changeOwnership };
}

View File

@@ -142,7 +142,7 @@ Python Tests:
'pgadmin4/web/pgadmin/utils/test.py' file.
- To run Feature Tests in parallel using selenoid(grid + docker), selenoid
need to be installed nad should be run only with SERVER_MODE=True.
need to be installed and should be run only with SERVER_MODE=True.
Steps to install selenoid -
- Install & Start docker

View File

@@ -73,7 +73,7 @@ class CheckRoleMembershipControlFeatureTest(BaseFeatureTest):
edit_object = self.wait.until(EC.visibility_of_element_located(
(By.CSS_SELECTOR, NavMenuLocators.edit_obj_css)))
edit_object.click()
membership_tab = WebDriverWait(self.page.driver, 4).until(
membership_tab = WebDriverWait(self.page.driver, 2).until(
EC.presence_of_element_located((
By.XPATH, "//button[normalize-space(text())='Membership']")))
membership_tab.click()

View File

@@ -1147,29 +1147,39 @@ class PgadminPage:
bottom_ele = self.driver.find_element(
By.XPATH,
"//div[@id='id-object-explorer']"
"/div/div/div/div/div[last()]")
bottom_ele_location = int(
bottom_ele.value_of_css_property('top').split("px")[0])
"/div/div/div/div/div/div[last()]")
bottom_ele_top = bottom_ele.value_of_css_property('top')
bottom_ele_location = 1
if (bottom_ele_top != 'auto'):
bottom_ele_location = int(
bottom_ele_top.split("px")[0]
)
if tree_height - bottom_ele_location < 25:
f_scroll = 0
f_scroll = bottom_ele_location - 25
else:
self.driver.execute_script(
self.js_executor_scrollintoview_arg, bottom_ele)
self.js_executor_scrollintoview_arg, bottom_ele
)
f_scroll -= 1
elif r_scroll > 0:
top_el = self.driver.find_element(
By.XPATH,
"//div[@id='id-object-explorer']"
"/div/div/div/div/div[1]")
top_el_location = int(
top_el.value_of_css_property('top').split("px")[0])
top_el_top = top_el.value_of_css_property('top')
top_el_location = 0
if (top_el_top != 'auto'):
top_el_location = int(top_el_top.split("px")[0])
if (tree_height - top_el_location) == tree_height:
r_scroll = 0
else:
webdriver.ActionChains(self.driver).move_to_element(
top_el).perform()
self.driver.execute_script(
self.js_executor_scrollintoview_arg, top_el
)
r_scroll -= 1
else:
break

View File

@@ -0,0 +1,157 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { isValueEqual } from '../../../pgadmin/static/js/SchemaView/common';
import {
createStore
} from '../../../pgadmin/static/js/SchemaView/SchemaState/store';
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',
};
describe('store', ()=>{
describe('', () => {
it('getState', () => {
const store = createStore(initData);
const data = store.getState();
expect(isValueEqual(data, initData)).toBe(true);
});
it('get', () => {
const store = createStore(initData);
const firstField3 = store.get(['fieldcoll', 0, 'field3']);
expect(firstField3 == 1).toBe(true);
const firstFieldCollRow = store.get(['fieldcoll', '0']);
// Sending a copy of the data, and not itself.
expect(isValueEqual(firstFieldCollRow, initData.fieldcoll[0])).toBe(true);
});
it('setState', () => {
const store = createStore(initData);
const newData = {a: 1};
store.setState(newData);
const newState = store.getState();
expect(Object.is(newState, newData)).toBe(false);
expect(isValueEqual(newState, newData)).toBe(true);
});
it ('set', () => {
const store = createStore(initData);
const newData = {a: 1};
store.set(newData);
let newState = store.getState();
expect(Object.is(newState, newData)).toBe(false);
expect(isValueEqual(newState, newData)).toBe(true);
store.set((prevState) => ({...prevState, initData}));
newState = store.getState();
expect(Object.is(newState, initData)).toBe(false);
expect(isValueEqual(newState, initData)).toBe(false);
delete newState['a'];
store.set(() => (newState));
newState = store.getState();
expect(isValueEqual(newState, initData)).toBe(false);
});
it ('subscribe', () => {
const store = createStore(initData);
const listener = jest.fn();
const unsubscribe1 = store.subscribe(listener);
store.set((prevState) => (prevState));
expect(listener).not.toHaveBeenCalled();
store.set((prevState) => {
prevState.id = 2;
return prevState;
});
expect(listener).toHaveBeenCalled();
const listenForFirstField3 = jest.fn();
const unsubscribe2 = store.subscribeForPath(
['fieldcoll', '0', 'field3'], listenForFirstField3
);
const listenForSecondField3 = jest.fn();
const unsubscribe3 = store.subscribeForPath(
['fieldcoll', '1', 'field3'], listenForSecondField3
);
let changeTo = 10;
store.set((prevState) => {
prevState.fieldcoll[0].field3 = changeTo;
return prevState;
});
expect(listenForFirstField3).toHaveBeenCalled();
expect(listener).toHaveBeenCalledTimes(2);
expect(listenForSecondField3).not.toHaveBeenCalled();
store.set((prevState) => {
// There is no actual change from previous state.
prevState.fieldcoll[0].field3 = 10;
return prevState;
});
// Not expecting it be called.
expect(listenForFirstField3).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledTimes(2);
expect(listenForSecondField3).not.toHaveBeenCalled();
unsubscribe1();
store.set((prevState) => {
prevState.fieldcoll[0].field3 = 50;
return prevState;
});
// Don't expect this to be called again.
expect(listener).toHaveBeenCalledTimes(2);
// Expect this one to be called
expect(listenForFirstField3).toHaveBeenCalledTimes(2);
expect(listenForSecondField3).not.toHaveBeenCalled();
unsubscribe2();
store.set((prevState) => {
prevState.fieldcoll[0].field3 = 100;
return prevState;
});
// Don't expect any of them to be called.
expect(listener).toHaveBeenCalledTimes(2);
expect(listenForFirstField3).toHaveBeenCalledTimes(2);
expect(listenForSecondField3).not.toHaveBeenCalled();
unsubscribe3();
});
});
});

View File

@@ -13,7 +13,7 @@ import {genericBeforeEach, getCreateView, getEditView, getPropertiesView} from '
describe('AggregateSchema', ()=>{
let schemaObj = new AggregateSchema();
let createSchemaObj = () => new AggregateSchema();
let getInitData = ()=>Promise.resolve({});
@@ -25,15 +25,15 @@ describe('AggregateSchema', ()=>{
});
it('create', async ()=>{
await getCreateView(schemaObj);
await getCreateView(createSchemaObj());
});
it('edit', async ()=>{
await getEditView(schemaObj, getInitData);
await getEditView(createSchemaObj(), getInitData);
});
it('properties', async ()=>{
await getPropertiesView(schemaObj, getInitData);
await getPropertiesView(createSchemaObj(), getInitData);
});
});

View File

@@ -14,12 +14,13 @@ import {genericBeforeEach, getCreateView, getEditView, getPropertiesView} from '
describe('CastSchema', ()=>{
let schemaObj = new CastSchema(
let createSchemaObj = () => new CastSchema(
{
getTypeOptions: ()=>[],
getFuncOptions: ()=>[],
},
);
const schemaObj = createSchemaObj();
let getInitData = ()=>Promise.resolve({});
@@ -29,15 +30,15 @@ describe('CastSchema', ()=>{
});
it('create', async ()=>{
await getCreateView(schemaObj);
await getCreateView(createSchemaObj());
});
it('edit', async ()=>{
await getEditView(schemaObj, getInitData);
await getEditView(createSchemaObj(), getInitData);
});
it('properties', async ()=>{
await getPropertiesView(schemaObj, getInitData);
await getPropertiesView(createSchemaObj(), getInitData);
});
it('srctyp depChange', ()=>{

View File

@@ -13,7 +13,7 @@ import {genericBeforeEach, getCreateView, getEditView, getPropertiesView} from '
describe('CatalogSchema', ()=>{
let catalogObj = new CatalogSchema(
let createCatalogObj = () => new CatalogSchema(
{
namespaceowner: '',
}
@@ -29,15 +29,15 @@ describe('CatalogSchema', ()=>{
});
it('create', async ()=>{
await getCreateView(catalogObj);
await getCreateView(createCatalogObj());
});
it('edit', async ()=>{
await getEditView(catalogObj, getInitData);
await getEditView(createCatalogObj(), getInitData);
});
it('properties', async ()=>{
await getPropertiesView(catalogObj, getInitData);
await getPropertiesView(createCatalogObj(), getInitData);
});
});

View File

@@ -13,23 +13,19 @@ import {genericBeforeEach, getCreateView, getPropertiesView} from '../genericFun
describe('CatalogObjectColumn', ()=>{
let schemaObj = new CatalogObjectColumn();
let createSchemaObj = () => new CatalogObjectColumn();
let getInitData = ()=>Promise.resolve({});
beforeEach(()=>{
genericBeforeEach();
});
it('create', async ()=>{
await getCreateView(schemaObj);
await getCreateView(createSchemaObj());
});
it('properties', async ()=>{
await getPropertiesView(schemaObj, getInitData);
await getPropertiesView(createSchemaObj(), getInitData);
});
});

View File

@@ -35,7 +35,8 @@ function getFieldDepChange(schema, id) {
describe('CheckConstraintSchema', ()=>{
let schemaObj = new CheckConstraintSchema();
let createSchemaObj = () => new CheckConstraintSchema();
let schemaObj = createSchemaObj();
let getInitData = ()=>Promise.resolve({});
@@ -47,15 +48,15 @@ describe('CheckConstraintSchema', ()=>{
});
it('create', async ()=>{
await getCreateView(schemaObj);
await getCreateView(createSchemaObj());
});
it('edit', async ()=>{
await getEditView(schemaObj, getInitData);
await getEditView(createSchemaObj(), getInitData);
});
it('properties', async ()=>{
await getPropertiesView(schemaObj, getInitData);
await getPropertiesView(createSchemaObj(), getInitData);
});
it('create collection', async ()=>{

View File

@@ -12,8 +12,7 @@ import CollationSchema from '../../../pgadmin/browser/server_groups/servers/data
import {genericBeforeEach, getCreateView, getEditView, getPropertiesView} from '../genericFunctions';
describe('CollationsSchema', () => {
let schemaObj = new CollationSchema(
const createSchemaObj = () => new CollationSchema(
{
rolesList: () => [],
schemaList: () => [],
@@ -24,24 +23,23 @@ describe('CollationsSchema', () => {
schema: ''
}
);
let schemaObj = createSchemaObj();
let getInitData = () => Promise.resolve({});
beforeEach(() => {
genericBeforeEach();
});
it('create', () => {
getCreateView(schemaObj);
getCreateView(createSchemaObj());
});
it('edit', () => {
getEditView(schemaObj, getInitData);
getEditView(createSchemaObj(), getInitData);
});
it('properties', () => {
getPropertiesView(schemaObj, getInitData);
getPropertiesView(createSchemaObj(), getInitData);
});
it('validate', () => {

View File

@@ -46,13 +46,13 @@ function getFieldDepChange(schema, id) {
}
describe('ColumnSchema', ()=>{
let schemaObj = new ColumnSchema(
const createSchemaObj = () => new ColumnSchema(
()=>new MockSchema(),
{},
()=>Promise.resolve([]),
()=>Promise.resolve([]),
);
let schemaObj = createSchemaObj();
let datatypes = [
{value: 'numeric', length: true, precision: true, min_val: 1, max_val: 140391},
{value: 'character varying', length: true, precision: false, min_val: 1, max_val: 140391},
@@ -64,15 +64,15 @@ describe('ColumnSchema', ()=>{
});
it('create', async ()=>{
await getCreateView(schemaObj);
await getCreateView(createSchemaObj());
});
it('edit', async ()=>{
await getEditView(schemaObj, getInitData);
await getEditView(createSchemaObj(), getInitData);
});
it('properties', async ()=>{
await getPropertiesView(schemaObj, getInitData);
await getPropertiesView(createSchemaObj(), getInitData);
});
it('create collection', async ()=>{

Some files were not shown because too many files have changed in this diff Show More