421 lines
14 KiB
JavaScript
421 lines
14 KiB
JavaScript
/////////////////////////////////////////////////////////////
|
|
//
|
|
// pgAdmin 4 - PostgreSQL Tools
|
|
//
|
|
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
|
|
// This software is released under the PostgreSQL Licence
|
|
//
|
|
//////////////////////////////////////////////////////////////
|
|
|
|
import gettext from 'sources/gettext';
|
|
import { styled } from '@mui/material/styles';
|
|
import url_for from 'sources/url_for';
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Box } from '@mui/material';
|
|
import PropTypes from 'prop-types';
|
|
import getApiInstance from '../../../../static/js/api_instance';
|
|
import HelpIcon from '@mui/icons-material/HelpRounded';
|
|
import SaveSharpIcon from '@mui/icons-material/SaveSharp';
|
|
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
|
import { PgButtonGroup, PgIconButton } from '../../../../static/js/components/Buttons';
|
|
import usePreferences from '../store';
|
|
import { usePgAdmin } from '../../../../static/js/PgAdminProvider';
|
|
import { InputText } from '../../../../static/js/components/FormComponents';
|
|
import { SearchRounded } from '@mui/icons-material';
|
|
import PreferencesSchema from './preferences.ui';
|
|
import { useFuzzySearchList } from '@nozbe/microfuzz/react';
|
|
import Loader from 'sources/components/Loader';
|
|
|
|
|
|
// Import helpers from new file
|
|
import {
|
|
reloadPgAdmin,
|
|
getNoteField,
|
|
prepareSubnodeData,
|
|
getCollectionValue,
|
|
showResetPrefModal
|
|
} from './PreferencesHelper';
|
|
import { LAYOUT_EVENTS, LayoutDockerContext } from '../../../../static/js/helpers/Layout';
|
|
import LeftTree from './LeftTree';
|
|
import RightPreference from './RightPreference';
|
|
|
|
// --- Styled Components ---
|
|
const Root = styled(Box)(({ theme }) => ({
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
background: theme.otherVars.emptySpaceBg,
|
|
overflow: 'hidden',
|
|
|
|
'& .PreferencesComponent-header': {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
background: theme.palette.background.default,
|
|
padding: theme.spacing(1),
|
|
...theme.mixins.panelBorder.bottom,
|
|
|
|
'& .PreferencesComponent-actionBtn': {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: theme.spacing(1),
|
|
},
|
|
|
|
'& .PreferencesComponent-searchInput': {
|
|
maxWidth: '300px',
|
|
marginLeft: 'auto',
|
|
},
|
|
},
|
|
|
|
'& .PreferencesComponent-body': {
|
|
flexGrow: 1,
|
|
minHeight: 0,
|
|
padding: theme.spacing(1),
|
|
|
|
'& .PreferencesComponent-bodyWrap': {
|
|
...theme.mixins.panelBorder.all,
|
|
display: 'flex',
|
|
height: '100%',
|
|
background: theme.palette.background.default,
|
|
|
|
'& .PreferencesComponent-treeContainer': {
|
|
minHeight: 0,
|
|
flexGrow: 1,
|
|
},
|
|
'& .PreferencesComponent-preferencesContainer': {
|
|
borderColor: `${theme.otherVars.borderColor} !important`,
|
|
borderLeft: '1px solid',
|
|
position: 'relative',
|
|
height: '100%',
|
|
overflow: 'auto',
|
|
width: '100%',
|
|
|
|
'& .PreferencesComponent-noSelection': {
|
|
padding: theme.spacing(1),
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: theme.spacing(0.5),
|
|
},
|
|
|
|
'& .PreferencesComponent-preferencesContainerBackground': {
|
|
backgroundColor: 'inherit',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'& .PreferencesComponent-footer': {
|
|
borderTop: `1px solid ${theme.otherVars.inputBorderColor} !important`,
|
|
padding: '0.5rem',
|
|
display: 'flex',
|
|
width: '100%',
|
|
background: theme.otherVars.headerBg,
|
|
'& .PreferencesComponent-actionBtn': {
|
|
alignItems: 'flex-start',
|
|
},
|
|
'& .PreferencesComponent-buttonMargin': {
|
|
marginLeft: '0.5em',
|
|
},
|
|
},
|
|
}));
|
|
|
|
// Helper to check if a page refresh is required
|
|
function checkRefreshRequired(pref) {
|
|
// Other preferences might also require a refresh, add them here
|
|
return pref.name === 'user_language';
|
|
};
|
|
|
|
// --- Main PreferencesComponent ---
|
|
export default function PreferencesComponent({panelId}) {
|
|
const [disableSave, setDisableSave] = useState(true);
|
|
const prefSchema = useRef(new PreferencesSchema({}, []));
|
|
const prefChangedData = useRef({});
|
|
const [prefTreeData, setPrefTreeData] = useState([]);
|
|
const [initValues, setInitValues] = useState({});
|
|
const api = getApiInstance();
|
|
const firstTreeElement = useRef('');
|
|
const preferencesStore = usePreferences();
|
|
const pgAdmin = usePgAdmin();
|
|
const [searchVal, setSearchVal] = useState('');
|
|
const [selectedItem, setSelectedItem] = useState(null);
|
|
const [loaderText, setLoaderText] = useState(gettext('Loading preferences...'));
|
|
const layoutDocker = React.useContext(LayoutDockerContext);
|
|
const valuesVersionRef = useRef();
|
|
|
|
const fetchPreferences = async () => {
|
|
setLoaderText(gettext('Loading preferences...'));
|
|
try {
|
|
const res = await api({
|
|
url: url_for('preferences.index'),
|
|
method: 'GET',
|
|
});
|
|
|
|
const schemaFields = [];
|
|
const treeNodesData = [];
|
|
let values = {};
|
|
|
|
res.data.forEach((node) => {
|
|
const categoryNode = {
|
|
id: node.id.toString(),
|
|
name: node.label,
|
|
key: node.name,
|
|
children: [],
|
|
};
|
|
|
|
if (firstTreeElement.current.length === 0) {
|
|
firstTreeElement.current = node.label;
|
|
}
|
|
|
|
node.children.forEach((subNode) => {
|
|
const nodeData = {
|
|
id: `${categoryNode.id}_${subNode.id}`,
|
|
name: subNode.label,
|
|
key: subNode.name,
|
|
};
|
|
|
|
categoryNode.children.push(nodeData);
|
|
schemaFields.push(...getNoteField(node, subNode, nodeData));
|
|
|
|
const {fieldItems, fieldValues} = prepareSubnodeData(node, subNode, nodeData, preferencesStore);
|
|
schemaFields.push(...fieldItems);
|
|
values = {...values, ...fieldValues};
|
|
});
|
|
treeNodesData.push(categoryNode);
|
|
});
|
|
|
|
valuesVersionRef.current = new Date().getTime();
|
|
setPrefTreeData(treeNodesData);
|
|
setInitValues(values);
|
|
setSelectedItem(selectedItem || treeNodesData[0]?.children[0]);
|
|
prefSchema.current = new PreferencesSchema(values, schemaFields);
|
|
setLoaderText(null);
|
|
} catch (err) {
|
|
pgAdmin.Browser.notifier.alert(err.response?.data || err.message || gettext('Failed to load preferences.'));
|
|
}
|
|
};
|
|
|
|
// Effect to fetch preferences data on component mount
|
|
useEffect(() => {
|
|
fetchPreferences();
|
|
}, []); // Added dependencies
|
|
|
|
useEffect(()=>{
|
|
/* Bind the close event and check if user should be warned */
|
|
const deregister = layoutDocker.eventBus.registerListener(LAYOUT_EVENTS.CLOSING, (id)=>{
|
|
if(panelId != id) return;
|
|
|
|
if(Object.keys(prefChangedData.current).length > 0) {
|
|
pgAdmin.Browser.notifier.confirm(
|
|
gettext('Warning'),
|
|
gettext('Changes will be lost. Are you sure you want to close the preferences?'),
|
|
function() {
|
|
layoutDocker.close(panelId, true);
|
|
return true;
|
|
},
|
|
null
|
|
);
|
|
return false; // Prevent closing
|
|
}
|
|
layoutDocker.close(panelId, true);
|
|
});
|
|
return ()=>{
|
|
deregister();
|
|
};
|
|
}, []);
|
|
|
|
|
|
const savePreferences = async () => {
|
|
const _data = [];
|
|
setLoaderText(gettext('Saving preferences...'));
|
|
for (const [key, value] of Object.entries(prefChangedData.current)) {
|
|
const _metadata = prefSchema.current.schemaFields.find((el) => el.id == key); // Find directly
|
|
if (_metadata) {
|
|
const val = getCollectionValue([_metadata], value, initValues); // Pass _metadata as array for consistency
|
|
_data.push({
|
|
category_id: _metadata.cid,
|
|
id: parseInt(key),
|
|
mid: _metadata.mid,
|
|
name: _metadata.name,
|
|
value: val,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (_data.length === 0) {
|
|
// No changes to save, just close modal
|
|
return;
|
|
}
|
|
|
|
const layoutPref = _data.find((x) => x.name === 'layout');
|
|
|
|
const saveData = async (shouldReloadOnLayoutChange = false) => {
|
|
try {
|
|
await api({
|
|
url: url_for('preferences.index'),
|
|
method: 'PUT',
|
|
data: _data,
|
|
});
|
|
|
|
if (shouldReloadOnLayoutChange) {
|
|
await api({
|
|
url: url_for('workspace.layout_changed'),
|
|
method: 'DELETE', // DELETE seems unusual for layout_changed, but maintaining original logic
|
|
data: _data,
|
|
});
|
|
pgAdmin.Browser.tree.destroy().then(() => {
|
|
pgAdmin.Browser.Events.trigger('pgadmin-browser:tree:destroyed', undefined, undefined);
|
|
reloadPgAdmin(); // Reload after destroying tree
|
|
});
|
|
} else {
|
|
const requiresTreeRefresh = _data.some((s) =>
|
|
['show_system_objects', 'show_empty_coll_nodes', 'hide_shared_server', 'show_user_defined_templates'].includes(s.name) || s.name.startsWith('show_node_')
|
|
);
|
|
|
|
let requiresFullPageRefresh = false;
|
|
for (const key of Object.keys(prefChangedData.current)) {
|
|
const pref = preferencesStore.getPreferenceForId(Number(key));
|
|
if (pref && checkRefreshRequired(pref)) {
|
|
requiresFullPageRefresh = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
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?'),
|
|
() => {
|
|
pgAdmin.Browser.tree.destroy().then(() => {
|
|
pgAdmin.Browser.Events.trigger('pgadmin-browser:tree:destroyed', undefined, undefined);
|
|
});
|
|
return true;
|
|
},
|
|
() => true,
|
|
gettext('Refresh'),
|
|
gettext('Later')
|
|
);
|
|
}
|
|
|
|
if (requiresFullPageRefresh) {
|
|
pgAdmin.Browser.notifier.confirm(
|
|
gettext('Refresh required'),
|
|
gettext('A page refresh is required. Do you wish to refresh the page now?'),
|
|
() => {
|
|
reloadPgAdmin();
|
|
return true;
|
|
},
|
|
() => { }, // Close modal if user opts for "Later"
|
|
gettext('Refresh'),
|
|
gettext('Later')
|
|
);
|
|
}
|
|
}
|
|
preferencesStore.cache(); // Refresh preferences cache
|
|
fetchPreferences();
|
|
} catch (err) {
|
|
pgAdmin.Browser.notifier.alert(err.response?.data || err.message || gettext('Failed to save preferences.'));
|
|
}
|
|
};
|
|
|
|
if (layoutPref && layoutPref.value === 'classic') {
|
|
pgAdmin.Browser.notifier.confirm(
|
|
gettext('Layout changed'),
|
|
`${gettext('Switching from Workspace to Classic layout will disconnect all server connections and refresh the entire page.')}
|
|
${gettext('To avoid losing unsaved data, click Cancel to manually review and close your connections.')}
|
|
${gettext('Note that if you choose Cancel, any changes to your preferences will not be saved.')}<br><br>
|
|
${gettext('Do you want to continue?')}`,
|
|
() => saveData(true), // User confirms, proceed with reload
|
|
() => false, // User cancels, do nothing
|
|
gettext('Continue'),
|
|
gettext('Cancel')
|
|
);
|
|
} else {
|
|
saveData();
|
|
}
|
|
};
|
|
|
|
const resetAllPreferences = () => {
|
|
showResetPrefModal(api, pgAdmin, preferencesStore, ()=>{
|
|
fetchPreferences();
|
|
});
|
|
};
|
|
|
|
const filteredList = useFuzzySearchList({
|
|
strategy: 'off',
|
|
queryText: searchVal,
|
|
getText: (item) => [item.label, item.helpMessage],
|
|
list: prefSchema.current.schemaFields,
|
|
mapResultItem: ({ item }) => item
|
|
});
|
|
|
|
const filteredItemIds = useMemo(()=>filteredList.map((item) => item.id), [filteredList]);
|
|
|
|
return (
|
|
<Root height={'100%'}>
|
|
<Box className='PreferencesComponent-header'>
|
|
<Box className='PreferencesComponent-actionBtn'>
|
|
<PgButtonGroup>
|
|
<PgIconButton
|
|
icon={<SaveSharpIcon style={{height: '1.4rem'}}/>}
|
|
aria-label="Save"
|
|
title={gettext('Save')}
|
|
onClick={savePreferences}
|
|
disabled={disableSave || Boolean(loaderText)}
|
|
/>
|
|
</PgButtonGroup>
|
|
<PgButtonGroup>
|
|
<PgIconButton
|
|
onClick={resetAllPreferences}
|
|
icon={<SettingsBackupRestoreIcon />}
|
|
aria-label="Reset all preferences"
|
|
title={gettext('Reset all preferences')}
|
|
/>
|
|
<PgIconButton
|
|
data-test="dialog-help" onClick={()=>{
|
|
window.open(url_for('help.static', { filename: 'preferences.html' }), 'pgadmin_help');
|
|
}}
|
|
icon={<HelpIcon />} title={gettext('Help')}
|
|
/>
|
|
</PgButtonGroup>
|
|
</Box>
|
|
<InputText
|
|
className='PreferencesComponent-searchInput'
|
|
placeholder={gettext('Search')}
|
|
controlProps={{ title: gettext('Search') }}
|
|
value={searchVal}
|
|
onChange={setSearchVal} // Direct setter for state
|
|
startAdornment={<SearchRounded />}
|
|
/>
|
|
</Box>
|
|
<Box className='PreferencesComponent-body'>
|
|
<Loader message={loaderText} />
|
|
<div className='PreferencesComponent-bodyWrap'>
|
|
<LeftTree
|
|
prefTreeData={prefTreeData}
|
|
selectedItem={selectedItem}
|
|
setSelectedItem={setSelectedItem}
|
|
filteredList={filteredList}
|
|
/>
|
|
{
|
|
prefSchema.current &&
|
|
<RightPreference
|
|
key={valuesVersionRef.current}
|
|
schema={prefSchema.current}
|
|
initValues={initValues}
|
|
filteredItemIds={filteredItemIds}
|
|
selectedItem={selectedItem}
|
|
setSelectedItem={setSelectedItem}
|
|
onDataChange={(changedData) => {
|
|
setDisableSave(Object.keys(changedData).length === 0);
|
|
prefChangedData.current = changedData;
|
|
}}
|
|
/>
|
|
}
|
|
</div>
|
|
</Box >
|
|
</Root>
|
|
);
|
|
}
|
|
|
|
PreferencesComponent.propTypes = {
|
|
panelId: PropTypes.string.isRequired,
|
|
};
|