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.
pull/7777/head
Ashesh Vashi 2024-08-02 09:59:01 +05:30 committed by GitHub
parent 546806c40c
commit 52af8d3e49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 2190 additions and 1570 deletions

View File

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

View File

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

View File

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

View File

@ -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(()=>
<PgReactTableRowContent ref={rowRef} data-handler-id={handlerId} className={isHovered ? 'DataGridView-tableRowHovered' : null} data-test='data-table-row' style={{position: 'initial'}}>
<PgReactTableRowContent ref={rowRef} data-handler-id={handlerId}
className={isHovered ? 'DataGridView-tableRowHovered' : null}
data-test='data-table-row' style={{position: 'initial'}}>
{row.getVisibleCells().map((cell) => {
let {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 &&
<PgReactTableCell cell={cell} row={row} key={cell.id} ref={cell.column.id == 'btn-reorder' ? dragHandleRef : null}>
return (
<PgReactTableCell cell={cell} row={row} key={cell.id}
ref={cell.column.id == 'btn-reorder' ? dragHandleRef : null}>
{content}
</PgReactTableCell>
);
@ -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||<>&nbsp;</>,
@ -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;
@ -538,16 +481,17 @@ export default function DataGridView({
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) => {
/* If component unmounted, dont update state */
if(checkIsMounted()) {
stateUtils.initOrigData(accessPath, res);
schemaState.setUnpreparedData(accessPath, res);
}
});
}
@ -559,11 +503,16 @@ export default function DataGridView({
// Try autofocus on newly added row.
setTimeout(() => {
const rowInput = tableRef.current?.querySelector(`.pgrt-row[data-index="${newRowIndex.current}"] input`);
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 (
<StyledBox className={containerClassName}>
<StyleDataGridBox className={containerClassName}>
<Box className='DataGridView-grid'>
{(props.label || props.canAdd) && <DataGridHeader label={props.label} canAdd={props.canAdd} onAddClick={onAddClick}
canSearch={props.canSearch}
@ -639,7 +588,7 @@ export default function DataGridView({
</PgReactTable>
</DndProvider>
</Box>
</StyledBox>
</StyleDataGridBox>
);
}

View File

@ -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 = [];
}

View File

@ -8,30 +8,39 @@
//////////////////////////////////////////////////////////////
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) {
// 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) => {
let source = accessPath.concat(dep);
@ -39,7 +48,9 @@ export default function FieldSetView({
source = dep;
}
if(field.depChange) {
depListener.addDepListener(source, accessPath.concat(field.id), field.depChange);
schemaState?.addDepListener(
source, accessPath.concat(field.id), field.depChange
);
}
});
});
@ -49,17 +60,25 @@ export default function FieldSetView({
let viewFields = [];
let inlineComponents = [];
/* 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);
if(!visible) {
return <></>;
}
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.
// Prepare the array of components based on the types.
for(const field of schema.fields) {
const {
visible, disabled, readonly, modeSupported
} = getFieldMetaData(field, schema, value, viewHelperProps);
if(!modeSupported) continue;
// Its a form control.
const hasError = (field.id === schemaState?.errors.name);
/*
* When there is a change, the dependent values can also change.
* Let's pass these changes to dependent for take them into effect to
* generate new values.
*/
const currentControl = <MappedFormControl
state={value}
@ -101,7 +120,8 @@ export default function FieldSetView({
withContainer: false, controlGridBasis: 3
}));
viewFields.push(
<Grid container spacing={0} key={`ic-${inlineComponents[0].key}`} className={controlClassName} rowGap="8px">
<Grid container spacing={0} key={`ic-${inlineComponents[0].key}`}
className={controlClassName} rowGap={INLINE_COMPONENT_ROWGAP}>
{inlineComponents}
</Grid>
);
@ -110,19 +130,16 @@ export default function FieldSetView({
viewFields.push(currentControl);
}
}
}
if(inlineComponents?.length > 0) {
viewFields.push(
<Grid container spacing={0} key={`ic-${inlineComponents[0].key}`} className={controlClassName} rowGap="8px">
<Grid container spacing={0} key={`ic-${inlineComponents[0].key}`}
className={controlClassName} rowGap={INLINE_COMPONENT_ROWGAP}>
{inlineComponents}
</Grid>
);
}
if(!visible) {
return <></>;
}
return (
<FieldSet title={label} className={controlClassName}>
{viewFields}

View File

@ -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(()=>{
if (schemaState?.isReady)
setTabValue(0);
}, [stateUtils.formResetKey]);
}, [schemaState?.isReady]);
let fullTabs = [];
let inlineComponents = [];
@ -242,23 +137,29 @@ 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) continue;
if(modeSupported) {
let {group, CustomControl} = field;
if(field.type === 'group') {
groupLabels[field.id] = field.label;
if(!visible) {
schemaRef.current.filterGroups.push(field.label);
}
continue;
}
group = groupLabels[group] || group || defaultTab;
if(!tabs[group]) tabs[group] = [];
/* Lets choose the path based on type */
// Lets choose the path based on type.
if(field.type === 'nested-tab') {
/* Pass on the top schema */
if(isNested) {
@ -318,7 +219,9 @@ export default function FormView({
}
} else {
/* Its a form control */
const hasError = _.isEqual(accessPath.concat(field.id), stateUtils.formErr.name);
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.
@ -392,7 +295,8 @@ export default function FormView({
withContainer: false, controlGridBasis: 3
}));
tabs[group].push(
<Grid container spacing={0} key={`ic-${inlineComponents[0].key}`} className='FormView-controlRow' rowGap="8px">
<Grid container spacing={0} key={`ic-${inlineComponents[0].key}`}
className='FormView-controlRow' rowGap="8px">
{inlineComponents}
</Grid>
);
@ -403,24 +307,27 @@ export default function FormView({
}
}
}
}
if(inlineComponents?.length > 0) {
tabs[inlineCompGroup].push(
<Grid container spacing={0} key={`ic-${inlineComponents[0].key}`} className='FormView-controlRow' rowGap="8px">
<Grid container spacing={0} key={`ic-${inlineComponents[0].key}`}
className='FormView-controlRow' rowGap="8px">
{inlineComponents}
</Grid>
);
}
let finalTabs = _.pickBy(tabs, (v, tabName)=>schemaRef.current.filterGroups.indexOf(tabName) <= -1);
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] = [
<SQLTab key="sqltab" active={sqlTabActive} getSQLValue={getSQLValue} />,
];
@ -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 (
<StyledBox height="100%" display="flex" flexDirection="column" className={className} ref={formRef} data-test="form-view">
<FormContentBox height="100%" display="flex" flexDirection="column"
className={className} ref={formRef} data-test="form-view">
<Box>
<Tabs
value={tabValue}
onChange={(event, selTabValue) => {
setTabValue(selTabValue);
}}
onChange={(event, selTabValue) => { setTabValue(selTabValue); }}
variant="scrollable"
scrollButtons="auto"
action={(ref)=>ref?.updateIndicator()}
@ -467,34 +373,49 @@ export default function FormView({
</Tabs>
</Box>
{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 (
<TabPanel key={tabName} value={tabValue} index={i} classNameRoot={[tabsClassname[tabName], (isNested ? 'FormView-nestedTabPanel' : null)].join(' ')}
<TabPanel key={tabName} value={tabValue} index={i}
classNameRoot={[
tabsClassname[tabName],
(isNested ? 'FormView-nestedTabPanel' : null)
].join(' ')}
className={contentClassName.join(' ')} data-testid={tabName}>
{finalTabs[tabName]}
</TabPanel>
);
})}
</StyledBox>
</FormContentBox>
);
} 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 (
<StyledBox height="100%" display="flex" flexDirection="column" className={className} ref={formRef} data-test="form-view">
<FormContentBox height="100%" display="flex" flexDirection="column" className={className} ref={formRef} data-test="form-view">
<TabPanel value={tabValue} index={0} classNameRoot={[isSingleCollection ? 'FormView-singleCollectionPanel' : 'FormView-nonTabPanel',className].join(' ')}
className={contentClassName.join(' ')}>
{Object.keys(finalTabs).map((tabName) => {
return (
<React.Fragment key={tabName}>{finalTabs[tabName]}</React.Fragment>
<React.Fragment key={tabName}>
{finalTabs[tabName]}
</React.Fragment>
);
})}
</TabPanel>
</StyledBox>);
</FormContentBox>
);
}
}

View File

@ -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 <PublishIcon />;
} else if(props.customSaveBtnIconType == 'done') {
return <DoneIcon />;
}
return <SaveIcon />;
};
const disableSaveBtn = saving ||
!isReady ||
!(mode === 'edit' || checkDirtyOnEnableSave ? isDirty : true) ||
Boolean(errors.name && errors.name !== 'apierror');
let ButtonIcon = getButtonIcon();
/* I am Groot */
return (
<StyledBox>
<SchemaStateContext.Provider value={schemaState}>
<Box className='Dialog-form'>
<Loader message={loaderText || loadingText}/>
<FormView value={data}
viewHelperProps={viewHelperProps}
schema={schema} accessPath={[]}
dataDispatch={dataDispatch}
hasSQLTab={props.hasSQL} getSQLValue={getSQLValue}
firstEleRef={firstEleRef} isTabView={isTabView}
className={props.formClassName} />
<FormFooterMessage
type={MESSAGE_TYPE.ERROR} message={errors?.message}
onClose={onErrClose} />
</Box>
{showFooter &&
<Box className='Dialog-footer'>
{
(!props.disableSqlHelp || !props.disableDialogHelp) &&
<Box>
<PgIconButton data-test='sql-help'
onClick={()=>props.onHelp(true, isNew)}
icon={<InfoIcon />} disabled={props.disableSqlHelp}
className='Dialog-buttonMargin'
title={ gettext('SQL help for this object type.') }
/>
<PgIconButton data-test='dialog-help'
onClick={()=>props.onHelp(false, isNew)}
icon={<HelpIcon />} disabled={props.disableDialogHelp}
title={ gettext('Help for this dialog.') }
/>
</Box>
}
<Box marginLeft='auto'>
<DefaultButton data-test='Close' onClick={props.onClose}
startIcon={<CloseIcon />} className='Dialog-buttonMargin'>
{ gettext('Close') }
</DefaultButton>
<DefaultButton data-test='Reset' onClick={onResetClick}
startIcon={<SettingsBackupRestoreIcon />}
disabled={(!isDirty) || saving }
className='Dialog-buttonMargin'>
{ gettext('Reset') }
</DefaultButton>
<PrimaryButton data-test='Save' onClick={onSaveClick}
startIcon={ButtonIcon}
disabled={disableSaveBtn}>{
props.customSaveBtnName || gettext('Save')
}
</PrimaryButton>
</Box>
</Box>
}
</SchemaStateContext.Provider>
</StyledBox>
);
}
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,
};

View File

@ -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(
<FieldSetView
key={`nested${tabs[group].length}`}
value={data}
viewHelperProps={viewHelperProps}
schema={field.schema}
accessPath={[]}
controlClassName='Properties-controlRow'
{...field}
visible={visible}
/>
);
} else if(field.type === 'collection') {
tabs[group].push(
<DataGridView
key={field.id}
viewHelperProps={viewHelperProps}
name={field.id}
value={data[field.id] || []}
schema={field.schema}
accessPath={[field.id]}
containerClassName='Properties-controlRow'
canAdd={false}
canEdit={false}
canDelete={false}
visible={visible}
/>
);
} else if(field.type === 'group') {
groupLabels[field.id] = field.label;
if(!visible) {
schema.filterGroups.push(field.label);
}
} else {
tabs[group].push(
<MappedFormControl
key={field.id}
viewHelperProps={viewHelperProps}
state={sessData}
name={field.id}
value={data[field.id]}
{...field}
readonly={readonly}
disabled={disabled}
visible={visible}
className={field.isFullTab ? null :'Properties-controlRow'}
noLabel={field.isFullTab}
memoDeps={[
data[field.id],
'Properties-controlRow',
field.isFullTab
]}
/>
);
}
});
let finalTabs = _.pickBy(
tabs, (v, tabName) => schema.filterGroups.indexOf(tabName) <= -1
);
return (
<StyledBox>
<Loader message={loaderText}/>
<Box className='Properties-toolbar'>
<PgButtonGroup size="small">
<PgIconButton
data-test="help" onClick={() => props.onHelp(true, false)}
icon={<InfoIcon />} disabled={props.disableSqlHelp}
title="SQL help for this object type." />
<PgIconButton data-test="edit"
onClick={props.onEdit} icon={<EditIcon />}
title={gettext('Edit object...')} />
</PgButtonGroup>
</Box>
<Box className={'Properties-form'}>
<Box>
{Object.keys(finalTabs).map((tabName)=>{
let id = tabName.replace(' ', '');
return (
<Accordion key={id}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls={`${id}-content`}
id={`${id}-header`}
>
{tabName}
</AccordionSummary>
<AccordionDetails className={tabsClassname[tabName]}>
<Box style={{width: '100%'}}>
{finalTabs[tabName]}
</Box>
</AccordionDetails>
</Accordion>
);
})}
</Box>
</Box>
</StyledBox>
);
}
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
};

View File

@ -0,0 +1,34 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import 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 (
<ErrorBoundary>
{
formType === 'tab' ?
<SchemaPropertiesView {...props}/> : <SchemaDialogView {...props}/>
}
</ErrorBoundary>
);
}
SchemaView.propTypes = {
formType: PropTypes.oneOf(['tab', 'dialog']),
};

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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'), '<br />'));
onJSONResult('ALERT_CALLED');
onJSONResult?.('ALERT_CALLED');
}
alert(title, text, onOkClick, okLabel=gettext('OK')) {

View File

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

View File

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

View File

@ -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 */