21720: fixed styling in multi-panel-view
[arvados.git] / services / workbench2 / src / components / multi-panel-view / multi-panel-view.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React, { MutableRefObject, ReactElement, ReactNode, useEffect, useRef, useState } from 'react';
6 import { CustomStyleRulesCallback } from 'common/custom-theme';
7 import { Button, Grid, Paper, Tooltip, Tabs, Tab } from "@mui/material";
8 import { WithStyles } from '@mui/styles';
9 import withStyles from '@mui/styles/withStyles';
10 import { GridProps } from '@mui/material/Grid';
11 import { isArray } from 'lodash';
12 import { DefaultView } from 'components/default-view/default-view';
13 import { InfoIcon } from 'components/icon/icon';
14 import classNames from 'classnames';
15
16 type CssRules =
17     | 'root'
18     | 'gridContainerRoot'
19     | 'exclusiveGridContainerRoot'
20     | 'gridItemRoot'
21     | 'paperRoot'
22     | 'button'
23     | 'buttonIcon'
24     | 'content'
25     | 'exclusiveContentPaper'
26     | 'exclusiveContent'
27     | 'buttonBarGridContainer'
28     | 'tabs';
29
30 const styles: CustomStyleRulesCallback<CssRules> = theme => ({
31     root: {
32         marginTop: '0',
33     },
34     gridContainerRoot: {
35         margin: '10px -4px -4px',
36         width: 'calc(100% + 8px) !important',
37     },
38     exclusiveGridContainerRoot: {
39         marginTop: 0,
40     },
41     gridItemRoot: {
42         paddingTop: '0 !important',
43     },
44     paperRoot: {
45         height: '100%',
46         display: 'flex',
47         flexDirection: 'column',
48     },
49     button: {
50         padding: '2px 5px',
51         marginRight: '5px',
52     },
53     buttonIcon: {
54         boxShadow: 'none',
55         padding: '2px 0px 2px 5px',
56         fontSize: '1rem'
57     },
58     content: {
59         overflow: 'auto',
60         margin: '-4px',
61         padding: '4px !important',
62     },
63     exclusiveContent: {
64         overflow: 'auto',
65         margin: 0,
66     },
67     exclusiveContentPaper: {
68         boxShadow: 'none',
69     },
70     buttonBarGridContainer: {
71         padding: '4px !important',
72     },
73     tabs: {
74         flexGrow: 1,
75         flexShrink: 1,
76         maxWidth: 'initial',
77         borderBottom: `1px solid ${theme.palette.grey[300]}`,
78     },
79 });
80
81 interface MPVHideablePanelDataProps {
82     name: string;
83     visible: boolean;
84     maximized: boolean;
85     illuminated: boolean;
86     children: ReactNode;
87     panelRef?: MutableRefObject<any>;
88     paperClassName?: string;
89 }
90
91 interface MPVHideablePanelActionProps {
92     doHidePanel: () => void;
93     doMaximizePanel: () => void;
94     doUnMaximizePanel: () => void;
95 }
96
97 type MPVHideablePanelProps = MPVHideablePanelDataProps & MPVHideablePanelActionProps;
98
99 const MPVHideablePanel = ({ doHidePanel, doMaximizePanel, doUnMaximizePanel, name, visible, maximized, illuminated, paperClassName, ...props }: MPVHideablePanelProps) =>
100     visible
101         ? <>
102             {React.cloneElement((props.children as ReactElement), {
103                 doHidePanel,
104                 doMaximizePanel,
105                 doUnMaximizePanel,
106                 panelName: name,
107                 panelMaximized: maximized,
108                 panelIlluminated: illuminated,
109                 panelRef: props.panelRef,
110                 paperClassName,
111             })}
112         </>
113         : null;
114
115 interface MPVPanelDataProps {
116     panelName?: string;
117     panelMaximized?: boolean;
118     panelIlluminated?: boolean;
119     panelRef?: MutableRefObject<any>;
120     forwardProps?: boolean;
121     maxHeight?: string;
122     minHeight?: string;
123     paperClassName?: string;
124 }
125
126 interface MPVPanelActionProps {
127     doHidePanel?: () => void;
128     doMaximizePanel?: () => void;
129     doUnMaximizePanel?: () => void;
130 }
131
132 // Props received by panel implementors
133 export type MPVPanelProps = MPVPanelDataProps & MPVPanelActionProps;
134
135 type MPVPanelContentProps = { children: ReactElement } & MPVPanelProps & GridProps;
136
137 // Grid item compatible component for layout and MPV props passing
138 export const MPVPanelContent = ({ doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName,
139     panelMaximized, panelIlluminated, panelRef, forwardProps, maxHeight, minHeight, paperClassName,
140     ...props }: MPVPanelContentProps) => {
141     useEffect(() => {
142         if (panelRef && panelRef.current) {
143             panelRef.current.scrollIntoView({ alignToTop: true });
144         }
145     }, [panelRef]);
146
147     const maxH = panelMaximized
148         ? '100%'
149         : maxHeight;
150
151     return <Grid item style={{ maxHeight: maxH, minHeight, padding: '4px' }} {...props}>
152         <span ref={panelRef} /> {/* Element to scroll to when the panel is selected */}
153         <Paper style={{ height: '100%' }} elevation={panelIlluminated ? 8 : 0}>
154             {forwardProps
155                 ? React.cloneElement(props.children, { doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName, panelMaximized, paperClassName })
156                 : React.cloneElement(props.children, { paperClassName })}
157         </Paper>
158     </Grid>;
159 }
160
161 export interface MPVPanelState {
162     name: string;
163     visible?: boolean;
164 }
165 interface MPVContainerDataProps {
166     panelStates?: MPVPanelState[];
167     mutuallyExclusive?: boolean;
168 }
169 type MPVContainerProps = MPVContainerDataProps & GridProps;
170
171 // Grid container compatible component that also handles panel toggling.
172 const MPVContainerComponent = ({ children, panelStates, classes, ...props }: MPVContainerProps & WithStyles<CssRules>) => {
173     if (children === undefined || children === null || Object.keys(children).length === 0) {
174         children = [];
175     } else if (!isArray(children)) {
176         children = [children];
177     }
178     const initialVisibility = (children as ReactNode[]).map((_, idx) =>
179         !panelStates || // if panelStates wasn't passed, default to all visible panels
180         (panelStates[idx] &&
181             (panelStates[idx].visible || panelStates[idx].visible === undefined)));
182     const [panelVisibility, setPanelVisibility] = useState<boolean[]>(initialVisibility);
183     const [previousPanelVisibility, setPreviousPanelVisibility] = useState<boolean[]>(initialVisibility);
184     const [highlightedPanel, setHighlightedPanel] = useState<number>(-1);
185     const currentSelectedPanel = panelVisibility.findIndex(Boolean);
186     const [selectedPanel, setSelectedPanel] = useState<number>(-1);
187     const panelRef = useRef<any>(null);
188
189     let panels: JSX.Element[] = [];
190     let buttons: JSX.Element[] = [];
191     let tabs: JSX.Element[] = [];
192     let buttonBar: JSX.Element = <></>;
193
194     if (isArray(children)) {
195         const showFn = (idx: number) => () => {
196             setPreviousPanelVisibility(initialVisibility);
197             if (props.mutuallyExclusive) {
198                 // Hide all other panels
199                 setPanelVisibility([
200                     ...(new Array(idx).fill(false)),
201                     true,
202                     ...(new Array(panelVisibility.length-(idx+1)).fill(false)),
203                 ]);
204             } else {
205                 setPanelVisibility([
206                     ...panelVisibility.slice(0, idx),
207                     true,
208                     ...panelVisibility.slice(idx + 1)
209                 ]);
210             }
211             setSelectedPanel(idx);
212         };
213         const hideFn = (idx: number) => () => {
214             setPreviousPanelVisibility(initialVisibility);
215             setPanelVisibility([
216                 ...panelVisibility.slice(0, idx),
217                 false,
218                 ...panelVisibility.slice(idx+1)
219             ])
220         };
221         const maximizeFn = (idx: number) => () => {
222             setPreviousPanelVisibility(panelVisibility);
223             // Maximize X == hide all but X
224             setPanelVisibility([
225                 ...panelVisibility.slice(0, idx).map(() => false),
226                 true,
227                 ...panelVisibility.slice(idx+1).map(() => false),
228             ]);
229         };
230         const unMaximizeFn = (idx: number) => () => {
231             setPanelVisibility(previousPanelVisibility);
232             setSelectedPanel(idx);
233         }
234         for (let idx = 0; idx < children.length; idx++) {
235             const panelName = panelStates === undefined
236                 ? `Panel ${idx + 1}`
237                 : (panelStates[idx] && panelStates[idx].name) || `Panel ${idx + 1}`;
238             const btnVariant = panelVisibility[idx]
239                 ? "contained"
240                 : "outlined";
241             const btnTooltip = panelVisibility[idx]
242                 ? ``
243                 : `Open ${panelName} panel`;
244             const panelIsMaximized = panelVisibility[idx] &&
245                 panelVisibility.filter(e => e).length === 1;
246
247             buttons = [
248                 ...buttons,
249                 <Tooltip title={btnTooltip} disableFocusListener>
250                     <Button variant={btnVariant} size="small" color="primary"
251                         className={classNames(classes.button)}
252                         onMouseEnter={() => {
253                             setHighlightedPanel(idx);
254                         }}
255                         onMouseLeave={() => {
256                             setHighlightedPanel(-1);
257                         }}
258                         onClick={showFn(idx)}>
259                         {panelName}
260                     </Button>
261                 </Tooltip>
262             ];
263
264             tabs = [
265                 ...tabs,
266                 <>{panelName}</>
267             ];
268
269             const aPanel =
270                 <MPVHideablePanel
271                     key={idx}
272                     visible={panelVisibility[idx]}
273                     name={panelName}
274                     paperClassName={props.mutuallyExclusive ? classes.exclusiveContentPaper : undefined}
275                     panelRef={(idx === selectedPanel) ? panelRef : undefined}
276                     maximized={panelIsMaximized} illuminated={idx === highlightedPanel}
277                     doHidePanel={hideFn(idx)} doMaximizePanel={maximizeFn(idx)} doUnMaximizePanel={panelIsMaximized ? unMaximizeFn(idx) : () => null}>
278                     {children[idx]}
279                 </MPVHideablePanel>;
280             panels = [...panels, aPanel];
281         };
282
283         buttonBar = props.mutuallyExclusive ?
284             <Tabs value={currentSelectedPanel} onChange={(e, val) => showFn(val)()} data-cy={"mpv-tabs"}>
285                 {tabs.map((tgl, idx) => <Tab className={classes.tabs} key={idx} label={tgl} />)}
286             </Tabs> :
287             <Grid container item direction="row" className={classes.buttonBarGridContainer}>
288                 {buttons.map((tgl, idx) => <Grid item key={idx}>{tgl}</Grid>)}
289             </Grid>;
290     };
291
292     const content = <Grid container item {...props} xs className={props.mutuallyExclusive ? classes.exclusiveContent : classes.content}
293         onScroll={() => setSelectedPanel(-1)}>
294         {panelVisibility.includes(true)
295             ? panels
296             : <Grid container item alignItems='center' justifyContent='center'>
297                 <DefaultView messages={["All panels are hidden.", "Click on the buttons above to show them."]} icon={InfoIcon} />
298             </Grid>}
299     </Grid>;
300
301     if (props.mutuallyExclusive) {
302         return <Grid container {...props} className={classNames(classes.exclusiveGridContainerRoot, props.className)}>
303             <Grid item {...props} className={classes.gridItemRoot}>
304                 <Paper className={classes.paperRoot}>
305                     {buttonBar}
306                     {content}
307                 </Paper>
308             </Grid>
309         </Grid>;
310     } else {
311         return <Grid container {...props} className={classNames(classes.gridContainerRoot, props.className)}>
312             {buttonBar}
313             {content}
314         </Grid>;
315     }
316 };
317
318 export const MPVContainer = withStyles(styles)(MPVContainerComponent);