diff --git a/web/pgadmin/static/js/SchemaView/DataGridView.jsx b/web/pgadmin/static/js/SchemaView/DataGridView.jsx index 23a69c895..d1e1cde82 100644 --- a/web/pgadmin/static/js/SchemaView/DataGridView.jsx +++ b/web/pgadmin/static/js/SchemaView/DataGridView.jsx @@ -9,7 +9,7 @@ /* The DataGridView component is based on react-table component */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Box } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { PgIconButton } from '../components/Buttons'; @@ -28,6 +28,9 @@ import FormView from './FormView'; import { confirmDeleteRow } from '../helpers/legacyConnector'; import CustomPropTypes from 'sources/custom_prop_types'; import { evalFunc } from 'sources/utils'; +import { useOnScreen } from '../custom_hooks'; +import { DepListenerContext } from './DepListener'; +import { getSchemaRow } from '../utils'; const useStyles = makeStyles((theme)=>({ grid: { @@ -57,6 +60,9 @@ const useStyles = makeStyles((theme)=>({ padding: 0, minWidth: 0, backgroundColor: 'inherit', + '&.Mui-disabled': { + border: 0, + }, }, gridTableContainer: { overflow: 'auto', @@ -138,13 +144,33 @@ DataTableHeader.propTypes = { headerGroups: PropTypes.array.isRequired, }; -function DataTableRow({row, totalRows, isResizing}) { +function DataTableRow({row, totalRows, isResizing, schema, accessPath}) { const classes = useStyles(); const [key, setKey] = useState(false); + const depListener = useContext(DepListenerContext); /* 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 */ + + useEffect(()=>{ + /* Calculate the fields which depends on the current field + deps has info on fields which the current field depends on. */ + schema.fields.forEach((field)=>{ + /* Self change is also dep change */ + if(field.depChange) { + depListener.addDepListener(accessPath.concat(field.id), accessPath.concat(field.id), field.depChange); + } + (evalFunc(null, field.deps) || []).forEach((dep)=>{ + let source = accessPath.concat(dep); + if(_.isArray(dep)) { + source = dep; + } + depListener.addDepListener(source, accessPath.concat(field.id), field.depChange); + }); + }); + }, []); + let depsMap = _.values(row.values, Object.keys(row.values).filter((k)=>!k.startsWith('btn'))); depsMap = depsMap.concat([totalRows, row.isExpanded, key, isResizing]); return useMemo(()=> @@ -168,18 +194,7 @@ function DataTableRow({row, totalRows, isResizing}) { export default function DataGridView({ value, viewHelperProps, formErr, schema, accessPath, dataDispatch, containerClassName, ...props}) { const classes = useStyles(); - /* Calculate the fields which depends on the current field - deps has info on fields which the current field depends on. */ - const dependsOnField = useMemo(()=>{ - let res = {}; - schema.fields.forEach((field)=>{ - (field.deps || []).forEach((dep)=>{ - res[dep] = res[dep] || []; - res[dep].push(field.id); - }); - }); - return res; - }, []); + /* Using ref so that schema variable is not frozen in columns closure */ const schemaRef = useRef(schema); let columns = useMemo( @@ -195,11 +210,17 @@ export default function DataGridView({ dataType: 'edit', width: 30, minWidth: '0', - Cell: ({row})=>} className={classes.gridRowButton} - onClick={()=>{ - row.toggleRowExpanded(!row.isExpanded); - }} - /> + Cell: ({row})=>{ + let canEditRow = true; + if(props.canEditRow) { + canEditRow = evalFunc(schemaRef.current, props.canEditRow, row.original || {}); + } + return } className={classes.gridRowButton} + onClick={()=>{ + row.toggleRowExpanded(!row.isExpanded); + }} disabled={!canEditRow} + /> + } }; colInfo.Cell.displayName = 'Cell', colInfo.Cell.propTypes = { @@ -218,17 +239,23 @@ export default function DataGridView({ width: 30, minWidth: '0', Cell: ({row}) => { + let canDeleteRow = true; + if(props.canDeleteRow) { + canDeleteRow = evalFunc(schemaRef.current, props.canDeleteRow, row.original || {}); + } + return ( } onClick={()=>{ confirmDeleteRow(()=>{ + /* Get the changes on dependent fields as well */ dataDispatch({ type: SCHEMA_STATE_ACTIONS.DELETE_ROW, path: accessPath, value: row.index, }); }, ()=>{}, props.customDeleteTitle, props.customDeleteMsg); - }} className={classes.gridRowButton} /> + }} className={classes.gridRowButton} disabled={!canDeleteRow} /> ); } }; @@ -287,25 +314,10 @@ export default function DataGridView({ disabled={!editable} visible={_visible} onCellChange={(value)=>{ - /* Get the changes on dependent fields as well. - * The return value of depChange function is merged and passed to state. - */ - const depChange = (state)=>{ - let rowdata = _.get(state, accessPath.concat(row.index)); - _field.depChange && _.merge(rowdata, _field.depChange(rowdata, _field.id) || {}); - (dependsOnField[_field.id] || []).forEach((d)=>{ - d = _.find(schemaRef.current.fields, (f)=>f.id==d); - if(d.depChange) { - _.merge(rowdata, d.depChange(rowdata, _field.id) || {}); - } - }); - return state; - }; dataDispatch({ type: SCHEMA_STATE_ACTIONS.SET_VALUE, path: accessPath.concat([row.index, _field.id]), value: value, - depChange: depChange, }); }} reRenderRow={other.reRenderRow} @@ -326,12 +338,7 @@ export default function DataGridView({ ); const onAddClick = useCallback(()=>{ - let newRow = {}; - columns.forEach((column)=>{ - if(column.field) { - newRow[column.field.id] = schemaRef.current.defaults[column.field.id]; - } - }); + let newRow = schemaRef.current.getNewData(); dataDispatch({ type: SCHEMA_STATE_ACTIONS.ADD_ROW, path: accessPath, @@ -387,12 +394,12 @@ export default function DataGridView({ {rows.map((row, i) => { prepareRow(row); return - + {props.canEdit && row.isExpanded && + schema={schemaRef.current} accessPath={accessPath.concat([row.index])} isNested={true} className={classes.expandedForm} + isDataGridForm={true}/> } ; })} diff --git a/web/pgadmin/static/js/SchemaView/DepListener.js b/web/pgadmin/static/js/SchemaView/DepListener.js new file mode 100644 index 000000000..13ad9c302 --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/DepListener.js @@ -0,0 +1,77 @@ +import _ from 'lodash'; +import React from 'react'; + +export const DepListenerContext = React.createContext(); + +export default class DepListener { + constructor() { + this._depListeners = []; + } + + /* Will keep track of the dependent fields and there callbacks */ + addDepListener(source, dest, callback, defCallback) { + this._depListeners = this._depListeners || []; + this._depListeners.push({ + source: source, + dest: dest, + callback: callback, + defCallback: defCallback + }); + } + + _getListenerData(state, listener, actionObj) { + /* Get data at same level */ + let data = state; + let dataPath = _.slice(listener.dest, 0, -1); + if(dataPath.length > 0) { + data = _.get(state, dataPath); + } + data = _.assign(data, listener.callback && listener.callback(data, listener.source, state, actionObj) || {}); + return state; + } + + _getDefListenerPromise(state, listener, actionObj) { + /* Get data at same level */ + let data = state; + let dataPath = _.slice(listener.dest, 0, -1); + if(dataPath.length > 0) { + data = _.get(state, dataPath); + } + return (listener.defCallback && listener.defCallback(data, listener.source, state, actionObj)); + } + + /* Called when any field changed and trigger callbacks */ + getDepChange(currPath, state, actionObj) { + if(actionObj.depChangeResolved) { + state = this._getListenerData(state, {callback: actionObj.depChangeResolved}, actionObj); + } else { + let allListeners = _.filter(this._depListeners, (entry)=>_.join(currPath, '|').startsWith(_.join(entry.source, '|'))); + if(allListeners) { + for(const listener of allListeners) { + state = this._getListenerData(state, listener, actionObj); + } + } + } + return state; + } + + getDeferredDepChange(currPath, state, actionObj) { + let deferredList = []; + let allListeners = _.filter(this._depListeners, (entry)=>_.join(currPath, '|').startsWith(_.join(entry.source, '|'))); + if(allListeners) { + for(const listener of allListeners) { + if(listener.defCallback) { + let thePromise = this._getDefListenerPromise(state, listener, actionObj); + if(thePromise) { + deferredList.push({ + action: actionObj, + promise: thePromise, + }); + } + } + + } + } + return deferredList; + } +} diff --git a/web/pgadmin/static/js/SchemaView/FormView.jsx b/web/pgadmin/static/js/SchemaView/FormView.jsx index 020e54efe..c9891c6cf 100644 --- a/web/pgadmin/static/js/SchemaView/FormView.jsx +++ b/web/pgadmin/static/js/SchemaView/FormView.jsx @@ -7,7 +7,7 @@ // ////////////////////////////////////////////////////////////// -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Box, makeStyles, Tab, Tabs } from '@material-ui/core'; import _ from 'lodash'; import PropTypes from 'prop-types'; @@ -21,6 +21,8 @@ import { InputSQL } from '../components/FormComponents'; import gettext from 'sources/gettext'; import { evalFunc } from 'sources/utils'; import CustomPropTypes from '../custom_prop_types'; +import { useOnScreen } from '../custom_hooks'; +import { DepListenerContext } from './DepListener'; const useStyles = makeStyles((theme)=>({ fullSpace: { @@ -33,6 +35,9 @@ const useStyles = makeStyles((theme)=>({ nestedTabPanel: { backgroundColor: theme.otherVars.headerBg, }, + nestedControl: { + height: 'unset', + } })); /* Optional SQL tab */ @@ -67,28 +72,52 @@ SQLTab.propTypes = { /* The first component of schema view form */ export default function FormView({ - value, formErr, schema={}, viewHelperProps, isNested=false, accessPath, dataDispatch, hasSQLTab, getSQLValue, onTabChange, firstEleRef, className}) { + value, formErr, schema={}, viewHelperProps, isNested=false, accessPath, dataDispatch, hasSQLTab, + getSQLValue, onTabChange, firstEleRef, className, isDataGridForm=false}) { let defaultTab = 'General'; let tabs = {}; let tabsClassname = {}; const [tabValue, setTabValue] = useState(0); const classes = useStyles(); const firstElement = useRef(); + const formRef = useRef(); + const onScreenTracker = useRef(false); + const depListener = useContext(DepListenerContext); let groupLabels = {}; schema = schema || {fields: []}; - /* Calculate the fields which depends on the current field - deps has info on fields which the current field depends on. */ - const dependsOnField = useMemo(()=>{ - let res = {}; - schema.fields.forEach((field)=>{ - (field.deps || []).forEach((dep)=>{ - res[dep] = res[dep] || []; - res[dep].push(field.id); + let isOnScreen = useOnScreen(formRef); + if(isOnScreen) { + /* Don't do it when the form is alredy visible */ + if(onScreenTracker.current == false) { + /* Re-select the tab. If form is hidden then sometimes it is not selected */ + setTabValue(tabValue); + onScreenTracker.current = true; + } + } else { + onScreenTracker.current = false; + } + + useEffect(()=>{ + /* Calculate the fields which depends on the current field */ + if(!isDataGridForm) { + schema.fields.forEach((field)=>{ + /* Self change is also dep change */ + if(field.depChange || field.deferredDepChange) { + depListener.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) { + depListener.addDepListener(source, accessPath.concat(field.id), field.depChange); + } + }); }); - }); - return res; + } }, []); /* Prepare the array of components based on the types */ @@ -127,19 +156,36 @@ export default function FormView({ /* Lets choose the path based on type */ if(field.type === 'nested-tab') { /* Pass on the top schema */ - field.schema.top = schema.top; + if(isNested) { + field.schema.top = schema.top; + } else { + field.schema.top = schema; + } + tabs[group].push( + schema={field.schema} accessPath={accessPath} dataDispatch={dataDispatch} isNested={true} {...field}/> ); } else if(field.type === 'collection') { - /* Pass on the top schema */ - field.schema.top = schema.top; /* If its a collection, let data grid view handle it */ + let depsMap = [value[field.id]]; + /* Pass on the top schema */ + if(isNested) { + field.schema.top = schema.top; + } else { + field.schema.top = schema; + } + + /* Eval the params based on state */ + let {canAdd, canEdit, canDelete, ..._field} = field; + canAdd = evalFunc(schema, canAdd, value); + canEdit = evalFunc(schema, canAdd, value); + canDelete = evalFunc(schema, canAdd, value); + tabs[group].push( - useMemo(()=>, [value[field.id]]) + useMemo(()=>, depsMap) ); } else if(field.type === 'group') { groupLabels[field.id] = field.label; @@ -171,21 +217,10 @@ export default function FormView({ {...field} onChange={(value)=>{ /* Get the changes on dependent fields as well */ - const depChange = (state)=>{ - field.depChange && _.merge(state, field.depChange(state) || {}); - (dependsOnField[field.id] || []).forEach((d)=>{ - d = _.find(schema.fields, (f)=>f.id==d); - if(d.depChange) { - _.merge(state, d.depChange(state) || {}); - } - }); - return state; - }; dataDispatch({ type: SCHEMA_STATE_ACTIONS.SET_VALUE, path: accessPath.concat(field.id), value: value, - depChange: depChange, }); }} hasError={hasError} @@ -197,7 +232,7 @@ export default function FormView({ _visible, hasError, classes.controlRow, - ...(field.deps || []).map((dep)=>value[dep]) + ...(evalFunc(null, field.deps) || []).map((dep)=>value[dep]), ]) ); } @@ -206,8 +241,8 @@ export default function FormView({ /* Add the SQL tab if required */ let sqlTabActive = false; + let sqlTabName = gettext('SQL'); if(hasSQLTab) { - let sqlTabName = gettext('SQL'); sqlTabActive = (Object.keys(tabs).length === tabValue); /* Re-render and fetch the SQL tab when it is active */ tabs[sqlTabName] = [ @@ -226,7 +261,7 @@ export default function FormView({ return ( <> - + {Object.keys(tabs).map((tabName, i)=>{ return ( - + {tabs[tabName]} ); diff --git a/web/pgadmin/static/js/SchemaView/base_schema.ui.js b/web/pgadmin/static/js/SchemaView/base_schema.ui.js index 7f9091dda..b8aa7cbea 100644 --- a/web/pgadmin/static/js/SchemaView/base_schema.ui.js +++ b/web/pgadmin/static/js/SchemaView/base_schema.ui.js @@ -7,6 +7,8 @@ // ////////////////////////////////////////////////////////////// +import _ from "lodash"; + /* This is the base schema class for SchemaView. * A UI schema must inherit this to use SchemaView for UI. */ @@ -28,8 +30,7 @@ export default class BaseUISchema { } get top() { - /* If no top, I'm the top */ - return this._top || this; + return this._top; } /* The original data before any changes */ @@ -41,6 +42,16 @@ export default class BaseUISchema { return this._origData || {}; } + /* 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 allowed anywhere */ + get sessData() { + return this._sessData || {}; + } + + set sessData(val) { + throw new Error('Property sessData is readonly.', val); + } + /* Property allows to restrict setting this later */ get defaults() { return this._defaults || {}; @@ -102,4 +113,17 @@ export default class BaseUISchema { validate() { return false; } + + /* Returns the new data row for the schema based on defaults and input */ + getNewData(data={}) { + let newRow = {}; + this.fields.forEach((field)=>{ + if(!_.isUndefined(data[field.id])){ + newRow[field.id] = data[field.id]; + } else { + newRow[field.id] = this.defaults[field.id]; + } + }); + return newRow; + } } diff --git a/web/pgadmin/static/js/SchemaView/index.jsx b/web/pgadmin/static/js/SchemaView/index.jsx index b66e2f9be..cb7ba49bc 100644 --- a/web/pgadmin/static/js/SchemaView/index.jsx +++ b/web/pgadmin/static/js/SchemaView/index.jsx @@ -34,6 +34,7 @@ import { evalFunc } from 'sources/utils'; import PropTypes from 'prop-types'; import CustomPropTypes from '../custom_prop_types'; import { parseApiError } from '../api_instance'; +import DepListener, {DepListenerContext} from './DepListener'; const useDialogStyles = makeStyles((theme)=>({ root: { @@ -221,8 +222,36 @@ export const SCHEMA_STATE_ACTIONS = { ADD_ROW: 'add_row', DELETE_ROW: 'delete_row', RERENDER: 'rerender', + CLEAR_DEFERRED_QUEUE: 'clear_deferred_queue', + DEFERRED_DEPCHANGE: 'deferred_depchange', }; +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), + depChangeResolved: action.depChangeResolved, + }); + } + return newState; +} + +const getDeferredDepChange = (currPath, newState, oldState, action)=>{ + if(action.deferredDepChange) { + let deferredPromiseList = action.deferredDepChange(currPath, newState, { + type: action.type, + path: action.path, + value: action.value, + depChange: action.depChange, + oldState: _.cloneDeep(oldState), + }); + return deferredPromiseList + } +} + /* The main function which manipulates the session state based on actions */ /* The state is managed based on path array of a particular key @@ -243,6 +272,7 @@ The state starts with path [] const sessDataReducer = (state, action)=>{ let data = _.cloneDeep(state); let rows, cid; + data.__deferred__ = data.__deferred__ || []; switch(action.type) { case SCHEMA_STATE_ACTIONS.INIT: data = action.payload; @@ -250,9 +280,10 @@ const sessDataReducer = (state, action)=>{ case SCHEMA_STATE_ACTIONS.SET_VALUE: _.set(data, action.path, action.value); /* If there is any dep listeners get the changes */ - if(action.depChange) { - data = action.depChange(data); - } + data = getDepChange(action.path, data, state, action); + let deferredList = getDeferredDepChange(action.path, data, state, action); + // let deferredInfo = 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 */ @@ -260,11 +291,21 @@ const sessDataReducer = (state, action)=>{ action.value['cid'] = cid; 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.CLEAR_DEFERRED_QUEUE: + data.__deferred__ = []; + break; + case SCHEMA_STATE_ACTIONS.DEFERRED_DEPCHANGE: + data = getDepChange(action.path, data, state, action); break; } return data; @@ -317,12 +358,16 @@ function SchemaDialogView({ const [formReady, setFormReady] = useState(false); const firstEleRef = useRef(); const isNew = schema.isNew(schema.origData); + + const depListenerObj = useRef(new DepListener()); /* The session data */ const [sessData, sessDispatch] = useReducer(sessDataReducer, {}); useEffect(()=>{ /* if sessData changes, validate the schema */ if(!formReady) return; + /* Set the _sessData, can be usefull to some deep controls */ + schema._sessData = sessData; let isNotValid = validateSchema(schema, sessData, (name, message)=>{ if(message) { setFormErr({ @@ -341,6 +386,25 @@ function SchemaDialogView({ props.onDataChange && props.onDataChange(dataChanged); }, [sessData]); + useEffect(()=>{ + if(sessData.__deferred__?.length > 0) { + sessDispatch({ + type: SCHEMA_STATE_ACTIONS.CLEAR_DEFERRED_QUEUE, + }); + + // let deferredDepChang = sessData.__deferred__[0]; + let item = sessData.__deferred__[0]; + item.promise.then((resFunc)=>{ + sessDispatch({ + type: SCHEMA_STATE_ACTIONS.DEFERRED_DEPCHANGE, + path: item.action.path, + depChange: item.action.depChange, + depChangeResolved: resFunc, + }); + }); + } + }, [sessData.__deferred__?.length]); + useEffect(()=>{ /* Docker on load focusses itself, so our focus should execute later */ let focusTimeout = setTimeout(()=>{ @@ -470,37 +534,47 @@ function SchemaDialogView({ } }; + const sessDispatchWithListener = (action)=>{ + sessDispatch({ + ...action, + depChange: (...args)=>depListenerObj.current.getDepChange(...args), + deferredDepChange: (...args)=>depListenerObj.current.getDeferredDepChange(...args), + }); + }; + /* I am Groot */ return ( - - - - setSqlTabActive(sqlActive)} - firstEleRef={firstEleRef} /> - - - - {useMemo(()=> - props.onHelp(true, isNew)} icon={} - disabled={props.disableSqlHelp} className={classes.buttonMargin} title="SQL help for this object type."/> - props.onHelp(false, isNew)} icon={} title="Help for this dialog."/> - , [])} - - } className={classes.buttonMargin}> - {gettext('Close')} - - } disabled={!dirty || saving} className={classes.buttonMargin}> - {gettext('Reset')} - - } disabled={!dirty || saving || Boolean(formErr.name) || !formReady}> - {gettext('Save')} - + + + + + setSqlTabActive(sqlActive)} + firstEleRef={firstEleRef} /> + + + + {useMemo(()=> + props.onHelp(true, isNew)} icon={} + disabled={props.disableSqlHelp} className={classes.buttonMargin} title="SQL help for this object type."/> + props.onHelp(false, isNew)} icon={} title="Help for this dialog."/> + , [])} + + } className={classes.buttonMargin}> + {gettext('Close')} + + } disabled={!dirty || saving} className={classes.buttonMargin}> + {gettext('Reset')} + + } disabled={!dirty || saving || Boolean(formErr.name) || !formReady}> + {gettext('Save')} + + - + ); } diff --git a/web/pgadmin/static/js/components/TabPanel.jsx b/web/pgadmin/static/js/components/TabPanel.jsx index ce46ba707..1e476d6dd 100644 --- a/web/pgadmin/static/js/components/TabPanel.jsx +++ b/web/pgadmin/static/js/components/TabPanel.jsx @@ -19,6 +19,9 @@ const useStyles = makeStyles((theme)=>({ padding: theme.spacing(1), overflow: 'auto', backgroundColor: theme.palette.grey[400] + }, + content: { + height: '100%', } })); @@ -28,7 +31,7 @@ export default function TabPanel({children, classNameRoot, className, value, ind const active = value === index; return ( ); } diff --git a/web/pgadmin/static/js/validators.js b/web/pgadmin/static/js/validators.js index a3343480a..fee3e7358 100644 --- a/web/pgadmin/static/js/validators.js +++ b/web/pgadmin/static/js/validators.js @@ -47,14 +47,14 @@ export function integerValidator(label, value) { /* Validate value to check if it is empty */ export function emptyValidator(label, value) { - if(isEmptyString(value) || String(value).replace(/^\s+|\s+$/g, '') == '') { + if(isEmptyString(value)) { return sprintf(pgAdmin.Browser.messages.CANNOT_BE_EMPTY, label); } return null; } -export function isEmptyString(string) { - return _.isUndefined(string) || _.isNull(string) || String(string).trim() === ''; +export function isEmptyString(value) { + return _.isUndefined(value) || _.isNull(value) || String(value).trim() === '' || String(value).replace(/^\s+|\s+$/g, '') == ''; } /* Validate rows to check for any duplicate rows based on uniqueCols-columns array */