pgadmin4/web/pgadmin/static/js/SchemaView/FormView.jsx

321 lines
9.9 KiB
JavaScript

/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, {
useCallback, useContext, useEffect, useMemo, useRef, useState
} from 'react';
import { Box, Tab, Tabs } from '@mui/material';
import _ from 'lodash';
import PropTypes from 'prop-types';
import {
FormFooterMessage, MESSAGE_TYPE, FormNote
} from 'sources/components/FormComponents';
import TabPanel from 'sources/components/TabPanel';
import { useOnScreen } from 'sources/custom_hooks';
import CustomPropTypes from 'sources/custom_prop_types';
import gettext from 'sources/gettext';
import { FieldControl } from './FieldControl';
import { SQLTab } from './SQLTab';
import { FormContentBox } from './StyledComponents';
import { SchemaStateContext } from './SchemaState';
import {
useFieldSchema, useFieldValue, useSchemaStateSubscriber,
} from './hooks';
import { registerView, View } from './registry';
import { createFieldControls, listenDepChanges } from './utils';
const ErrorMessageBox = () => {
const [key, setKey] = useState(0);
const schemaState = useContext(SchemaStateContext);
const onErrClose = useCallback(() => {
const err = { ...schemaState.errors, message: '' };
// Unset the error message, but not the name.
schemaState.setError(err);
}, [schemaState]);
const errors = schemaState.errors;
const message = errors?.message || '';
useEffect(() => {
// Refresh on message changes.
return schemaState.subscribe(
['errors', 'message'], () => setKey(Date.now()), 'states'
);
}, [key]);
return <FormFooterMessage
type={MESSAGE_TYPE.ERROR} message={message} onClose={onErrClose}
/>;
};
// The first component of schema view form.
export default function FormView({
accessPath, schema=null, isNested=false, dataDispatch, className,
hasSQLTab, getSQLValue, isTabView=true, viewHelperProps, field,
showError=false, resetKey, focusOnFirstInput=false
}) {
const [key, setKey] = useState(0);
const subscriberManager = useSchemaStateSubscriber(setKey);
const schemaState = useContext(SchemaStateContext);
const value = useFieldValue(accessPath, schemaState);
const { visible } = useFieldSchema(
field, accessPath, value, viewHelperProps, schemaState, subscriberManager
);
const [tabValue, setTabValue] = useState(0);
const formRef = useRef();
const onScreenTracker = useRef(false);
let isOnScreen = useOnScreen(formRef);
if (!schema) schema = field.schema;
// Set focus on the first focusable element.
useEffect(() => {
if (!focusOnFirstInput) return;
setTimeout(() => {
const formEle = formRef.current;
if (!formEle) return;
const activeTabElement = formEle.querySelector(
'[data-test="tabpanel"]:not([hidden])'
);
if (!activeTabElement) return;
// Find the first focusable input, which is either:
// * An editable Input element.
// * A select element, which is not disabled.
// * An href element.
// * Any element with 'tabindex', but - tabindex is not set to '-1'.
const firstFocussableElement = activeTabElement.querySelector([
'button:not([role="tab"])',
'[href]',
'input:not(disabled)',
'select:not(disabled)',
'textarea',
'[tabindex]:not([tabindex="-1"]):not([data-test="tabpanel"])',
'div[class="cm-content"]:not([aria-readonly="true"])',
].join(', '));
if (firstFocussableElement) firstFocussableElement.focus();
}, 200);
}, [tabValue]);
useEffect(() => {
// Refresh on message changes.
return subscriberManager.current?.add(
schemaState, ['errors', 'message'], 'states',
(newState, prevState) => {
if (_.isUndefined(newState) || _.isUndefined(prevState))
subscriberManager.current?.signal();
}
);
}, [key]);
useEffect(() => {
if (!visible) return;
if(isOnScreen) {
/* Don't do it when the form is alredy visible */
if(!onScreenTracker.current) {
/* Re-select the tab. If form is hidden then sometimes it is not selected */
setTabValue((prev)=>prev);
onScreenTracker.current = true;
}
} else {
onScreenTracker.current = false;
}
}, [isOnScreen]);
listenDepChanges(
accessPath, field, schemaState, () => subscriberManager.current?.signal()
);
// Upon reset, set the tab to first.
useEffect(() => {
if (!visible || !resetKey) return;
if (resetKey) {
setTabValue(0);
}
}, [resetKey]);
const finalGroups = useMemo(
() => createFieldControls({
schema, schemaState, accessPath, viewHelperProps, dataDispatch
}),
[schema, schemaState, accessPath, viewHelperProps, dataDispatch]
);
// Check whether form is kept hidden by visible prop.
if(!finalGroups || (!_.isUndefined(visible) && !visible)) {
return <></>;
}
const isSingleCollection = () => {
const DataGridView = View('DataGridView');
return (
finalGroups.length == 1 &&
finalGroups[0].controls.length == 1 &&
finalGroups[0].controls[0].control == DataGridView
);
};
if(isTabView) {
return (
<>
<FormContentBox height="100%" display="flex" flexDirection="column"
className={className} ref={formRef} data-test="form-view">
<Box>
<Tabs
value={tabValue}
onChange={(ev, nextTabIndex) => { setTabValue(nextTabIndex); }}
variant="scrollable"
scrollButtons="auto"
action={(ref) => ref?.updateIndicator()}
>{
finalGroups.map((tabGroup, idx) =>
<Tab
key={tabGroup.id}
label={tabGroup.label}
data-test={tabGroup.id}
className={
tabGroup.hasError &&
tabValue != idx ? 'tab-with-error' : ''
}
/>
)
}{hasSQLTab &&
<Tab
key={'sql-tab'}
label={gettext('SQL')}
data-test={'SQL'}
/>
}</Tabs>
</Box>
{
finalGroups.map((group, idx) => {
let contentClassName = [
group.isFullTab ?
'FormView-fullControl' : 'FormView-nestedControl',
schemaState.errors?.message ? 'FormView-errorMargin' : null
];
let id = group.id.replace(' ', '');
return (
<TabPanel
key={id}
value={tabValue}
index={idx}
classNameRoot={[
group.className,
(isNested ? 'FormView-nestedTabPanel' : null)
].join(' ')}
className={contentClassName.join(' ')}
data-testid={group.id}>
{
group.isFullTab && group.field?.helpMessage ?
<FormNote
key={`note-${group.field.id}`}
text={group.field.helpMessage}/> :
<></>
}
{
group.controls.map(
(item, idx) => <FieldControl
item={item} key={idx} schemaId={schema._id} />
)
}
</TabPanel>
);
})
}
{
hasSQLTab &&
<TabPanel
key={'sql-tab'}
value={tabValue}
index={finalGroups.length}
classNameRoot={'FormView-fullSpace'}
data-testid={'SQL'}
>
<SQLTab
active={(Object.keys(finalGroups).length === tabValue)}
getSQLValue={getSQLValue}
/>
</TabPanel>
}
</FormContentBox>
{ showError && <ErrorMessageBox /> }
</>
);
} else {
let contentClassName = [
isSingleCollection() ? 'FormView-singleCollectionPanelContent' :
'FormView-nonTabPanelContent',
(schemaState.errors?.message ? 'FormView-errorMargin' : null)
];
return (
<>
<FormContentBox
height="100%" display="flex" flexDirection="column"
className={className}
ref={formRef}
data-test="form-view"
>
<TabPanel
value={tabValue} index={0}
classNameRoot={[
isSingleCollection() ?
'FormView-singleCollectionPanel' : 'FormView-nonTabPanel',
className
].join(' ')}
className={contentClassName.join(' ')}>
{
finalGroups.map((group, idx) =>
<React.Fragment key={idx}>{
group.controls.map(
(item, idx) => <FieldControl
item={item} key={idx} schemaId={schema._id}
/>
)
}</React.Fragment>
)
}
{
hasSQLTab && <SQLTab active={true} getSQLValue={getSQLValue} />
}
</TabPanel>
</FormContentBox>
{ showError && <ErrorMessageBox /> }
</>
);
}
}
FormView.propTypes = {
schema: CustomPropTypes.schemaUI,
viewHelperProps: PropTypes.object,
isNested: PropTypes.bool,
isTabView: PropTypes.bool,
accessPath: PropTypes.array.isRequired,
dataDispatch: PropTypes.func,
hasSQLTab: PropTypes.bool,
getSQLValue: PropTypes.func,
className: CustomPropTypes.className,
field: PropTypes.object,
showError: PropTypes.bool,
resetKey: PropTypes.number,
focusOnFirstInput: PropTypes.bool,
};
registerView(FormView, 'FormView');