From 52af8d3e49cfda531a5c6f3a7065672c6b2e272b Mon Sep 17 00:00:00 2001 From: Ashesh Vashi Date: Fri, 2 Aug 2024 09:59:01 +0530 Subject: [PATCH] Introduce custom React Hook useSchemaState to simplify SchemaView component. #7776 Changes include: - Simplify current SchemaView code - Add ability to reuse the schema data & state management implementation outside the SchemaDialogView component. - Further split components in small and manageable separate files. - Removed the 'DepListenerContext' context as there was no need for separate context. - Added a reload functionality in the 'useSchemaState' - Changes in feature tests. --- web/package.json | 2 +- .../misc/properties/ObjectNodeProperties.jsx | 13 +- .../js/components/PreferencesComponent.jsx | 18 +- .../static/js/SchemaView/DataGridView.jsx | 191 ++- .../static/js/SchemaView/DepListener.js | 4 +- .../static/js/SchemaView/FieldSetView.jsx | 175 +-- web/pgadmin/static/js/SchemaView/FormView.jsx | 521 ++++---- .../static/js/SchemaView/SchemaDialogView.jsx | 301 +++++ .../js/SchemaView/SchemaPropertiesView.jsx | 214 ++++ .../static/js/SchemaView/SchemaView.jsx | 34 + .../static/js/SchemaView/StyledComponents.jsx | 170 +++ .../static/js/SchemaView/base_schema.ui.js | 18 +- web/pgadmin/static/js/SchemaView/common.js | 167 +++ web/pgadmin/static/js/SchemaView/index.jsx | 1075 +---------------- .../static/js/SchemaView/schemaUtils.js | 333 +++++ .../static/js/SchemaView/useSchemaState.js | 481 ++++++++ web/pgadmin/static/js/helpers/Notifier.jsx | 7 +- .../pg_utilities_backup_restore_test.py | 6 +- web/regression/feature_utils/pgadmin_page.py | 28 +- .../SchemaView/SchemaDialogViewEdit.spec.js | 2 +- 20 files changed, 2190 insertions(+), 1570 deletions(-) create mode 100644 web/pgadmin/static/js/SchemaView/SchemaDialogView.jsx create mode 100644 web/pgadmin/static/js/SchemaView/SchemaPropertiesView.jsx create mode 100644 web/pgadmin/static/js/SchemaView/SchemaView.jsx create mode 100644 web/pgadmin/static/js/SchemaView/StyledComponents.jsx create mode 100644 web/pgadmin/static/js/SchemaView/common.js create mode 100644 web/pgadmin/static/js/SchemaView/schemaUtils.js create mode 100644 web/pgadmin/static/js/SchemaView/useSchemaState.js diff --git a/web/package.json b/web/package.json index 934a93944..b004b43e4 100644 --- a/web/package.json +++ b/web/package.json @@ -172,7 +172,7 @@ "pep8": "pycodestyle --config=../.pycodestyle ../docs && pycodestyle --config=../.pycodestyle ../pkg && pycodestyle --config=../.pycodestyle ../tools && pycodestyle --config=../.pycodestyle ../web", "auditjs-html": "yarn audit --json | yarn run yarn-audit-html --output ../auditjs.html", "auditjs": "yarn audit --groups dependencies", - "auditpy": "safety check --full-report -i 51668 -i 52495yarn npm audit", + "auditpy": "safety check --full-report -i 51668 -i 52495", "audit-all": "yarn run auditjs && yarn run auditpy" }, "packageManager": "yarn@3.8.3", diff --git a/web/pgadmin/misc/properties/ObjectNodeProperties.jsx b/web/pgadmin/misc/properties/ObjectNodeProperties.jsx index 8b02d590e..4aa0718de 100644 --- a/web/pgadmin/misc/properties/ObjectNodeProperties.jsx +++ b/web/pgadmin/misc/properties/ObjectNodeProperties.jsx @@ -42,7 +42,18 @@ export default function ObjectNodeProperties({panelId, node, treeNodeInfo, nodeD let warnOnCloseFlag = true; const confirmOnCloseReset = usePreferences().getPreferencesForModule('browser').confirm_on_properties_close; let updatedData = ['table', 'partition'].includes(nodeType) && !_.isEmpty(nodeData.rows_cnt) ? {rows_cnt: nodeData.rows_cnt} : undefined; - let schema = node.getSchema(treeNodeInfo, nodeData); + + const objToString = (obj) => ( + (obj && typeof obj === 'object') ? Object.keys(obj).sort().reduce( + (acc, key) => (acc + `${key}=` + objToString(obj[key])), '' + ) : String(obj) + ); + + const treeNodeId = objToString(treeNodeInfo); + + let schema = useMemo( + () => node.getSchema(treeNodeInfo, nodeData), [treeNodeId, isActive] + ); // We only have two actionTypes, 'create' and 'edit' to initiate the dialog, // so if isActionTypeCopy is true, we should revert back to "create" since diff --git a/web/pgadmin/preferences/static/js/components/PreferencesComponent.jsx b/web/pgadmin/preferences/static/js/components/PreferencesComponent.jsx index 05baaf0af..400fc9337 100644 --- a/web/pgadmin/preferences/static/js/components/PreferencesComponent.jsx +++ b/web/pgadmin/preferences/static/js/components/PreferencesComponent.jsx @@ -491,7 +491,9 @@ export default function PreferencesComponent({ ...props }) { function savePreferences(data, initVal) { let _data = []; for (const [key, value] of Object.entries(data.current)) { - let _metadata = prefSchema.current.schemaFields.filter((el) => { return el.id == key; }); + let _metadata = prefSchema.current.schemaFields.filter( + (el) => { return el.id == key; } + ); if (_metadata.length > 0) { let val = getCollectionValue(_metadata, value, initVal); _data.push({ @@ -525,7 +527,11 @@ export default function PreferencesComponent({ ...props }) { data: save_data, }).then(() => { let requiresTreeRefresh = save_data.some((s)=>{ - return s.name=='show_system_objects'||s.name=='show_empty_coll_nodes'||s.name.startsWith('show_node_')||s.name=='hide_shared_server'||s.name=='show_user_defined_templates'; + return ( + s.name=='show_system_objects' || s.name=='show_empty_coll_nodes' || + s.name.startsWith('show_node_') || s.name=='hide_shared_server' || + s.name=='show_user_defined_templates' + ); }); let requires_refresh = false; for (const [key] of Object.entries(data.current)) { @@ -536,11 +542,15 @@ export default function PreferencesComponent({ ...props }) { if (requiresTreeRefresh) { pgAdmin.Browser.notifier.confirm( gettext('Object explorer refresh required'), - gettext('An object explorer refresh is required. Do you wish to refresh it now?'), + gettext( + 'An object explorer refresh is required. Do you wish to refresh it now?' + ), function () { pgAdmin.Browser.tree.destroy().then( () => { - pgAdmin.Browser.Events.trigger('pgadmin-browser:tree:destroyed', undefined, undefined); + pgAdmin.Browser.Events.trigger( + 'pgadmin-browser:tree:destroyed', undefined, undefined + ); return true; } ); diff --git a/web/pgadmin/static/js/SchemaView/DataGridView.jsx b/web/pgadmin/static/js/SchemaView/DataGridView.jsx index ab51a267c..aab6f7fec 100644 --- a/web/pgadmin/static/js/SchemaView/DataGridView.jsx +++ b/web/pgadmin/static/js/SchemaView/DataGridView.jsx @@ -10,12 +10,8 @@ /* The DataGridView component is based on react-table component */ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { styled } from '@mui/material/styles'; import { Box } from '@mui/material'; -import { PgIconButton } from '../components/Buttons'; import AddIcon from '@mui/icons-material/AddOutlined'; -import { MappedCellControl } from './MappedControl'; - import { useReactTable, getCoreRowModel, @@ -24,103 +20,41 @@ import { 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 gettext from 'sources/gettext'; -import { SCHEMA_STATE_ACTIONS, StateUtilsContext } from '.'; -import FormView, { getFieldMetaData } from './FormView'; -import CustomPropTypes from 'sources/custom_prop_types'; -import { evalFunc } from 'sources/utils'; -import { DepListenerContext } from './DepListener'; -import { useIsMounted } from '../custom_hooks'; -import { InputText } from '../components/FormComponents'; -import { usePgAdmin } from '../BrowserComponent'; -import { requestAnimationAndFocus } from '../utils'; -import { PgReactTable, PgReactTableBody, PgReactTableCell, PgReactTableHeader, +import { usePgAdmin } from 'sources/BrowserComponent'; +import { PgIconButton } from 'sources/components/Buttons'; +import { + PgReactTable, PgReactTableBody, PgReactTableCell, PgReactTableHeader, PgReactTableRow, PgReactTableRowContent, PgReactTableRowExpandContent, - getDeleteCell, getEditCell, getReorderCell } from '../components/PgReactTableStyled'; -import { useVirtualizer } from '@tanstack/react-virtual'; + 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'; -const StyledBox = styled(Box)(({theme}) => ({ - '& .DataGridView-grid': { - ...theme.mixins.panelBorder, - backgroundColor: theme.palette.background.default, - display: 'flex', - flexDirection: 'column', - minHeight: 0, - height: '100%', - '& .DataGridView-gridHeader': { - display: 'flex', - ...theme.mixins.panelBorder.bottom, - backgroundColor: theme.otherVars.headerBg, - '& .DataGridView-gridHeaderText': { - padding: theme.spacing(0.5, 1), - fontWeight: theme.typography.fontWeightBold, - }, - '& .DataGridView-gridControls': { - marginLeft: 'auto', - '& .DataGridView-gridControlsButton': { - border: 0, - borderRadius: 0, - ...theme.mixins.panelBorder.left, - }, - }, - }, - '& .DataGridView-table': { - '&.pgrt-table': { - '& .pgrt-body':{ - '& .pgrt-row': { - backgroundColor: theme.otherVars.emptySpaceBg, - '& .pgrt-row-content':{ - '& .pgrd-row-cell': { - height: 'auto', - padding: theme.spacing(0.5), - '&.btn-cell, &.expanded-icon-cell': { - padding: '2px 0px' - }, - } - }, - } - } - } - }, - }, - '& .DataGridView-tableRowHovered': { - position: 'relative', - '& .hover-overlay': { - backgroundColor: theme.palette.primary.light, - position: 'absolute', - inset: 0, - opacity: 0.75, - } - }, - '& .DataGridView-resizer': { - display: 'inline-block', - width: '5px', - height: '100%', - position: 'absolute', - right: 0, - top: 0, - transform: 'translateX(50%)', - zIndex: 1, - touchAction: 'none', - }, - '& .DataGridView-expandedForm': { - border: '1px solid '+theme.palette.grey[400], - }, - '& .DataGridView-expandedIconCell': { - backgroundColor: theme.palette.grey[400], - borderBottom: 'none', - } -})); +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}) { + +function DataTableRow({ + index, row, totalRows, isResizing, isHovered, schema, schemaRef, accessPath, + moveRow, setHoverIndex, viewHelperProps +}) { const [key, setKey] = useState(false); - const depListener = useContext(DepListenerContext); + const schemaState = useContext(SchemaStateContext); const rowRef = useRef(null); const dragHandleRef = useRef(null); @@ -150,7 +84,7 @@ function DataTableRow({index, row, totalRows, isResizing, isHovered, schema, sch schemaRef.current.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); + 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); @@ -158,14 +92,14 @@ function DataTableRow({index, row, totalRows, isResizing, isHovered, schema, sch source = dep; } if(field.depChange) { - depListener?.addDepListener(source, accessPath.concat(field.id), field.depChange); + schemaState?.addDepListener(source, accessPath.concat(field.id), field.depChange); } }); }); return ()=>{ /* Cleanup the listeners when unmounting */ - depListener?.removeDepListener(accessPath); + schemaState?.removeDepListener(accessPath); }; }, []); @@ -227,9 +161,14 @@ function DataTableRow({index, row, totalRows, isResizing, isHovered, schema, sch drop(rowRef); return useMemo(()=> - + {row.getVisibleCells().map((cell) => { - let {modeSupported} = cell.column.field ? getFieldMetaData(cell.column.field, schemaRef.current, {}, viewHelperProps) : {modeSupported: true}; + // 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, @@ -237,8 +176,9 @@ function DataTableRow({index, row, totalRows, isResizing, isHovered, schema, sch reRenderRow: ()=>{setKey((currKey)=>!currKey);} }); - return (modeSupported && - + return ( + {content} ); @@ -337,9 +277,10 @@ function getMappedCell({ export default function DataGridView({ value, viewHelperProps, schema, accessPath, dataDispatch, containerClassName, - fixedRows, ...props}) { + fixedRows, ...props +}) { - const stateUtils = useContext(StateUtilsContext); + const schemaState = useContext(SchemaStateContext); const checkIsMounted = useIsMounted(); const [hoverIndex, setHoverIndex] = useState(); const newRowIndex = useRef(); @@ -439,14 +380,14 @@ export default function DataGridView({ } cols = cols.concat( - schemaRef.current.fields.filter((f)=>{ - return _.isArray(props.columns) ? props.columns.indexOf(f.id) > -1 : true; - }).sort((firstF, secondF)=>{ - if(_.isArray(props.columns)) { - return props.columns.indexOf(firstF.id) < props.columns.indexOf(secondF.id) ? -1 : 1; - } - return 0; - }).map((field)=>{ + 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; @@ -461,7 +402,10 @@ export default function DataGridView({ if(field.maxWidth) { widthParms.maxSize = field.maxWidth; } - widthParms.enableResizing = _.isUndefined(field.enableResizing) ? true : Boolean(field.enableResizing); + widthParms.enableResizing = + _.isUndefined(field.enableResizing) ? true : Boolean( + field.enableResizing + ); let colInfo = { header: field.label||<> , @@ -490,8 +434,7 @@ export default function DataGridView({ const ret = {}; columns.forEach(column => { - let {modeSupported} = column.field ? getFieldMetaData(column.field, schemaRef.current, {}, viewHelperProps) : {modeSupported: true}; - ret[column.id] = modeSupported; + ret[column.id] = isModeSupportedByField(column.field, viewHelperProps); }); return ret; @@ -535,19 +478,20 @@ export default function DataGridView({ }); }, [props.canAddRow, rows?.length]); - useEffect(()=>{ + useEffect(() => { let rowsPromise = fixedRows; - /* If fixedRows is defined, fetch the details */ + // If fixedRows is defined, fetch the details. if(typeof rowsPromise === 'function') { rowsPromise = rowsPromise(); } + if(rowsPromise) { Promise.resolve(rowsPromise) - .then((res)=>{ + .then((res) => { /* If component unmounted, dont update state */ if(checkIsMounted()) { - stateUtils.initOrigData(accessPath, res); + schemaState.setUnpreparedData(accessPath, res); } }); } @@ -558,12 +502,17 @@ export default function DataGridView({ virtualizer.scrollToIndex(newRowIndex.current); // Try autofocus on newly added row. - setTimeout(()=>{ - const rowInput = tableRef.current?.querySelector(`.pgrt-row[data-index="${newRowIndex.current}"] input`); + 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); + requestAnimationAndFocus(tableRef.current.querySelector( + `.pgrt-row[data-index="${newRowIndex.current}"] input` + )); + props.expandEditOnAdd && props.canEdit && + rows[newRowIndex.current]?.toggleExpanded(true); newRowIndex.current = undefined; }, 50); } @@ -599,7 +548,7 @@ export default function DataGridView({ } return ( - + {(props.label || props.canAdd) && - + ); } diff --git a/web/pgadmin/static/js/SchemaView/DepListener.js b/web/pgadmin/static/js/SchemaView/DepListener.js index 4bebe0148..b8ea2344a 100644 --- a/web/pgadmin/static/js/SchemaView/DepListener.js +++ b/web/pgadmin/static/js/SchemaView/DepListener.js @@ -7,11 +7,9 @@ // ////////////////////////////////////////////////////////////// import _ from 'lodash'; -import React from 'react'; -export const DepListenerContext = React.createContext(); -export default class DepListener { +export class DepListener { constructor() { this._depListeners = []; } diff --git a/web/pgadmin/static/js/SchemaView/FieldSetView.jsx b/web/pgadmin/static/js/SchemaView/FieldSetView.jsx index ed60182cc..0812ef133 100644 --- a/web/pgadmin/static/js/SchemaView/FieldSetView.jsx +++ b/web/pgadmin/static/js/SchemaView/FieldSetView.jsx @@ -8,38 +8,49 @@ ////////////////////////////////////////////////////////////// import React, { useContext, useEffect } from 'react'; + +import Grid from '@mui/material/Grid'; import _ from 'lodash'; import PropTypes from 'prop-types'; -import { MappedFormControl } from './MappedControl'; -import { SCHEMA_STATE_ACTIONS, StateUtilsContext } from '.'; +import FieldSet from 'sources/components/FieldSet'; +import CustomPropTypes from 'sources/custom_prop_types'; import { evalFunc } from 'sources/utils'; -import CustomPropTypes from '../custom_prop_types'; -import { DepListenerContext } from './DepListener'; -import { getFieldMetaData } from './FormView'; -import FieldSet from '../components/FieldSet'; -import { Grid } from '@mui/material'; + +import { MappedFormControl } from './MappedControl'; +import { + getFieldMetaData, SCHEMA_STATE_ACTIONS, SchemaStateContext +} from './common'; + + +const INLINE_COMPONENT_ROWGAP = '8px'; export default function FieldSetView({ - value, schema={}, viewHelperProps, accessPath, dataDispatch, controlClassName, isDataGridForm=false, label, visible}) { - const depListener = useContext(DepListenerContext); - const stateUtils = useContext(StateUtilsContext); + value, schema={}, viewHelperProps, accessPath, dataDispatch, + controlClassName, isDataGridForm=false, label, visible +}) { + const schemaState = useContext(SchemaStateContext); - useEffect(()=>{ - /* Calculate the fields which depends on the current field */ - if(!isDataGridForm && depListener) { - schema.fields.forEach((field)=>{ + 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) { - depListener.addDepListener(accessPath.concat(field.id), accessPath.concat(field.id), field.depChange, field.deferredDepChange); + schemaState?.addDepListener( + accessPath.concat(field.id), accessPath.concat(field.id), + field.depChange, field.deferredDepChange + ); } - (evalFunc(null, field.deps) || []).forEach((dep)=>{ + (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); + schemaState?.addDepListener( + source, accessPath.concat(field.id), field.depChange + ); } }); }); @@ -49,80 +60,86 @@ export default function FieldSetView({ let viewFields = []; let inlineComponents = []; - /* Prepare the array of components based on the types */ + if(!visible) { + return <>; + } + + // Prepare the array of components based on the types. for(const field of schema.fields) { - let {visible, disabled, readonly, modeSupported} = - getFieldMetaData(field, schema, value, viewHelperProps); + const { + visible, disabled, readonly, modeSupported + } = getFieldMetaData(field, schema, value, viewHelperProps); - if(modeSupported) { - /* Its a form control */ - const hasError = field.id == stateUtils?.formErr.name; - /* When there is a change, the dependent values can change - * lets pass the new changes to dependent and get the new values - * from there as well. - */ - const currentControl = { - /* 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(!modeSupported) continue; - 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( - - {inlineComponents} - - ); - inlineComponents = []; - } else { - viewFields.push(currentControl); - } + // 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 = { + /* 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( + + {inlineComponents} + + ); + inlineComponents = []; + } else { + viewFields.push(currentControl); } } + if(inlineComponents?.length > 0) { viewFields.push( - + {inlineComponents} ); } - if(!visible) { - return <>; - } - return (
{viewFields} diff --git a/web/pgadmin/static/js/SchemaView/FormView.jsx b/web/pgadmin/static/js/SchemaView/FormView.jsx index 6d3b44896..b43ee505a 100644 --- a/web/pgadmin/static/js/SchemaView/FormView.jsx +++ b/web/pgadmin/static/js/SchemaView/FormView.jsx @@ -7,69 +7,29 @@ // ////////////////////////////////////////////////////////////// -import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { styled } from '@mui/material/styles'; +import React, { + useContext, useEffect, useMemo, useRef, useState +} from 'react'; import { Box, Tab, Tabs, Grid } from '@mui/material'; import _ from 'lodash'; import PropTypes from 'prop-types'; -import { MappedFormControl } from './MappedControl'; -import TabPanel from '../components/TabPanel'; -import DataGridView from './DataGridView'; -import { SCHEMA_STATE_ACTIONS, StateUtilsContext } from '.'; -import { FormNote, InputSQL } from '../components/FormComponents'; +import { FormNote, InputSQL } 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 CustomPropTypes from '../custom_prop_types'; -import { useOnScreen } from '../custom_hooks'; -import { DepListenerContext } from './DepListener'; -import FieldSetView from './FieldSetView'; -const StyledBox = styled(Box)(({theme}) => ({ - '& .FormView-nestedControl': { - height: 'unset !important', - '& .FormView-controlRow': { - marginBottom: theme.spacing(1), - }, - '& .FormView-nestedTabPanel': { - backgroundColor: theme.otherVars.headerBg, - } - }, - '& .FormView-errorMargin': { - /* Error footer space */ - paddingBottom: '36px !important', - }, - '& .FormView-fullSpace': { - padding: '0 !important', - height: '100%', - overflow: 'hidden', - '& .FormView-fullControl': { - display: 'flex', - flexDirection: 'column', - '& .FormView-sqlTabInput': { - border: 0, - }, - } - }, - '& .FormView-nonTabPanel': { - ...theme.mixins.tabPanel, - '& .FormView-nonTabPanelContent': { - height: 'unset', - '& .FormView-controlRow': { - marginBottom: theme.spacing(1), - }, - } - }, - '& .FormView-singleCollectionPanel': { - ...theme.mixins.tabPanel, - '& .FormView-singleCollectionPanelContent': { - '& .FormView-controlRow': { - marginBottom: theme.spacing(1), - height: '100%', - }, - } - }, -})); +import DataGridView from './DataGridView'; +import { MappedFormControl } from './MappedControl'; +import FieldSetView from './FieldSetView'; +import { + SCHEMA_STATE_ACTIONS, SchemaStateContext, getFieldMetaData +} from './common'; + +import { FormContentBox } from './StyledComponents'; + /* Optional SQL tab */ function SQLTab({active, getSQLValue}) { @@ -102,77 +62,12 @@ SQLTab.propTypes = { getSQLValue: PropTypes.func.isRequired, }; -export function getFieldMetaData(field, schema, value, viewHelperProps, onlyModeCheck=false) { - let retData = { - readonly: false, - disabled: false, - visible: true, - editable: true, - canAdd: true, - canEdit: false, - canDelete: true, - modeSupported: true, - canAddRow: true, - }; - - if(field.mode) { - retData.modeSupported = (field.mode.indexOf(viewHelperProps.mode) > -1); - } - if(!retData.modeSupported) { - return retData; - } - - if(onlyModeCheck) { - return retData; - } - - let {visible, disabled, readonly, editable} = field; - let verInLimit; - - if (_.isUndefined(viewHelperProps.serverInfo)) { - verInLimit= true; - } else { - verInLimit = ((_.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))); - } - - retData.readonly = viewHelperProps.inCatalog || (viewHelperProps.mode == 'properties'); - if(!retData.readonly) { - retData.readonly = evalFunc(schema, readonly, value); - } - - let _visible = verInLimit; - _visible = _visible && evalFunc(schema, _.isUndefined(visible) ? true : visible, value); - retData.visible = Boolean(_visible); - - retData.disabled = Boolean(evalFunc(schema, disabled, value)); - - 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; - retData.canAdd = _.isUndefined(canAdd) ? retData.canAdd : evalFunc(schema, canAdd, value); - retData.canAdd = !retData.disabled && retData.canAdd; - retData.canEdit = _.isUndefined(canEdit) ? retData.canEdit : evalFunc(schema, canEdit, value); - retData.canEdit = !retData.disabled && retData.canEdit; - retData.canDelete = _.isUndefined(canDelete) ? retData.canDelete : evalFunc(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); - return retData; -} /* 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 = 'General'; + let defaultTab = gettext('General'); let tabs = {}; let tabsClassname = {}; const [tabValue, setTabValue] = useState(0); @@ -180,10 +75,9 @@ export default function FormView({ const firstEleID = useRef(); const formRef = useRef(); const onScreenTracker = useRef(false); - const depListener = useContext(DepListenerContext); let groupLabels = {}; const schemaRef = useRef(schema); - const stateUtils = useContext(StateUtilsContext); + const schemaState = useContext(SchemaStateContext); let isOnScreen = useOnScreen(formRef); @@ -206,7 +100,7 @@ export default function FormView({ schemaRef.current.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); + 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 @@ -217,24 +111,25 @@ export default function FormView({ source = dep; } if(field.depChange || field.deferredDepChange) { - depListener.addDepListener(source, accessPath.concat(field.id), field.depChange, field.deferredDepChange); + schemaState?.addDepListener(source, accessPath.concat(field.id), field.depChange, field.deferredDepChange); } if(field.depChange || field.deferredDepChange) { - depListener.addDepListener(source, accessPath.concat(field.id), field.depChange, field.deferredDepChange); + schemaState?.addDepListener(source, accessPath.concat(field.id), field.depChange, field.deferredDepChange); } }); }); return ()=>{ /* Cleanup the listeners when unmounting */ - depListener.removeDepListener(accessPath); + schemaState?.removeDepListener(accessPath); }; } }, []); /* Upon reset, set the tab to first */ useEffect(()=>{ - setTabValue(0); - }, [stateUtils.formResetKey]); + if (schemaState?.isReady) + setTabValue(0); + }, [schemaState?.isReady]); let fullTabs = []; let inlineComponents = []; @@ -242,185 +137,197 @@ export default function FormView({ /* 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); + let { + visible, disabled, readonly, canAdd, canEdit, canDelete, canReorder, + canAddRow, modeSupported + } = getFieldMetaData(field, schema, value, viewHelperProps); - if(modeSupported) { - let {group, CustomControl} = field; - if(field.type === 'group') { - groupLabels[field.id] = field.label; - if(!visible) { - schemaRef.current.filterGroups.push(field.label); - } - continue; + if(!modeSupported) continue; + + let {group, CustomControl} = field; + + if(field.type === 'group') { + groupLabels[field.id] = field.label; + + if(!visible) { + schemaRef.current.filterGroups.push(field.label); } - group = groupLabels[group] || group || defaultTab; + continue; + } - if(!tabs[group]) tabs[group] = []; + group = groupLabels[group] || group || defaultTab; - /* 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( - - ); - } 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( - - ); - } 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(!tabs[group]) tabs[group] = []; - 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(); - } else { - tabs[group].push(); - } + // 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 { - /* Its a form control */ - const hasError = _.isEqual(accessPath.concat(field.id), stateUtils.formErr.name); - /* When there is a change, the dependent values can change - * lets pass the new changes to dependent and get the new values - * from there as well. - */ - if(field.isFullTab) { - tabsClassname[group] ='FormView-fullSpace'; - fullTabs.push(group); - } + field.schema.top = schema; + } + tabs[group].push( + + ); + } 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( + + ); + } 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; + } - const id = field.id || `control${tabs[group].length}`; - if(visible && !disabled && !firstEleID.current) { - firstEleID.current = field.id; - } + if(!_.isUndefined(field.fixedRows)) { + canAdd = false; + canDelete = false; + } - let currentControl = { - if(firstEleRef && firstEleID.current === field.id) { - if(typeof firstEleRef == 'function') { - firstEleRef(ele); - } else { - firstEleRef.current = ele; - } + 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(); + } else { + tabs[group].push(); + } + } 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 = { + 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]), - ]} - />; + } + }} + 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 = ( - - {currentControl} - ); - } + if(field.isFullTab && field.helpMessage) { + currentControl = ( + + {currentControl} + ); + } - 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( - - {inlineComponents} - - ); - inlineComponents = []; - inlineCompGroup = null; - } else { - tabs[group].push(currentControl); - } + 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( + + {inlineComponents} + + ); + inlineComponents = []; + inlineCompGroup = null; + } else { + tabs[group].push(currentControl); } } } if(inlineComponents?.length > 0) { tabs[inlineCompGroup].push( - + {inlineComponents} ); } - let finalTabs = _.pickBy(tabs, (v, tabName)=>schemaRef.current.filterGroups.indexOf(tabName) <= -1); + let finalTabs = _.pickBy( + tabs, (v, tabName) => schemaRef.current.filterGroups.indexOf(tabName) <= -1 + ); - /* Add the SQL tab if required */ + // 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 */ + // Re-render and fetch the SQL tab when it is active. finalTabs[sqlTabName] = [ , ]; @@ -428,7 +335,7 @@ export default function FormView({ fullTabs.push(sqlTabName); } - useEffect(()=>{ + useEffect(() => { onTabChange?.(tabValue, Object.keys(tabs)[tabValue], sqlTabActive); }, [tabValue]); @@ -437,26 +344,25 @@ export default function FormView({ // 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; - + 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 */ + // Check whether form is kept hidden by visible prop. if(!_.isUndefined(visible) && !visible) { return <>; } if(isTabView) { return ( - + { - setTabValue(selTabValue); - }} + onChange={(event, selTabValue) => { setTabValue(selTabValue); }} variant="scrollable" scrollButtons="auto" action={(ref)=>ref?.updateIndicator()} @@ -467,34 +373,49 @@ export default function FormView({ {Object.keys(finalTabs).map((tabName, i)=>{ - let contentClassName = [(stateUtils.formErr.message ? 'FormView-errorMargin': null)]; + let contentClassName = [( + schemaState.errors?.message ? 'FormView-errorMargin': null + )]; + if(fullTabs.indexOf(tabName) == -1) { contentClassName.push('FormView-nestedControl'); } else { contentClassName.push('FormView-fullControl'); } + return ( - {finalTabs[tabName]} ); })} - + ); } else { - let contentClassName = [isSingleCollection ? 'FormView-singleCollectionPanelContent' : 'FormView-nonTabPanelContent', (stateUtils.formErr.message ? 'FormView-errorMargin' : null)]; + let contentClassName = [ + isSingleCollection ? 'FormView-singleCollectionPanelContent' : + 'FormView-nonTabPanelContent', + (schemaState.errors?.message ? 'FormView-errorMargin' : null) + ]; return ( - + - {Object.keys(finalTabs).map((tabName)=>{ + {Object.keys(finalTabs).map((tabName) => { return ( - {finalTabs[tabName]} + + {finalTabs[tabName]} + ); })} - ); + + ); } } diff --git a/web/pgadmin/static/js/SchemaView/SchemaDialogView.jsx b/web/pgadmin/static/js/SchemaView/SchemaDialogView.jsx new file mode 100644 index 000000000..6283ae2e6 --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/SchemaDialogView.jsx @@ -0,0 +1,301 @@ +///////////////////////////////////////////////////////////// +// +// 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 CloseIcon from '@mui/icons-material/Close'; +import DoneIcon from '@mui/icons-material/Done'; +import InfoIcon from '@mui/icons-material/InfoRounded'; +import HelpIcon from '@mui/icons-material/HelpRounded'; +import PublishIcon from '@mui/icons-material/Publish'; +import SaveIcon from '@mui/icons-material/Save'; +import SettingsBackupRestoreIcon from + '@mui/icons-material/SettingsBackupRestore'; +import Box from '@mui/material/Box'; +import _ from 'lodash'; +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 +} 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 FormView from './FormView'; +import { StyledBox } from './StyledComponents'; +import { useSchemaState } from './useSchemaState'; +import { + getForQueryParams, SchemaStateContext +} from './common'; + + +/* If its the dialog */ +export default function SchemaDialogView({ + getInitData, viewHelperProps, loadingText, schema={}, showFooter=true, + 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({ + 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: {} + }); + + // Is saving operation in progress? + const [saving, setSaving] = useState(false); + + // 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(); + const Notifier = props.Notifier || pgAdmin.Browser.notifier; + + useEffect(() => { + /* + * Docker on load focusses itself, so our focus should execute later. + */ + let focusTimeout = setTimeout(()=>{ + firstEleRef.current?.focus(); + }, 250); + + // Clear the focus timeout if unmounted. + return () => { + clearTimeout(focusTimeout); + }; + }, []); + + useEffect(() => { + setLoaderText(schemaState.message); + }, [schemaState.message]); + + useEffect(() => { + setData(sessData); + updateSchemaState(schemaState); + }, [sessData.__changeId]); + + const onResetClick = () => { + const resetIt = () => { + firstEleRef.current?.focus(); + reset(); + return true; + }; + + if (!props.confirmOnCloseReset) { + resetIt(); + return; + } + + Notifier.confirm( + gettext('Warning'), + gettext('Changes will be lost. Are you sure you want to reset?'), + resetIt, () => (true), + ); + }; + + const save = (changeData) => { + props.onSave(isNew, changeData) + .then(()=>{ + if(schema.informText) { + Notifier.alert( + gettext('Warning'), + schema.informText, + ); + } + }).catch((err)=>{ + schemaState.setError({ + name: 'apierror', + message: _.escape(parseApiError(err)), + }); + }).finally(()=>{ + if(checkIsMounted()) { + setSaving(false); + setLoaderText(''); + } + }); + }; + + const onSaveClick = () => { + // Do nothing when there is no change or there is an error + if (!schemaState.changes || errors.name) return; + + setSaving(true); + setLoaderText('Saving...'); + + if (!schema.warningText) { + save(schemaState.changes); + return; + } + + Notifier.confirm( + gettext('Warning'), + schema.warningText, + ()=> { save(schemaState.changes); }, + () => { + setSaving(false); + setLoaderText(''); + return true; + }, + ); + }; + + 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) { + return Promise.resolve('-- ' + gettext('No updates.')); + } + + if(errors.name) { + return Promise.resolve('-- ' + gettext('Definition incomplete.')); + } + + 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)); + }; + + const getButtonIcon = () => { + if(props.customSaveBtnIconType == 'upload') { + return ; + } else if(props.customSaveBtnIconType == 'done') { + return ; + } + return ; + }; + + const disableSaveBtn = saving || + !isReady || + !(mode === 'edit' || checkDirtyOnEnableSave ? isDirty : true) || + Boolean(errors.name && errors.name !== 'apierror'); + + let ButtonIcon = getButtonIcon(); + + /* I am Groot */ + return ( + + + + + + + + {showFooter && + + { + (!props.disableSqlHelp || !props.disableDialogHelp) && + + props.onHelp(true, isNew)} + icon={} disabled={props.disableSqlHelp} + className='Dialog-buttonMargin' + title={ gettext('SQL help for this object type.') } + /> + props.onHelp(false, isNew)} + icon={} disabled={props.disableDialogHelp} + title={ gettext('Help for this dialog.') } + /> + + } + + } className='Dialog-buttonMargin'> + { gettext('Close') } + + } + disabled={(!isDirty) || saving } + className='Dialog-buttonMargin'> + { gettext('Reset') } + + { + props.customSaveBtnName || gettext('Save') + } + + + + } + + + ); +} + +SchemaDialogView.propTypes = { + getInitData: PropTypes.func, + viewHelperProps: PropTypes.shape({ + mode: PropTypes.string.isRequired, + serverInfo: PropTypes.shape({ + type: PropTypes.string, + version: PropTypes.number, + }), + inCatalog: PropTypes.bool, + keepCid: PropTypes.bool, + }).isRequired, + loadingText: PropTypes.string, + schema: CustomPropTypes.schemaUI, + onSave: PropTypes.func, + onClose: PropTypes.func, + onHelp: PropTypes.func, + onDataChange: PropTypes.func, + confirmOnCloseReset: PropTypes.bool, + isTabView: PropTypes.bool, + hasSQL: PropTypes.bool, + getSQLValue: PropTypes.func, + disableSqlHelp: PropTypes.bool, + disableDialogHelp: PropTypes.bool, + showFooter: PropTypes.bool, + resetKey: PropTypes.any, + customSaveBtnName: PropTypes.string, + customSaveBtnIconType: PropTypes.string, + formClassName: CustomPropTypes.className, + Notifier: PropTypes.object, + checkDirtyOnEnableSave: PropTypes.bool, +}; diff --git a/web/pgadmin/static/js/SchemaView/SchemaPropertiesView.jsx b/web/pgadmin/static/js/SchemaView/SchemaPropertiesView.jsx new file mode 100644 index 000000000..63ceb9123 --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/SchemaPropertiesView.jsx @@ -0,0 +1,214 @@ +///////////////////////////////////////////////////////////// +// +// 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 ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import InfoIcon from '@mui/icons-material/InfoRounded'; +import EditIcon from '@mui/icons-material/Edit'; +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 { StyledBox } from './StyledComponents'; + + +/* 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({ + schema: schema, getInitData: getInitData, immutableData: updatedData, + mode: mode, keepCid: keepCid, 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( + + ); + } else if(field.type === 'collection') { + tabs[group].push( + + ); + } else if(field.type === 'group') { + groupLabels[field.id] = field.label; + + if(!visible) { + schema.filterGroups.push(field.label); + } + } else { + tabs[group].push( + + ); + } + }); + + let finalTabs = _.pickBy( + tabs, (v, tabName) => schema.filterGroups.indexOf(tabName) <= -1 + ); + + return ( + + + + + props.onHelp(true, false)} + icon={} disabled={props.disableSqlHelp} + title="SQL help for this object type." /> + } + title={gettext('Edit object...')} /> + + + + + {Object.keys(finalTabs).map((tabName)=>{ + let id = tabName.replace(' ', ''); + return ( + + } + aria-controls={`${id}-content`} + id={`${id}-header`} + > + {tabName} + + + + {finalTabs[tabName]} + + + + ); + })} + + + + ); +} + +SchemaPropertiesView.propTypes = { + getInitData: PropTypes.func.isRequired, + updatedData: PropTypes.object, + viewHelperProps: PropTypes.shape({ + mode: PropTypes.string.isRequired, + serverInfo: PropTypes.shape({ + type: PropTypes.string, + version: PropTypes.number, + }), + inCatalog: PropTypes.bool, + keepCid: PropTypes.bool, + }).isRequired, + schema: CustomPropTypes.schemaUI, + onHelp: PropTypes.func, + disableSqlHelp: PropTypes.bool, + onEdit: PropTypes.func, + resetKey: PropTypes.any, + itemNodeData: PropTypes.object +}; + diff --git a/web/pgadmin/static/js/SchemaView/SchemaView.jsx b/web/pgadmin/static/js/SchemaView/SchemaView.jsx new file mode 100644 index 000000000..3da1d55ea --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/SchemaView.jsx @@ -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 React from 'react'; + +import PropTypes from 'prop-types'; + +import ErrorBoundary from 'sources/helpers/ErrorBoundary'; + +import SchemaDialogView from './SchemaDialogView'; +import SchemaPropertiesView from './SchemaPropertiesView'; + + +export default function SchemaView({formType, ...props}) { + /* Switch the view based on formType */ + return ( + + { + formType === 'tab' ? + : + } + + ); +} + +SchemaView.propTypes = { + formType: PropTypes.oneOf(['tab', 'dialog']), +}; diff --git a/web/pgadmin/static/js/SchemaView/StyledComponents.jsx b/web/pgadmin/static/js/SchemaView/StyledComponents.jsx new file mode 100644 index 000000000..3529007a8 --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/StyledComponents.jsx @@ -0,0 +1,170 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2024, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; + +export const StyledBox = styled(Box)(({theme}) => ({ + display: 'flex', + flexDirection: 'column', + height: '100%', + minHeight: 0, + '& .Dialog-form': { + flexGrow: 1, + position: 'relative', + minHeight: 0, + display: 'flex', + flexDirection: 'column', + }, + '& .Dialog-footer': { + padding: theme.spacing(1), + background: theme.otherVars.headerBg, + display: 'flex', + zIndex: 1010, + ...theme.mixins.panelBorder.top, + '& .Dialog-buttonMargin': { + marginRight: '0.5rem', + }, + }, + '& .Properties-toolbar': { + padding: theme.spacing(1), + background: theme.palette.background.default, + ...theme.mixins.panelBorder.bottom, + }, + '& .Properties-form': { + padding: theme.spacing(1), + overflow: 'auto', + flexGrow: 1, + '& .Properties-controlRow': { + marginBottom: theme.spacing(1), + }, + }, + '& .Properties-noPadding': { + padding: 0, + }, +})); + +export const StyleDataGridBox = styled(Box)(({theme}) => ({ + '& .DataGridView-grid': { + ...theme.mixins.panelBorder, + backgroundColor: theme.palette.background.default, + display: 'flex', + flexDirection: 'column', + minHeight: 0, + height: '100%', + '& .DataGridView-gridHeader': { + display: 'flex', + ...theme.mixins.panelBorder.bottom, + backgroundColor: theme.otherVars.headerBg, + '& .DataGridView-gridHeaderText': { + padding: theme.spacing(0.5, 1), + fontWeight: theme.typography.fontWeightBold, + }, + '& .DataGridView-gridControls': { + marginLeft: 'auto', + '& .DataGridView-gridControlsButton': { + border: 0, + borderRadius: 0, + ...theme.mixins.panelBorder.left, + }, + }, + }, + '& .DataGridView-table': { + '&.pgrt-table': { + '& .pgrt-body':{ + '& .pgrt-row': { + backgroundColor: theme.otherVars.emptySpaceBg, + '& .pgrt-row-content':{ + '& .pgrd-row-cell': { + height: 'auto', + padding: theme.spacing(0.5), + '&.btn-cell, &.expanded-icon-cell': { + padding: '2px 0px' + }, + } + }, + } + } + } + }, + }, + '& .DataGridView-tableRowHovered': { + position: 'relative', + '& .hover-overlay': { + backgroundColor: theme.palette.primary.light, + position: 'absolute', + inset: 0, + opacity: 0.75, + } + }, + '& .DataGridView-resizer': { + display: 'inline-block', + width: '5px', + height: '100%', + position: 'absolute', + right: 0, + top: 0, + transform: 'translateX(50%)', + zIndex: 1, + touchAction: 'none', + }, + '& .DataGridView-expandedForm': { + border: '1px solid '+theme.palette.grey[400], + }, + '& .DataGridView-expandedIconCell': { + backgroundColor: theme.palette.grey[400], + borderBottom: 'none', + } +})); + +export const FormContentBox = styled(Box)(({theme}) => ({ + '& .FormView-nestedControl': { + height: 'unset !important', + '& .FormView-controlRow': { + marginBottom: theme.spacing(1), + }, + '& .FormView-nestedTabPanel': { + backgroundColor: theme.otherVars.headerBg, + } + }, + '& .FormView-errorMargin': { + /* Error footer space */ + paddingBottom: '36px !important', + }, + '& .FormView-fullSpace': { + padding: '0 !important', + height: '100%', + overflow: 'hidden', + '& .FormView-fullControl': { + display: 'flex', + flexDirection: 'column', + '& .FormView-sqlTabInput': { + border: 0, + }, + } + }, + '& .FormView-nonTabPanel': { + ...theme.mixins.tabPanel, + '& .FormView-nonTabPanelContent': { + height: 'unset', + '& .FormView-controlRow': { + marginBottom: theme.spacing(1), + }, + } + }, + '& .FormView-singleCollectionPanel': { + ...theme.mixins.tabPanel, + '& .FormView-singleCollectionPanelContent': { + '& .FormView-controlRow': { + marginBottom: theme.spacing(1), + height: '100%', + }, + } + }, +})); diff --git a/web/pgadmin/static/js/SchemaView/base_schema.ui.js b/web/pgadmin/static/js/SchemaView/base_schema.ui.js index c2985954c..f42b5599e 100644 --- a/web/pgadmin/static/js/SchemaView/base_schema.ui.js +++ b/web/pgadmin/static/js/SchemaView/base_schema.ui.js @@ -22,6 +22,9 @@ export default class BaseUISchema { this.filterGroups = []; // If set, these groups will be filtered out this.informText = null; // Inform text to show after save, this only saves it this._top = null; + + this._state = null; + this._id = Date.now(); } /* Top schema is helpful if this is used as child */ @@ -42,8 +45,19 @@ 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 */ + set state(state) { + this._state = state; + } + + get state() { + return this._state; + } + + /* + * 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 || {}; } diff --git a/web/pgadmin/static/js/SchemaView/common.js b/web/pgadmin/static/js/SchemaView/common.js new file mode 100644 index 000000000..7ad31c79b --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/common.js @@ -0,0 +1,167 @@ +///////////////////////////////////////////////////////////// +// +// 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 { 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 function getFieldMetaData( + field, schema, value, viewHelperProps +) { + let retData = { + readonly: false, + disabled: false, + visible: true, + editable: true, + canAdd: true, + canEdit: false, + canDelete: true, + modeSupported: isModeSupportedByField(field, viewHelperProps), + canAddRow: true, + }; + + if(!retData.modeSupported) { + return retData; + } + + let {visible, disabled, readonly, editable} = field; + let verInLimit; + + if (_.isUndefined(viewHelperProps.serverInfo)) { + verInLimit= true; + } else { + verInLimit = ((_.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))); + } + + retData.readonly = viewHelperProps.inCatalog || (viewHelperProps.mode == 'properties'); + if(!retData.readonly) { + retData.readonly = evalFunc(schema, readonly, value); + } + + let _visible = verInLimit; + _visible = _visible && evalFunc(schema, _.isUndefined(visible) ? true : visible, value); + retData.visible = Boolean(_visible); + + retData.disabled = Boolean(evalFunc(schema, disabled, value)); + + 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; + retData.canAdd = + _.isUndefined(canAdd) ? retData.canAdd : evalFunc(schema, canAdd, value); + retData.canAdd = !retData.disabled && retData.canAdd; + retData.canEdit = _.isUndefined(canEdit) ? retData.canEdit : evalFunc( + schema, canEdit, value + ); + retData.canEdit = !retData.disabled && retData.canEdit; + retData.canDelete = _.isUndefined(canDelete) ? retData.canDelete : evalFunc( + 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 + ); + + return retData; +} + +/* + * Compare the sessData with schema.origData. + * schema.origData is set to incoming or default data + */ +export function isValueEqual(val1, val2) { + let attrDefined = ( + !_.isUndefined(val1) && !_.isUndefined(val2) && + !_.isNull(val1) && !_.isNull(val2) + ); + + /* + * 1. If the orig value was null and new one is empty string, then its a + * "no change". + * 2. If the orig value and new value are of different datatype but of same + * value(numeric) "no change". + * 3. If the orig value is undefined or null and new value is boolean false + * "no change". + */ + return ( + _.isEqual(val1, val2) || ( + (val1 === null || _.isUndefined(val1)) && val2 === '' + ) || ( + (val1 === null || _.isUndefined(val1)) && + typeof(val2) === 'boolean' && !val2 + ) || ( + attrDefined ? ( + !_.isObject(val1) && _.isEqual(val1.toString(), val2.toString()) + ) : false + ) + ); +} + +/* + * Compare two objects. + */ +export function isObjectEqual(val1, val2) { + const allKeys = Array.from( + new Set([...Object.keys(val1), ...Object.keys(val2)]) + ); + return !allKeys.some((k) => { + return !isValueEqual(val1[k], val2[k]); + }); +} + +export function getForQueryParams(data) { + let retData = {...data}; + Object.keys(retData).forEach((key)=>{ + let value = retData[key]; + if(_.isObject(value) || _.isNull(value)) { + retData[key] = JSON.stringify(value); + } + }); + return retData; +} diff --git a/web/pgadmin/static/js/SchemaView/index.jsx b/web/pgadmin/static/js/SchemaView/index.jsx index 456aaef63..11f6bfff4 100644 --- a/web/pgadmin/static/js/SchemaView/index.jsx +++ b/web/pgadmin/static/js/SchemaView/index.jsx @@ -7,1048 +7,43 @@ // ////////////////////////////////////////////////////////////// -import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; -import { Box, Accordion, AccordionSummary, AccordionDetails} from '@mui/material'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import SaveIcon from '@mui/icons-material/Save'; -import PublishIcon from '@mui/icons-material/Publish'; -import DoneIcon from '@mui/icons-material/Done'; -import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; -import CloseIcon from '@mui/icons-material/Close'; -import InfoIcon from '@mui/icons-material/InfoRounded'; -import HelpIcon from '@mui/icons-material/HelpRounded'; -import EditIcon from '@mui/icons-material/Edit'; -import diffArray from 'diff-arrays-of-objects'; -import _ from 'lodash'; - -import {FormFooterMessage, MESSAGE_TYPE } from 'sources/components/FormComponents'; -import { PrimaryButton, DefaultButton, PgIconButton } from 'sources/components/Buttons'; -import Loader from 'sources/components/Loader'; -import { minMaxValidator, numberValidator, integerValidator, emptyValidator, checkUniqueCol, isEmptyString} from '../validators'; -import { MappedFormControl } from './MappedControl'; -import gettext from 'sources/gettext'; -import BaseUISchema from 'sources/SchemaView/base_schema.ui'; -import FormView, { getFieldMetaData } from './FormView'; -import PropTypes from 'prop-types'; -import CustomPropTypes from '../custom_prop_types'; -import { parseApiError } from '../api_instance'; -import DepListener, {DepListenerContext} from './DepListener'; -import FieldSetView from './FieldSetView'; import DataGridView from './DataGridView'; -import { useIsMounted } from '../custom_hooks'; -import ErrorBoundary from '../helpers/ErrorBoundary'; -import { usePgAdmin } from '../BrowserComponent'; -import { PgButtonGroup } from '../components/Buttons'; -import { styled } from '@mui/material/styles'; +import FieldSetView from './FieldSetView'; +import FormView from './FormView'; +import SchemaDialogView from './SchemaDialogView'; +import SchemaPropertiesView from './SchemaPropertiesView'; +import SchemaView from './SchemaView'; +import BaseUISchema from './base_schema.ui'; +import { useSchemaState } from './useSchemaState'; +import { + SCHEMA_STATE_ACTIONS, + SchemaStateContext, + generateTimeBasedRandomNumberString, + isModeSupportedByField, + getFieldMetaData, + isValueEqual, + isObjectEqual, + getForQueryParams +} from './common'; -const StyledBox = styled(Box)(({theme})=>({ - display: 'flex', - flexDirection: 'column', - height: '100%', - minHeight: 0, - '& .Dialog-form': { - flexGrow: 1, - position: 'relative', - minHeight: 0, - display: 'flex', - flexDirection: 'column', - }, - '& .Dialog-footer': { - padding: theme.spacing(1), - background: theme.otherVars.headerBg, - display: 'flex', - zIndex: 1010, - ...theme.mixins.panelBorder.top, - '& .Dialog-buttonMargin': { - marginRight: '0.5rem', - }, - }, - '& .Properties-toolbar': { - padding: theme.spacing(1), - background: theme.palette.background.default, - ...theme.mixins.panelBorder.bottom, - }, - '& .Properties-form': { - padding: theme.spacing(1), - overflow: 'auto', - flexGrow: 1, - '& .Properties-controlRow': { - marginBottom: theme.spacing(1), - }, - }, - '& .Properties-noPadding': { - padding: 0, - }, -})); -export const StateUtilsContext = React.createContext(); +export default SchemaView; -function getForQueryParams(data) { - let retData = {...data}; - Object.keys(retData).forEach((key)=>{ - let value = retData[key]; - if(_.isObject(value) || _.isNull(value)) { - retData[key] = JSON.stringify(value); - } - }); - return retData; -} - -/* Compare the sessData with schema.origData -schema.origData is set to incoming or default data -*/ -function isValueEqual(val1, val2) { - let attrDefined = !_.isUndefined(val1) && !_.isUndefined(val2) && !_.isNull(val1) && !_.isNull(val2); - - /* If the orig value was null and new one is empty string, then its a "no change" */ - /* If the orig value and new value are of different datatype but of same value(numeric) "no change" */ - /* If the orig value is undefined or null and new value is boolean false "no change" */ - return (_.isEqual(val1, val2) - || ((val1 === null || _.isUndefined(val1)) && val2 === '') - || ((val1 === null || _.isUndefined(val1)) && typeof(val2) === 'boolean' && !val2) - || (attrDefined ? (!_.isObject(val1) && _.isEqual(val1.toString(), val2.toString())) : false) - ); -} - -/* Compare two objects */ -function isObjectEqual(val1, val2) { - const allKeys = Array.from(new Set([...Object.keys(val1), ...Object.keys(val2)])); - return !allKeys.some((k)=>{ - return !isValueEqual(val1[k], val2[k]); - }); -} - -function getChangedData(topSchema, viewHelperProps, sessData, stringify=false, includeSkipChange=true) { - let changedData = {}; - let isEdit = viewHelperProps.mode === 'edit'; - - /* Will be called recursively as data can be nested */ - const parseChanges = (schema, origVal, sessVal)=>{ - let levelChanges = {}; - parseChanges.depth = _.isUndefined(parseChanges.depth) ? 0 : parseChanges.depth+1; - - /* The comparator and setter */ - const attrChanged = (id, change, force=false)=>{ - if(isValueEqual(_.get(origVal, id), _.get(sessVal, id)) && !force) { - return; - } else { - change = change || _.get(sessVal, id); - if(stringify && (_.isArray(change) || _.isObject(change))) { - change = JSON.stringify(change); - } - /* Null values are not passed in URL params, pass it as an empty string - Nested values does not need this */ - if(_.isNull(change) && parseChanges.depth === 0) { - change = ''; - } - levelChanges[id] = change; - } - }; - - schema.fields.forEach((field)=>{ - /* At this point the schema assignments like top may not have been done - So, only check the mode by passing true to getFieldMetaData */ - let {modeSupported} = getFieldMetaData(field, schema, {}, viewHelperProps, true); - - /* If skipChange is true, then field will not be considered for changed data, - This is helpful when Save or Reset should not be enabled on this field change alone. - No change in other behaviour */ - if(!modeSupported || (field.skipChange && !includeSkipChange)) { - return; - } - if(typeof(field.type) == 'string' && field.type.startsWith('nested-')) { - /* Even if its nested, state is on same hierarchical level. - Find the changes and merge */ - levelChanges = { - ...levelChanges, - ...parseChanges(field.schema, origVal, sessVal), - }; - } else if(isEdit && !_.isEqual(_.get(origVal, field.id), _.get(sessVal, field.id))) { - /* Check for changes only if its in edit mode, otherwise everything can go through comparator */ - let change = null; - if(field.type === 'collection') { - /* Use diffArray package to get the array diff and extract the info. - cid is used to identify the rows uniquely */ - const changeDiff = diffArray( - _.get(origVal, field.id) || [], - _.get(sessVal, field.id) || [], - 'cid', - { - compareFunction: isObjectEqual, - } - ); - change = {}; - if(changeDiff.added.length > 0) { - change['added'] = cleanCid(changeDiff.added, viewHelperProps.keepCid); - } - if(changeDiff.removed.length > 0) { - change['deleted'] = cleanCid(changeDiff.removed.map((row)=>{ - /* Deleted records should be original, not the changed */ - return _.find(_.get(origVal, field.id), ['cid', row.cid]); - }), viewHelperProps.keepCid); - } - if(changeDiff.updated.length > 0) { - /* There is change in collection. Parse further to go deep */ - let changed = []; - for(const changedRow of changeDiff.updated) { - let finalChangedRow = {}; - let rowIndxSess = _.findIndex(_.get(sessVal, field.id), (r)=>r.cid==changedRow.cid); - let rowIndxOrig = _.findIndex(_.get(origVal, field.id), (r)=>r.cid==changedRow.cid); - finalChangedRow = parseChanges(field.schema, _.get(origVal, [field.id, rowIndxOrig]), _.get(sessVal, [field.id, rowIndxSess])); - - if(_.isEmpty(finalChangedRow)) { - continue; - } - /* If the id attr value is present, then only changed keys can be passed. - Otherwise, passing all the keys is useful */ - let idAttrValue = _.get(sessVal, [field.id, rowIndxSess, field.schema.idAttribute]); - if(_.isUndefined(idAttrValue)) { - changed.push({ - ...changedRow, - ...finalChangedRow, - }); - } else { - changed.push({ - [field.schema.idAttribute]: idAttrValue, - ...finalChangedRow, - }); - } - } - if(changed.length > 0) { - change['changed'] = cleanCid(changed, viewHelperProps.keepCid); - } - } - if(Object.keys(change).length > 0) { - attrChanged(field.id, change, true); - } - } else { - attrChanged(field.id); - } - } else if(!isEdit) { - if(field.type === 'collection') { - const origColl = _.get(origVal, field.id) || []; - const sessColl = _.get(sessVal, field.id) || []; - let changeDiff = diffArray(origColl,sessColl,'cid',{ - compareFunction: isObjectEqual, - }); - - /* For fixed rows, check only the updated changes */ - /* If canReorder, check the updated changes */ - if((!_.isUndefined(field.fixedRows) && changeDiff.updated.length > 0) - || (_.isUndefined(field.fixedRows) && ( - changeDiff.added.length > 0 || changeDiff.removed.length > 0 || changeDiff.updated.length > 0 - )) - || (field.canReorder && _.differenceBy(origColl, sessColl, 'cid')) - ) { - let change = cleanCid(_.get(sessVal, field.id), viewHelperProps.keepCid); - attrChanged(field.id, change, true); - return; - } - - if(field.canReorder) { - changeDiff = diffArray(origColl,sessColl); - if(changeDiff.updated.length > 0) { - let change = cleanCid(_.get(sessVal, field.id), viewHelperProps.keepCid); - attrChanged(field.id, change, true); - } - } - } else { - attrChanged(field.id); - } - } - }); - - parseChanges.depth--; - return levelChanges; - }; - - changedData = parseChanges(topSchema, topSchema.origData, sessData); - return changedData; -} - -function validateSchema(schema, sessData, setError, accessPath=[], collLabel=null) { - sessData = sessData || {}; - for(let field of schema.fields) { - /* Skip id validation */ - if(schema.idAttribute == field.id) { - continue; - } - /* If the field is has nested schema then validate the schema */ - if(field.schema && (field.schema instanceof BaseUISchema)) { - /* A collection is an array */ - if(field.type === 'collection') { - let rows = sessData[field.id] || []; - let currPath = accessPath.concat(field.id); - - /* Validate duplicate rows */ - let dupInd = checkUniqueCol(rows, field.uniqueCol); - if(dupInd > 0) { - let uniqueColNames = _.filter(field.schema.fields, (uf)=>field.uniqueCol.indexOf(uf.id) > -1) - .map((uf)=>uf.label).join(', '); - if (isEmptyString(field.label)) { - setError(currPath, gettext('%s must be unique.', uniqueColNames)); - } else { - setError(currPath, gettext('%s in %s must be unique.', uniqueColNames, field.label)); - } - return true; - } - /* Loop through data */ - for(const [rownum, row] of rows.entries()) { - if(validateSchema(field.schema, row, setError, currPath.concat(rownum), field.label)) { - return true; - } - } - } else if(validateSchema(field.schema, sessData, setError, accessPath)) { - /* A nested schema ? Recurse */ - return true; - } - } else { - /* Normal field, default validations */ - let value = sessData[field.id]; - let message = null; - if(field.noEmpty) { - let label = field.label; - if(collLabel) { - label = gettext('%s in %s', field.label, collLabel); - } - if(field.noEmptyLabel) { - label = field.noEmptyLabel; - } - message = emptyValidator(label, value); - } - if(!message && (field.type == 'int' || field.type == 'numeric')) { - message = minMaxValidator(field.label, value, field.min, field.max); - } - if(!message && field.type == 'int') { - message = integerValidator(field.label, value); - } else if(!message && field.type == 'numeric') { - message = numberValidator(field.label, value); - } - if(message) { - setError(accessPath.concat(field.id), message); - return true; - } - } - } - return schema.validate(sessData, (id, message)=>setError(accessPath.concat(id), message)); -} - -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)||[]; - let 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__ = []; - break; - case SCHEMA_STATE_ACTIONS.DEFERRED_DEPCHANGE: - data = getDepChange(action.path, data, state, action); - break; - } - return data; -}; - -/* Remove cid key added by prepareData */ -function cleanCid(coll, keepCid=false) { - if(!coll || keepCid) { - return coll; - } - return coll.map((o)=>_.pickBy(o, (v, k)=>k!='cid')); -} - -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; -} - -/* If its the dialog */ -function SchemaDialogView({ - getInitData, viewHelperProps, loadingText, schema={}, showFooter=true, isTabView=true, checkDirtyOnEnableSave=false, ...props}) { - /* Some useful states */ - const [dirty, setDirty] = useState(false); - /* formErr has 2 keys - name and message. - Footer message will be displayed if message is set. - */ - const pgAdmin = usePgAdmin(); - const [formErr, setFormErr] = useState({}); - const [loaderText, setLoaderText] = useState(''); - const [saving, setSaving] = useState(false); - const [formReady, setFormReady] = useState(false); - const [formResetKey, setFormResetKey] = useState(0); - const firstEleRef = useRef(); - const isNew = schema.isNew(schema.origData); - const checkIsMounted = useIsMounted(); - const preFormReadyQueue = useRef([]); - const Notifier = props.Notifier || pgAdmin.Browser.notifier; - - const depListenerObj = useRef(new DepListener()); - /* The session data */ - const [sessData, sessDispatch] = useReducer(sessDataReducer, {}); - - useEffect(()=>{ - /* Dispatch all the actions recorded before form ready */ - if(formReady) { - if(preFormReadyQueue.current.length > 0) { - for (const dispatchPayload of preFormReadyQueue.current) { - sessDispatch(dispatchPayload); - } - } - /* destroy the queue so that no one uses it */ - preFormReadyQueue.current = undefined; - } - }, [formReady]); - - useEffect(()=>{ - /* if sessData changes, validate the schema */ - if(!formReady) return; - let isNotValid = validateSchema(schema, sessData, (path, message)=>{ - if(message) { - setFormErr({ - name: path, - message: _.escape(message), - }); - } - }); - if(!isNotValid) setFormErr({}); - - /* check if anything changed */ - let changedData = getChangedData(schema, viewHelperProps, sessData, false, false); - let isDataChanged = Object.keys(changedData).length > 0; - setDirty(isDataChanged); - - /* tell the callbacks the data has changed */ - if(viewHelperProps.mode !== 'edit') { - /* If new then merge the changed data with origData */ - changedData = _.assign({}, schema.origData, changedData); - } - - props.onDataChange?.(isDataChanged, changedData); - }, [sessData, formReady]); - - useEffect(()=>{ - if(sessData.__deferred__?.length > 0) { - let items = sessData.__deferred__; - 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]); - - useEffect(()=>{ - let unmounted = false; - /* Docker on load focusses itself, so our focus should execute later */ - let focusTimeout = setTimeout(()=>{ - firstEleRef.current?.focus(); - }, 250); - - setLoaderText('Loading...'); - /* Get the initial data using getInitData */ - /* If its an edit mode, getInitData should be present and a promise */ - if(!getInitData && viewHelperProps.mode === 'edit') { - throw new Error('getInitData must be passed for edit'); - } - let initDataPromise = getInitData?.() || Promise.resolve({}); - initDataPromise.then((data)=>{ - if(unmounted) { - return; - } - data = data || {}; - if(viewHelperProps.mode === 'edit') { - /* Set the origData to incoming data, useful for comparing and reset */ - schema.origData = prepareData(data || {}); - } else { - /* In create mode, merge with defaults */ - schema.origData = prepareData({ - ...schema.defaults, - ...data, - }, true); - } - schema.initialise(schema.origData); - sessDispatch({ - type: SCHEMA_STATE_ACTIONS.INIT, - payload: schema.origData, - }); - setFormReady(true); - setLoaderText(''); - }).catch((err)=>{ - if(unmounted) { - return; - } - setLoaderText(''); - setFormErr({ - name: 'apierror', - message: _.escape(parseApiError(err)), - }); - }); - /* Clear the focus timeout if unmounted */ - return ()=>{ - unmounted = true; - clearTimeout(focusTimeout); - }; - }, []); - - useEffect(()=>{ - /* If reset key changes, reset the form */ - schema.initialise(schema.origData); - sessDispatch({ - type: SCHEMA_STATE_ACTIONS.INIT, - payload: schema.origData, - }); - }, [props.resetKey]); - - const onResetClick = ()=>{ - const resetIt = ()=>{ - firstEleRef.current?.focus(); - setFormResetKey((prev)=>prev+1); - schema.initialise(schema.origData); - sessDispatch({ - type: SCHEMA_STATE_ACTIONS.INIT, - payload: schema.origData, - }); - return true; - }; - /* Confirm before reset */ - if(props.confirmOnCloseReset) { - Notifier.confirm( - gettext('Warning'), - gettext('Changes will be lost. Are you sure you want to reset?'), - resetIt, - function() { - return true; - }, - ); - } else { - resetIt(); - } - }; - - const onSaveClick = ()=>{ - setSaving(true); - setLoaderText('Saving...'); - /* Get the changed data */ - let changeData = getChangedData(schema, viewHelperProps, sessData); - - /* Add the id when in edit mode */ - if(viewHelperProps.mode !== 'edit') { - /* If new then merge the changed data with origData */ - changeData = _.assign({}, schema.origData, changeData); - } else { - changeData[schema.idAttribute] = schema.origData[schema.idAttribute]; - } - if (schema.warningText) { - Notifier.confirm( - gettext('Warning'), - schema.warningText, - ()=> { - save(changeData); - }, - () => { - setSaving(false); - setLoaderText(''); - return true; - }, - ); - } else { - save(changeData); - } - }; - - const save = (changeData) => { - props.onSave(isNew, changeData) - .then(()=>{ - if(schema.informText) { - Notifier.alert( - gettext('Warning'), - schema.informText, - ); - } - }).catch((err)=>{ - console.error(err); - setFormErr({ - name: 'apierror', - message: _.escape(parseApiError(err)), - }); - }).finally(()=>{ - if(checkIsMounted()) { - setSaving(false); - setLoaderText(''); - } - }); - }; - - const onErrClose = useCallback(()=>{ - /* Unset the error message, but not the name */ - setFormErr((prev)=>({ - ...prev, - message: '', - })); - }); - - const getSQLValue = ()=>{ - /* Called when SQL tab is active */ - if(dirty) { - if(!formErr.name) { - let changeData = getChangedData(schema, viewHelperProps, sessData); - if(viewHelperProps.mode !== 'edit') { - /* If new then merge the changed data with origData */ - changeData = _.assign({}, schema.origData, changeData); - } else { - changeData[schema.idAttribute] = schema.origData[schema.idAttribute]; - } - /* Call the passed incoming getSQLValue func to get the SQL - return of getSQLValue should be a promise. - */ - return props.getSQLValue(isNew, getForQueryParams(changeData)); - } else { - return Promise.resolve('-- ' + gettext('Definition incomplete.')); - } - } else { - return Promise.resolve('-- ' + gettext('No updates.')); - } - }; - - const sessDispatchWithListener = (action)=>{ - let dispatchPayload = { - ...action, - depChange: (...args)=>depListenerObj.current.getDepChange(...args), - deferredDepChange: (...args)=>depListenerObj.current.getDeferredDepChange(...args), - }; - /* All the session changes coming before init should be queued up - They will be processed later when form is ready. - */ - if(preFormReadyQueue.current) { - preFormReadyQueue.current.push(dispatchPayload); - return; - } - sessDispatch(dispatchPayload); - }; - - const stateUtils = useMemo(()=>{ - return { - dataDispatch: sessDispatchWithListener, - initOrigData: (path, value)=>{ - if(path) { - let data = prepareData(value); - _.set(schema.origData, path, data); - sessDispatchWithListener({ - type: SCHEMA_STATE_ACTIONS.SET_VALUE, - path: path, - value: data, - }); - } - }, - formResetKey: formResetKey, - formErr: formErr, - };}, [formResetKey, formErr.name, formErr.message]); - - const getButtonIcon = () => { - if(props.customSaveBtnIconType == 'upload') { - return ; - } else if(props.customSaveBtnIconType == 'done') { - return ; - } - return ; - }; - - let ButtonIcon = getButtonIcon(); - /* Set the _sessData, can be usefull to some deep controls */ - schema._sessData = sessData; - - /* I am Groot */ - return ( - - - - - - - - - {showFooter && - {(!props.disableSqlHelp || !props.disableDialogHelp) && - props.onHelp(true, isNew)} icon={} - disabled={props.disableSqlHelp} className='Dialog-buttonMargin' title="SQL help for this object type."/> - props.onHelp(false, isNew)} icon={} title="Help for this dialog." - disabled={props.disableDialogHelp}/> - } - - } className='Dialog-buttonMargin'> - {gettext('Close')} - - } disabled={!dirty || saving} className='Dialog-buttonMargin'> - {gettext('Reset')} - - - {props.customSaveBtnName ? gettext(props.customSaveBtnName) : gettext('Save')} - - - } - - - - ); -} - -SchemaDialogView.propTypes = { - getInitData: PropTypes.func, - viewHelperProps: PropTypes.shape({ - mode: PropTypes.string.isRequired, - serverInfo: PropTypes.shape({ - type: PropTypes.string, - version: PropTypes.number, - }), - inCatalog: PropTypes.bool, - }).isRequired, - loadingText: PropTypes.string, - schema: CustomPropTypes.schemaUI, - onSave: PropTypes.func, - onClose: PropTypes.func, - onHelp: PropTypes.func, - onDataChange: PropTypes.func, - confirmOnCloseReset: PropTypes.bool, - isTabView: PropTypes.bool, - hasSQL: PropTypes.bool, - getSQLValue: PropTypes.func, - disableSqlHelp: PropTypes.bool, - disableDialogHelp: PropTypes.bool, - showFooter: PropTypes.bool, - resetKey: PropTypes.any, - customSaveBtnName: PropTypes.string, - customSaveBtnIconType: PropTypes.string, - formClassName: CustomPropTypes.className, - Notifier: PropTypes.object, - checkDirtyOnEnableSave: PropTypes.bool, -}; - - - -/* If its the properties tab */ -function SchemaPropertiesView({ - getInitData, viewHelperProps, schema={}, updatedData, ...props}) { - - let defaultTab = 'General'; - let tabs = {}; - let tabsClassname = {}; - let groupLabels = {}; - const [origData, setOrigData] = useState({}); - const [loaderText, setLoaderText] = useState(''); - const checkIsMounted = useIsMounted(); - const pgAdmin = usePgAdmin(); - - useEffect(()=>{ - setLoaderText('Loading...'); - getInitData().then((data)=>{ - data = data || {}; - schema.initialise(data); - if(checkIsMounted()) { - setOrigData({ - ...data, - ...updatedData - }); - setLoaderText(''); - } - }).catch((err)=>{ - setLoaderText(''); - pgAdmin.Browser.notifier.pgRespErrorNotify(err); - }); - }, []); - - useEffect(()=>{ - if(updatedData) { - setOrigData(prevData => ({ - ...prevData, - ...updatedData - })); - } - },[updatedData]); - - /* A simple loop to get all the controls for the fields */ - schema.fields.forEach((field)=>{ - let {group} = field; - let {visible, disabled, readonly, modeSupported} = getFieldMetaData(field, schema, origData, viewHelperProps); - group = group || defaultTab; - - if(field.isFullTab) { - tabsClassname[group] = 'Properties-noPadding'; - } - - if(modeSupported) { - group = groupLabels[group] || group || defaultTab; - if(field.helpMessageMode && field.helpMessageMode.indexOf(viewHelperProps.mode) == -1) { - field.helpMessage = ''; - } - - if(!tabs[group]) tabs[group] = []; - if(field && field.type === 'nested-fieldset') { - tabs[group].push( - - ); - } else if(field.type === 'collection') { - tabs[group].push( - - ); - } else if(field.type === 'group') { - groupLabels[field.id] = field.label; - if(!visible) { - schema.filterGroups.push(field.label); - } - } else { - tabs[group].push( - - ); - } - } - }); - - let finalTabs = _.pickBy(tabs, (v, tabName)=>schema.filterGroups.indexOf(tabName) <= -1); - return ( - - - - - props.onHelp(true, false)} icon={} disabled={props.disableSqlHelp} - title="SQL help for this object type." /> - } title={gettext('Edit object...')} /> - - - - - {Object.keys(finalTabs).map((tabName)=>{ - let id = tabName.replace(' ', ''); - return ( - - } - aria-controls={`${id}-content`} - id={`${id}-header`} - > - {tabName} - - - - {finalTabs[tabName]} - - - - ); - })} - - - - ); -} - -SchemaPropertiesView.propTypes = { - getInitData: PropTypes.func.isRequired, - updatedData: PropTypes.object, - viewHelperProps: PropTypes.shape({ - mode: PropTypes.string.isRequired, - serverInfo: PropTypes.shape({ - type: PropTypes.string, - version: PropTypes.number, - }), - inCatalog: PropTypes.bool, - }).isRequired, - schema: CustomPropTypes.schemaUI, - onHelp: PropTypes.func, - disableSqlHelp: PropTypes.bool, - onEdit: PropTypes.func, - itemNodeData: PropTypes.object -}; - -export default function SchemaView({formType, ...props}) { - /* Switch the view based on formType */ - if(formType === 'tab') { - return ( - - - - ); - } - return ( - - - - ); -} - -SchemaView.propTypes = { - formType: PropTypes.oneOf(['tab', 'dialog']), +export { + DataGridView, + FieldSetView, + FormView, + SchemaDialogView, + SchemaPropertiesView, + SchemaView, + BaseUISchema, + useSchemaState, + SCHEMA_STATE_ACTIONS, + SchemaStateContext, + generateTimeBasedRandomNumberString, + isModeSupportedByField, + getFieldMetaData, + isValueEqual, + isObjectEqual, + getForQueryParams }; diff --git a/web/pgadmin/static/js/SchemaView/schemaUtils.js b/web/pgadmin/static/js/SchemaView/schemaUtils.js new file mode 100644 index 000000000..0fc204f50 --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/schemaUtils.js @@ -0,0 +1,333 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2024, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import diffArray from 'diff-arrays-of-objects'; +import _ from 'lodash'; + +import gettext from 'sources/gettext'; +import { + minMaxValidator, numberValidator, integerValidator, emptyValidator, + checkUniqueCol, isEmptyString +} from 'sources/validators'; + +import BaseUISchema from './base_schema.ui'; +import { isModeSupportedByField, isObjectEqual, isValueEqual } from './common'; + +// Remove cid key added by prepareData +const cleanCid = (coll, keepCid=false) => ( + (!coll || keepCid) ? coll : coll.map( + (o) => _.pickBy(o, (v, k) => (k !== 'cid')) + ) +); + + +export function getCollectionDiffInEditMode( + field, origVal, sessVal, keepCid, parseChanges +) { + let change = {}; + + const id = field.id; + const collIdAttr = field.schema.idAttribute; + const origColl = _.get(origVal, id) || []; + const sessColl = _.get(sessVal, id) || []; + /* + * Use 'diffArray' package to get the array diff and extract the + * info. 'cid' attribute is used to identify the rows uniquely. + */ + const changeDiff = diffArray( + origColl, sessColl || [], 'cid', { compareFunction: isObjectEqual } + ); + + if(changeDiff.added.length > 0) { + change['added'] = cleanCid(changeDiff.added, keepCid); + } + + if(changeDiff.removed.length > 0) { + change['deleted'] = cleanCid(changeDiff.removed.map((row) => { + // Deleted records must be from the original data, not the newly added. + return _.find(_.get(origVal, field.id), ['cid', row.cid]); + }), keepCid); + } + + if(changeDiff.updated.length > 0) { + /* + * There is a change in collection. Parse further deep to figure + * out the exact details. + */ + let changed = []; + + for(const changedRow of changeDiff.updated) { + const rowIndxSess = _.findIndex( + _.get(sessVal, id), (row) => (row.cid === changedRow.cid) + ); + const rowIndxOrig = _.findIndex( + _.get(origVal, id), (row) => (row.cid==changedRow.cid) + ); + const finalChangedRow = parseChanges( + field.schema, _.get(origVal, [id, rowIndxOrig]), + _.get(sessVal, [id, rowIndxSess]) + ); + + if(_.isEmpty(finalChangedRow)) { + continue; + } + + /* + * If the 'id' attribute value is present, then only changed keys + * can be passed. Otherwise, passing all the keys is useful. + */ + const idAttrValue = _.get(sessVal, [id, rowIndxSess, collIdAttr]); + + if(_.isUndefined(idAttrValue)) { + changed.push({ ...changedRow, ...finalChangedRow }); + } else { + changed.push({ [collIdAttr]: idAttrValue, ...finalChangedRow }); + } + } + + if(changed.length > 0) { + change['changed'] = cleanCid(changed, keepCid); + } + } + + return change; +} + +export function getSchemaDataDiff( + topSchema, initData, sessData, mode, keepCid, + stringify=false, includeSkipChange=true +) { + const isEditMode = mode === 'edit'; + + // This will be executed recursively as data can be nested. + let parseChanges = (schema, origVal, sessVal) => { + let levelChanges = {}; + parseChanges.depth = + _.isUndefined(parseChanges.depth) ? 0 : (parseChanges.depth + 1); + + /* The comparator and setter */ + const attrChanged = (id, change, force=false) => { + if(isValueEqual(_.get(origVal, id), _.get(sessVal, id)) && !force) { + return; + } + + change = change || _.get(sessVal, id); + + if(stringify && (_.isArray(change) || _.isObject(change))) { + change = JSON.stringify(change); + } + + /* + * Null values are not passed in URL params, pass it as an empty string. + * Nested values does not need this. + */ + if(_.isNull(change) && parseChanges.depth === 0) { + change = ''; + } + + levelChanges[id] = change; + }; + + schema.fields.forEach((field) => { + /* + * If skipChange is true, then field will not be considered for changed + * data. This is helpful when 'Save' or 'Reset' should not be enabled on + * this field change alone. No change in other behaviour. + */ + if(field.skipChange && !includeSkipChange) return; + + /* + * At this point the schema assignments like top may not have been done, + * so - check if mode is supported by this field, or not. + */ + if (!isModeSupportedByField(field, {mode})) return; + + if( + typeof(field.type) === 'string' && field.type.startsWith('nested-') + ) { + /* + * Even if its nested, state is on same hierarchical level. + * Find the changes and merge. + */ + levelChanges = { + ...levelChanges, + ...parseChanges(field.schema, origVal, sessVal), + }; + } else if(isEditMode && !_.isEqual( + _.get(origVal, field.id), _.get(sessVal, field.id) + )) { + /* + * Check for changes only if in edit mode, otherwise - everything can + * go through comparator + */ + if(field.type === 'collection') { + const change = getCollectionDiffInEditMode( + field, origVal, sessVal, keepCid, parseChanges + ); + + if(Object.keys(change).length > 0) { + attrChanged(field.id, change, true); + } + } else { + attrChanged(field.id); + } + } else if(!isEditMode) { + if(field.type === 'collection') { + const origColl = _.get(origVal, field.id) || []; + const sessColl = _.get(sessVal, field.id) || []; + + let changeDiff = diffArray( + origColl, sessColl, 'cid', {compareFunction: isObjectEqual} + ); + + // Check the updated changes,when: + // 1. These are the fixed rows. + // 2. 'canReorder' flag is set to true. + if(( + !_.isUndefined(field.fixedRows) && changeDiff.updated.length > 0 + ) || ( + _.isUndefined(field.fixedRows) && ( + changeDiff.added.length > 0 || changeDiff.removed.length > 0 || + changeDiff.updated.length > 0 + ) + ) || ( + field.canReorder && _.differenceBy(origColl, sessColl, 'cid') + )) { + attrChanged( + field.id, cleanCid(_.get(sessVal, field.id), keepCid), true + ); + return; + } + + if(field.canReorder) { + changeDiff = diffArray(origColl, sessColl); + + if(changeDiff.updated.length > 0) { + attrChanged( + field.id, cleanCid(_.get(sessVal, field.id), keepCid), true + ); + } + } + } else { + attrChanged(field.id); + } + } + }); + + parseChanges.depth--; + return levelChanges; + }; + + let res = parseChanges(topSchema, initData, sessData); + + return res; +} + +export function validateCollectionSchema( + field, sessData, accessPath, setError +) { + const rows = sessData[field.id] || []; + const currPath = accessPath.concat(field.id); + + // Validate duplicate rows. + const dupInd = checkUniqueCol(rows, field.uniqueCol); + + if(dupInd > 0) { + const uniqueColNames = _.filter( + field.schema.fields, (uf) => field.uniqueCol.indexOf(uf.id) > -1 + ).map((uf)=>uf.label).join(', '); + + if (isEmptyString(field.label)) { + setError(currPath, gettext('%s must be unique.', uniqueColNames)); + } else { + setError( + currPath, + gettext('%s in %s must be unique.', uniqueColNames, field.label) + ); + } + return true; + } + + // Loop through data. + for(const [rownum, row] of rows.entries()) { + if(validateSchema( + field.schema, row, setError, currPath.concat(rownum), field.label + )) { + return true; + } + } + + return false; +} + +export function validateSchema( + schema, sessData, setError, accessPath=[], collLabel=null +) { + sessData = sessData || {}; + + for(const field of schema.fields) { + // Skip id validation + 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)) { + // A collection is an array. + if(field.type === 'collection') { + if (validateCollectionSchema(field, sessData, accessPath, setError)) + return true; + } + // A nested schema ? Recurse + else if(validateSchema(field.schema, sessData, setError, accessPath)) { + return true; + } + } else { + // Normal field, default validations. + const value = sessData[field.id]; + + const fieldPath = accessPath.concat(field.id); + + const setErrorOnMessage = (message) => { + if (message) { + setError(fieldPath, message); + return true; + } + return false; + }; + + if(field.noEmpty) { + const label = ( + collLabel && gettext('%s in %s', field.label, collLabel) + ) || field.noEmptyLabel || field.label; + + if (setErrorOnMessage(emptyValidator(label, value))) + return true; + } + + if(field.type === 'int') { + if (setErrorOnMessage( + integerValidator(field.label, value) || + minMaxValidator(field.label, value, field.min, field.max) + )) + return true; + } else if(field.type === 'numeric') { + if (setErrorOnMessage( + numberValidator(field.label, value) || + minMaxValidator(field.label, value, field.min, field.max) + )) + return true; + } + } + } + + return schema.validate( + sessData, (id, message) => setError(accessPath.concat(id), message) + ); +} diff --git a/web/pgadmin/static/js/SchemaView/useSchemaState.js b/web/pgadmin/static/js/SchemaView/useSchemaState.js new file mode 100644 index 000000000..103296699 --- /dev/null +++ b/web/pgadmin/static/js/SchemaView/useSchemaState.js @@ -0,0 +1,481 @@ +///////////////////////////////////////////////////////////// +// +// 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' +}; + +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 || !state.onDataChange) return; + + if( + !validateSchema(schema, sessData, (path, message) => { + message && state.setError({ name: path, message: _.escape(message) }); + }) + ) state.setError({}); + + // Check if anything changed. + let dataDiff = getSchemaDataDiff( + schema, state.initData, sessData, + state.mode, state.keepCid, false, false + ); + const hasDataChanged = 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. + state.changes = dataDiff; + } else if (hasDataChanged) { + const idAttr = schema.idAttribute; + const idVal = state.initData[idAttr]; + // Append 'idAttr' only if it actually exists + if (idVal) dataDiff[idAttr] = idVal; + state.changes = dataDiff; + } else { + state.changes = null; + } + + state.data = sessData; + + state.onDataChange(hasDataChanged, dataDiff); + } + + 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, + }, + }); + }); + }); + }, [schemaState.__deferred__?.length]); + + schemaState.reload = reload; + schemaState.reset = resetData; + + return { + schemaState, + dataDispatch: sessDispatchWithListener, + sessData, + reset: resetData, + }; +}; diff --git a/web/pgadmin/static/js/helpers/Notifier.jsx b/web/pgadmin/static/js/helpers/Notifier.jsx index 7d9d123a4..c502839bf 100644 --- a/web/pgadmin/static/js/helpers/Notifier.jsx +++ b/web/pgadmin/static/js/helpers/Notifier.jsx @@ -122,7 +122,10 @@ class Notifier { pgRespErrorNotify(error, prefixMsg='') { if (error.response?.status === 410) { - this.alert(gettext('Error: Object not found - %s.', error.response.statusText), parseApiError(error)); + this.alert( + gettext('Error: Object not found - %s.', error.response.statusText), + parseApiError(error) + ); } else { this.error(prefixMsg + ' ' + parseApiError(error)); } @@ -163,7 +166,7 @@ class Notifier { return onJSONResult(); } this.alert(promptmsg, msg.replace(new RegExp(/\r?\n/, 'g'), '
')); - onJSONResult('ALERT_CALLED'); + onJSONResult?.('ALERT_CALLED'); } alert(title, text, onOkClick, okLabel=gettext('OK')) { diff --git a/web/regression/feature_tests/pg_utilities_backup_restore_test.py b/web/regression/feature_tests/pg_utilities_backup_restore_test.py index 208c7fb59..c7a50d1de 100644 --- a/web/regression/feature_tests/pg_utilities_backup_restore_test.py +++ b/web/regression/feature_tests/pg_utilities_backup_restore_test.py @@ -304,9 +304,9 @@ class PGUtilitiesBackupFeatureTest(BaseFeatureTest): existing_path = path_input.get_property("value") if existing_path != default_binary_path[serv]: path_already_set = False - self.page.clear_edit_box(path_input) - path_input.click() - path_input.send_keys(default_binary_path[serv]) + self.page.fill_input( + path_input, default_binary_path[serv] + ) else: print('Binary path Key is Incorrect') else: diff --git a/web/regression/feature_utils/pgadmin_page.py b/web/regression/feature_utils/pgadmin_page.py index 91f239afa..2ec898a3d 100644 --- a/web/regression/feature_utils/pgadmin_page.py +++ b/web/regression/feature_utils/pgadmin_page.py @@ -851,23 +851,25 @@ class PgadminPage: self.driver.execute_script( "arguments[0].dispatchEvent(new Event('blur'));", field) - def fill_input(self, field, field_content, input_keys=False, - key_after_input=Keys.ARROW_DOWN): - try: - attempt = 0 - for attempt in range(0, 3): + def fill_input( + self, field, field_content, input_keys=False, + key_after_input=Keys.ARROW_DOWN + ): + for attempt in range(0, 3): + try: field.click() break - except Exception as e: - time.sleep(.2) - if attempt == 2: - raise e + except Exception as e: + time.sleep(.2) + if attempt == 2: + raise e + # Use send keys if input_keys true, else use javascript to set content if input_keys: - backspaces = [Keys.BACKSPACE] * len(field.get_attribute('value')) - field.send_keys(backspaces) - field.send_keys(str(field_content)) - # self.wait_for_input_by_element(field, field_content) + # Clear the existing content first + self.clear_edit_box(field) + # Send the keys one by one. + [field.send_keys(c) for c in str(field_content)] else: self.driver.execute_script("arguments[0].value = arguments[1]", field, field_content) diff --git a/web/regression/javascript/SchemaView/SchemaDialogViewEdit.spec.js b/web/regression/javascript/SchemaView/SchemaDialogViewEdit.spec.js index a116d80e5..ac394fbc6 100644 --- a/web/regression/javascript/SchemaView/SchemaDialogViewEdit.spec.js +++ b/web/regression/javascript/SchemaView/SchemaDialogViewEdit.spec.js @@ -110,8 +110,8 @@ describe('SchemaView', ()=>{ }); it('onReset after change', async ()=>{ - onDataChange.mockClear(); await simulateChanges(); + onDataChange.mockClear(); let confirmSpy = jest.spyOn(pgAdmin.Browser.notifier, 'confirm'); await user.click(ctrl.container.querySelector('[data-test="Reset"]')); /* Press OK */