pgadmin4/web/pgadmin/static/js/helpers/Layout/index.jsx

512 lines
16 KiB
JavaScript

import React, { useRef, useMemo, useEffect, useCallback, useState } from 'react';
import DockLayout from 'rc-dock';
import PropTypes from 'prop-types';
import EventBus from '../EventBus';
import getApiInstance from '../../api_instance';
import url_for from 'sources/url_for';
import { PgIconButton } from '../../components/Buttons';
import CloseIcon from '@mui/icons-material/CloseRounded';
import gettext from 'sources/gettext';
import {ExpandDialogIcon, MinimizeDialogIcon } from '../../components/ExternalIcon';
import { Box } from '@mui/material';
import ErrorBoundary from '../ErrorBoundary';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ContextMenu from '../../components/ContextMenu';
import { showRenameTab } from '../../Dialogs';
import usePreferences from '../../../../preferences/static/js/store';
import _ from 'lodash';
function TabTitle({id, closable, defaultInternal}) {
const layoutDocker = React.useContext(LayoutDockerContext);
const internal = layoutDocker?.find(id)?.internal ?? defaultInternal;
const [attrs, setAttrs] = useState({
icon: internal.icon,
title: internal.title,
tooltip: internal.tooltip ?? internal.title,
});
const onContextMenu = useCallback((e)=>{
const g = layoutDocker.find(id)?.group??'';
if((layoutDocker.noContextGroups??[]).includes(g)) return;
e.preventDefault();
layoutDocker.eventBus.fireEvent(LAYOUT_EVENTS.CONTEXT, e, id);
}, []);
useEffect(()=>{
const deregister = layoutDocker.eventBus.registerListener(LAYOUT_EVENTS.REFRESH_TITLE, _.debounce((panelId)=>{
if(panelId == id) {
const internal = layoutDocker?.find(id)?.internal??{};
setAttrs({
icon: internal.icon,
title: internal.title,
tooltip: internal.tooltip ?? internal.title,
});
}
}, 100));
return ()=>deregister?.();
}, []);
return (
<Box display="flex" alignItems="center" title={attrs.tooltip} onContextMenu={onContextMenu} width="100%">
{attrs.icon && <span className={`dock-tab-icon ${attrs.icon}`}></span>}
<span style={{textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap'}} data-visible={layoutDocker.isTabVisible(id)}>{attrs.title}</span>
{closable && <PgIconButton title={gettext('Close')} icon={<CloseIcon style={{height: '0.7em'}} />} size="xs" noBorder onClick={()=>{
layoutDocker.close(id);
}} style={{margin: '-1px -10px -1px 0'}} />}
</Box>
);
}
TabTitle.propTypes = {
id: PropTypes.string,
closable: PropTypes.bool,
defaultInternal: PropTypes.object
};
export class LayoutDocker {
constructor(layoutId, defaultLayout, resetToTabPanel, noContextGroups) {
this.layoutId = layoutId;
this.defaultLayout = defaultLayout;
/* When reset layout, we'll move the manually added tabs to this panel */
this.resetToTabPanel = resetToTabPanel;
// don't show context for these groups
this.noContextGroups = noContextGroups??[];
this.noContextGroups.push('dialogs');
this.layoutObj = null;
this.eventBus = new EventBus();
}
close(panelId, force=false) {
const panelData = this.find(panelId);
if(!panelData) {
return;
}
if(!panelData.internal?.closable) {
return;
}
if(panelData.internal?.manualClose && !force) {
this.eventBus.fireEvent(LAYOUT_EVENTS.CLOSING, panelId);
} else {
this.layoutObj.dockMove(panelData, null, 'remove');
// rc-dock is not firing the "active" event after a tab is removed
// and another is focussed. here we try get the new active id and
// manually fire the active event
const newActiveId = this.find(panelData?.parent?.id)?.activeId;
if(newActiveId) {
this.eventBus.fireEvent(LAYOUT_EVENTS.ACTIVE, newActiveId);
}
}
}
closeAll(panelId, exceptCurrent=false) {
let parentData = this.find(panelId);
if(_.isUndefined(parentData.tabs)) {
parentData = parentData.parent;
}
if(parentData?.tabs) {
parentData.tabs.filter((t)=>(t.internal?.closable && (exceptCurrent ? t.id!=panelId : true))).forEach((t)=>{
this.close(t.id);
});
}
}
focus(panelId) {
this.layoutObj.updateTab(panelId, null, true);
}
//it will navigate to nearest panel/tab
navigatePanel() {
this.layoutObj.navigateToPanel();
}
find(...args) {
return this.layoutObj?.find(...args);
}
setTitle(panelId, title, icon, tooltip) {
const panelData = this.find(panelId);
if(!panelData) return;
const internal = {
...panelData.internal,
};
if(title) {
internal.title = title;
}
if(icon) {
internal.icon = icon;
}
if(tooltip) {
internal.tooltip = tooltip;
}
panelData.internal = internal;
this.eventBus.fireEvent(LAYOUT_EVENTS.REFRESH_TITLE, panelId);
}
setInternalAttrs(panelId, attrs) {
const panelData = this.find(panelId);
panelData.internal = {
...panelData.internal,
...attrs,
};
}
getInternalAttrs(panelId) {
const panelData = this.find(panelId);
return panelData.internal;
}
openDialog(panelData, width=500, height=300) {
let panel = this.layoutObj.find(panelData.id);
if(panel) {
this.layoutObj.dockMove(panel, null, 'front');
} else {
let {width: lw, height: lh} = this.layoutObj.getLayoutSize();
/* position in more top direction */
lw = (lw - width)/2;
lh = (lh - height)/5;
this.layoutObj.dockMove({
x: lw,
y: lh,
w: width,
h: height,
tabs: [LayoutDocker.getPanel({
...panelData,
content: <ErrorBoundary>{panelData.content}</ErrorBoundary>,
group: 'dialogs',
closable: true,
})],
}, null, 'float');
}
}
isTabOpen(panelId) {
return Boolean(this.layoutObj.find(panelId));
}
isTabVisible(panelId) {
let panelData = this.layoutObj?.find(panelId);
return panelData?.parent?.activeId == panelData?.id;
}
openTab(panelData, refTabId, direction='middle', forceRerender=false) {
let panel = this.layoutObj.find(panelData.id);
if(panel) {
if(forceRerender) {
this.layoutObj.updateTab(panelData.id, LayoutDocker.getPanel(panelData), true);
} else {
this.focus(panelData.id);
}
} else {
let tgtPanel = this.layoutObj.find(refTabId);
this.layoutObj.dockMove(LayoutDocker.getPanel(panelData), tgtPanel, direction);
}
}
loadLayout(savedLayout) {
try {
this.layoutObj.loadLayout(JSON.parse(savedLayout));
} catch {
/* Fallback to default */
this.layoutObj.loadLayout(this.defaultLayout);
}
}
saveLayout(l) {
let api = getApiInstance();
if(!this.layoutId || !this.layoutObj) {
return;
}
const formData = new FormData();
formData.append('setting', this.layoutId);
formData.append('value', JSON.stringify(l || this.layoutObj.saveLayout()));
api.post(url_for('settings.store_bulk'), formData)
.catch(()=>{/* No need to throw error */});
}
resetLayout() {
const flatCurr = [];
const flatDefault = [];
// flatten the nested tabs into an array
const flattenLayout = (box, arr)=>{
box.children.forEach((child)=>{
if(child.children) {
flattenLayout(child, arr);
} else {
arr.push(...child.tabs??[]);
}
});
};
flattenLayout(this.defaultLayout.dockbox, flatDefault);
flattenLayout(this.layoutObj.getLayout().dockbox, flatCurr);
// Find the difference between default layout and current layout
let saveNonDefaultTabs = _.differenceBy(flatCurr, flatDefault, 'id');
// load the default layout
this.layoutObj.loadLayout(this.defaultLayout);
const focusOn = this.find(this.resetToTabPanel)?.activeId;
// restor the tabs opened
saveNonDefaultTabs.forEach((t)=>{
this.openTab({
id: t.id, content: t.content, ...t.internal
}, this.resetToTabPanel, 'middle');
});
focusOn && this.focus(focusOn);
this.saveLayout();
}
static getPanel({icon, title, closable, tooltip, renamable, manualClose, ...attrs}) {
const internal = {
icon: icon,
title: title,
tooltip: tooltip,
closable: _.isUndefined(closable) ? manualClose : closable,
renamable: renamable,
manualClose: manualClose,
};
return {
cached: true,
group: 'default',
minWidth: 200,
...attrs,
closable: false,
title: <TabTitle id={attrs.id} closable={attrs.group!='dialogs' && closable} defaultInternal={internal}/>,
internal: internal
};
}
static moveTo(direction) {
let dockBar = document.activeElement.closest('.dock')?.querySelector('.dock-bar.drag-initiator');
if(dockBar) {
let key = {
key: 'ArrowRight', keyCode: 39, which: 39, code: 'ArrowRight',
metaKey: false, ctrlKey: false, shiftKey: false, altKey: false,
bubbles: true,
};
if(direction == 'right') {
key = {
...key,
key: 'ArrowRight', keyCode: 39, which: 39, code: 'ArrowRight'
};
} else if(direction == 'left') {
key = {
...key,
key: 'ArrowLeft', keyCode: 37, which: 37, code: 'ArrowLeft',
};
}
dockBar.dispatchEvent(new KeyboardEvent('keydown', key));
}
}
static switchPanel() {
let currDockPanel = document.activeElement.closest('.dock-panel.dock-style-default');
let dockLayoutPanels = currDockPanel?.closest('.dock-layout').querySelectorAll('.dock-panel.dock-style-default');
if(dockLayoutPanels?.length > 1) {
for(let i=0; i<dockLayoutPanels.length; i++) {
if(dockLayoutPanels[i] == currDockPanel) {
let newPanelIdx = (i+1)%dockLayoutPanels.length;
dockLayoutPanels[newPanelIdx]?.querySelector('.dock-tab.dock-tab-active .dock-tab-btn')?.focus();
break;
}
}
}
}
}
export const LayoutDockerContext = React.createContext(new LayoutDocker(null, null));
function DialogClose({panelData}) {
const layoutDocker = React.useContext(LayoutDockerContext);
// In a dialog, panelData is the data of the container panel and not the
// data of actual dialog tab. panelData.activeId gives the id of dialog tab.
return (
<Box display="flex" alignItems="center">
<PgIconButton title={gettext('Close')} icon={<CloseIcon />} size="xs" noBorder onClick={()=>{
layoutDocker.close(panelData.activeId);
}} style={{marginRight: '-4px'}}/>
</Box>
);
}
DialogClose.propTypes = {
panelData: PropTypes.object
};
function getDialogsGroup() {
return {
disableDock: true,
tabLocked: true,
floatable: 'singleTab',
moreIcon: <ExpandMoreIcon style={{height: '0.9em'}} />,
panelExtra: (panelData) => {
return <DialogClose panelData={panelData} />;
}
};
}
export function getDefaultGroup() {
return {
closable: false,
maximizable: false,
floatable: false,
moreIcon: <ExpandMoreIcon style={{height: '0.9em', marginTop: '4px'}} />,
panelExtra: (panelData, context) => {
let icon = <ExpandDialogIcon style={{width: '0.7em'}}/>;
let title = gettext('Maximise');
if(panelData?.parent?.mode == 'maximize') {
icon = <MinimizeDialogIcon />;
title = gettext('Restore');
}
return <Box display="flex" alignItems="center">
{Boolean(panelData.maximizable) && <PgIconButton title={title} icon={icon} size="xs" noBorder onClick={()=>{
context.dockMove(panelData, null, 'maximize');
}} />}
</Box>;
}
};
}
export default function Layout({groups, noContextGroups, getLayoutInstance, layoutId, savedLayout, resetToTabPanel, ...props}) {
const [[contextPos, contextPanelId, contextExtraMenus], setContextPos] = React.useState([null, null, null]);
const defaultGroups = React.useMemo(()=>({
'dialogs': getDialogsGroup(),
'default': getDefaultGroup(),
...groups,
}), [groups]);
const layoutDockerObj = React.useMemo(()=>new LayoutDocker(layoutId, props.defaultLayout, resetToTabPanel, noContextGroups), []);
const prefStore = usePreferences();
const dynamicTabsStyleRef = useRef();
useEffect(()=>{
layoutDockerObj.eventBus.registerListener(LAYOUT_EVENTS.REMOVE, (panelId)=>{
layoutDockerObj.close(panelId);
});
layoutDockerObj.eventBus.registerListener(LAYOUT_EVENTS.CONTEXT, (e, id, extraMenus)=>{
setContextPos([{x: e.clientX, y: e.clientY}, id, extraMenus]);
});
}, []);
useEffect(()=>{
const dynamicTabs = prefStore.getPreferencesForModule('browser')?.dynamic_tabs;
// Add a class to set max width for non dynamic Tabs
if(!dynamicTabs && !dynamicTabsStyleRef.current) {
const css = '.dock-tab:not(div.dock-tab-active) { max-width: 180px; }',
head = document.head || document.getElementsByTagName('head')[0];
dynamicTabsStyleRef.current = document.createElement('style');
head.appendChild(dynamicTabsStyleRef.current);
dynamicTabsStyleRef.current.appendChild(document.createTextNode(css));
} else if(dynamicTabs && dynamicTabsStyleRef.current) {
dynamicTabsStyleRef.current.remove();
dynamicTabsStyleRef.current = null;
}
}, [prefStore]);
const getTabMenuItems = (panelId)=>{
const ret = [];
if(panelId) {
const panelData = layoutDockerObj?.find(panelId);
if(_.isUndefined(panelData.tabs)) {
if(panelData.internal.closable) {
ret.push({
label: gettext('Close'),
callback: ()=>{
layoutDockerObj.close(panelId);
}
});
}
if(panelData.parent?.tabs?.length > 1) {
ret.push({
label: gettext('Close Others'),
callback: ()=>{
layoutDockerObj.closeAll(panelId, true);
}
});
}
}
ret.push({
label: gettext('Close All'),
callback: ()=>{
layoutDockerObj.closeAll(panelId);
}
});
if(panelData.internal?.renamable) {
ret.push({
type: 'separator',
}, {
label: gettext('Rename'),
callback: ()=>{
showRenameTab(panelId, layoutDockerObj);
}
});
}
}
return ret;
};
const contextMenuItems = getTabMenuItems(contextPanelId)
.concat(contextExtraMenus ? [{type: 'separator'}, ...contextExtraMenus] : []);
return (
<LayoutDockerContext.Provider value={layoutDockerObj}>
{useMemo(()=>(<DockLayout
style={{
height: '100%',
}}
ref={(obj)=>{
if(obj) {
layoutDockerObj.layoutObj = obj;
getLayoutInstance?.(layoutDockerObj);
layoutDockerObj.loadLayout(savedLayout);
}
}}
groups={defaultGroups}
onLayoutChange={(l, currentTabId, direction)=>{
if(Object.values(LAYOUT_EVENTS).indexOf(direction) > -1) {
layoutDockerObj.eventBus.fireEvent(LAYOUT_EVENTS[direction.toUpperCase()], currentTabId);
layoutDockerObj.saveLayout(l);
} else if(direction && direction != 'update') {
layoutDockerObj.eventBus.fireEvent(LAYOUT_EVENTS.CHANGE, currentTabId);
layoutDockerObj.saveLayout(l);
}
}}
{...props}
/>), [])}
<div id="layout-portal"></div>
<ContextMenu menuItems={contextMenuItems} position={contextPos} onClose={()=>setContextPos([null, null, null])}
label="Layout Context Menu" />
</LayoutDockerContext.Provider>
);
}
Layout.propTypes = {
groups: PropTypes.object,
defaultLayout: PropTypes.object,
noContextGroups: PropTypes.array,
getLayoutInstance: PropTypes.func,
layoutId: PropTypes.string,
savedLayout: PropTypes.string,
resetToTabPanel: PropTypes.string,
};
export const LAYOUT_EVENTS = {
ACTIVE: 'active',
REMOVE: 'remove',
FLOAT: 'float',
FRONT: 'front',
MAXIMIZE: 'maximize',
MOVE: 'move',
CLOSING: 'closing',
CONTEXT: 'context',
CHANGE: 'change',
REFRESH_TITLE: 'refresh-title'
};