[arvados-workbench2] updated: 2.4.3-8-g879e4519

git repository hosting git at public.arvados.org
Tue Nov 15 15:20:32 UTC 2022


Summary of changes:
 cypress/integration/process.spec.js                |  41 ++++++-
 cypress/integration/project.spec.js                |  26 ++++
 cypress/integration/search.spec.js                 |  33 ++++++
 src/components/data-explorer/data-explorer.tsx     |  16 ++-
 src/components/icon/icon.tsx                       |  10 +-
 .../multi-panel-view/multi-panel-view.test.tsx     |   2 +-
 .../multi-panel-view/multi-panel-view.tsx          |  81 +++++++------
 src/components/search-input/search-input.test.tsx  |  15 ++-
 src/components/search-input/search-input.tsx       | 131 ++++++++++-----------
 src/models/container.ts                            |   1 +
 src/services/api/filter-builder.ts                 |  13 +-
 src/store/processes/processes-actions.ts           |   4 +
 src/store/sharing-dialog/sharing-dialog-actions.ts |   6 +-
 .../action-sets/process-resource-action-set.ts     |  11 +-
 .../process-panel/process-details-attributes.tsx   |  10 ++
 src/views/process-panel/process-log-card.tsx       |  17 ++-
 src/views/process-panel/process-panel-root.tsx     |   2 +-
 .../subprocess-panel/subprocess-panel-root.tsx     |   1 +
 18 files changed, 289 insertions(+), 131 deletions(-)

       via  879e4519b2d4ae67efc87cf769356f46e35bd372 (commit)
       via  1cf8517bb89d48ea16bba1140281877f4090bc5f (commit)
       via  cda1302343f358d0ebc616faa7d8072f03147e72 (commit)
       via  a3b0cad2c654924a6667c03e70a47e9baa1a43d6 (commit)
       via  f4a79b20e7804f3d9be174776cddc02a3f42756b (commit)
       via  990de1d4ca607d7bd7e77b42cd17e3fbf567bd40 (commit)
       via  cd7c0634f08f7d14bec7d020a93c252f4b5759c3 (commit)
       via  96b461be487b32343c730068d24c481c175f8f45 (commit)
      from  e30e54d674c95ee15e296c71e471c1555bdc5a38 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.


commit 879e4519b2d4ae67efc87cf769356f46e35bd372
Author: Lucas Di Pentima <lucas.dipentima at curii.com>
Date:   Fri Nov 4 13:31:26 2022 -0300

    Merge branch '19687-log-scrolling-safari'. Closes #19687
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima at curii.com>

diff --git a/src/components/multi-panel-view/multi-panel-view.tsx b/src/components/multi-panel-view/multi-panel-view.tsx
index f0cbcf56..877061de 100644
--- a/src/components/multi-panel-view/multi-panel-view.tsx
+++ b/src/components/multi-panel-view/multi-panel-view.tsx
@@ -67,6 +67,7 @@ interface MPVPanelDataProps {
     panelRef?: MutableRefObject<any>;
     forwardProps?: boolean;
     maxHeight?: string;
+    minHeight?: string;
 }
 
 interface MPVPanelActionProps {
@@ -82,7 +83,7 @@ type MPVPanelContentProps = {children: ReactElement} & MPVPanelProps & GridProps
 
 // Grid item compatible component for layout and MPV props passing
 export const MPVPanelContent = ({doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName,
-    panelMaximized, panelIlluminated, panelRef, forwardProps, maxHeight,
+    panelMaximized, panelIlluminated, panelRef, forwardProps, maxHeight, minHeight,
     ...props}: MPVPanelContentProps) => {
     useEffect(() => {
         if (panelRef && panelRef.current) {
@@ -90,11 +91,11 @@ export const MPVPanelContent = ({doHidePanel, doMaximizePanel, doUnMaximizePanel
         }
     }, [panelRef]);
 
-    const mh = panelMaximized
+    const maxH = panelMaximized
         ? '100%'
         : maxHeight;
 
-    return <Grid item style={{maxHeight: mh}} {...props}>
+    return <Grid item style={{maxHeight: maxH, minHeight}} {...props}>
         <span ref={panelRef} /> {/* Element to scroll to when the panel is selected */}
         <Paper style={{height: '100%'}} elevation={panelIlluminated ? 8 : 0}>
             { forwardProps
diff --git a/src/views/process-panel/process-log-card.tsx b/src/views/process-panel/process-log-card.tsx
index 03739699..4890c726 100644
--- a/src/views/process-panel/process-log-card.tsx
+++ b/src/views/process-panel/process-log-card.tsx
@@ -55,6 +55,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     logViewer: {
         height: '100%',
+        overflowY: 'scroll', // Required for MacOS's Safari -- See #19687
     },
     logViewerContainer: {
         height: '100%',
diff --git a/src/views/process-panel/process-panel-root.tsx b/src/views/process-panel/process-panel-root.tsx
index f8ff8430..3a4ae2e8 100644
--- a/src/views/process-panel/process-panel-root.tsx
+++ b/src/views/process-panel/process-panel-root.tsx
@@ -66,7 +66,7 @@ export const ProcessPanelRoot = withStyles(styles)(
                     onCopy={props.onCopyToClipboard}
                     process={process} />
             </MPVPanelContent>
-            <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-logs">
+            <MPVPanelContent forwardProps xs minHeight='50%' data-cy="process-logs">
                 <ProcessLogsCard
                     onCopy={props.onCopyToClipboard}
                     process={process}

commit 1cf8517bb89d48ea16bba1140281877f4090bc5f
Author: Stephen Smith <stephen at curii.com>
Date:   Mon Oct 31 09:55:56 2022 -0400

    Merge branch '19315-process-runtime-user-rebase1' into main. Closes #19315
    
    Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen at curii.com>

diff --git a/cypress/integration/process.spec.js b/cypress/integration/process.spec.js
index a543ffde..07f3ff9c 100644
--- a/cypress/integration/process.spec.js
+++ b/cypress/integration/process.spec.js
@@ -106,13 +106,50 @@ describe('Process tests', function() {
                 },
                 event_type: 'stdout'
             }).then(function(log) {
-                cy.get('[data-cy=process-logs]')
+                cy.get('[data-cy=process-logs]', {timeout: 7000})
                     .should('not.contain', 'No logs yet')
                     .and('contain', 'hello world');
             })
         });
     });
 
+    it('shows process details', function() {
+        createContainerRequest(
+            activeUser,
+            `test_container_request ${Math.floor(Math.random() * 999999)}`,
+            'arvados/jobs',
+            ['echo', 'hello world'],
+            false, 'Committed')
+        .then(function(containerRequest) {
+            cy.loginAs(activeUser);
+            cy.goToPath(`/processes/${containerRequest.uuid}`);
+            cy.get('[data-cy=process-details]').should('contain', containerRequest.name);
+            cy.get('[data-cy=process-details-attributes-modifiedby-user]').contains(`Active User (${activeUser.user.uuid})`);
+            cy.get('[data-cy=process-details-attributes-runtime-user]').should('not.exist');
+        });
+
+        // Fake submitted by another user
+        cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => {
+            req.reply((res) => {
+                res.body.modified_by_user_uuid = 'zzzzz-tpzed-000000000000000';
+            });
+        });
+
+        createContainerRequest(
+            activeUser,
+            `test_container_request ${Math.floor(Math.random() * 999999)}`,
+            'arvados/jobs',
+            ['echo', 'hello world'],
+            false, 'Committed')
+        .then(function(containerRequest) {
+            cy.loginAs(activeUser);
+            cy.goToPath(`/processes/${containerRequest.uuid}`);
+            cy.get('[data-cy=process-details]').should('contain', containerRequest.name);
+            cy.get('[data-cy=process-details-attributes-modifiedby-user]').contains(`zzzzz-tpzed-000000000000000`);
+            cy.get('[data-cy=process-details-attributes-runtime-user]').contains(`Active User (${activeUser.user.uuid})`);
+        });
+    });
+
     it('filters process logs by event type', function() {
         const nodeInfoLogs = [
             'Host Information',
@@ -169,7 +206,7 @@ describe('Process tests', function() {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
                 // Should show main logs by default
-                cy.get('[data-cy=process-logs-filter]').should('contain', 'Main logs');
+                cy.get('[data-cy=process-logs-filter]', {timeout: 7000}).should('contain', 'Main logs');
                 cy.get('[data-cy=process-logs]')
                     .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
                     .and('not.contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
diff --git a/src/models/container.ts b/src/models/container.ts
index 127c2508..d6e0e04a 100644
--- a/src/models/container.ts
+++ b/src/models/container.ts
@@ -29,6 +29,7 @@ export interface ContainerResource extends Resource {
     mounts: MountType[];
     runtimeConstraints: RuntimeConstraints;
     runtimeStatus: RuntimeStatus;
+    runtimeUserUuid: string;
     schedulingParameters: SchedulingParameters;
     output: string | null;
     containerImage: string;
diff --git a/src/store/processes/processes-actions.ts b/src/store/processes/processes-actions.ts
index c4d421ac..57b373cf 100644
--- a/src/store/processes/processes-actions.ts
+++ b/src/store/processes/processes-actions.ts
@@ -29,6 +29,10 @@ export const loadProcess = (containerRequestUuid: string) =>
         if (containerRequest.containerUuid) {
             const container = await services.containerService.get(containerRequest.containerUuid);
             dispatch<any>(updateResources([container]));
+            if (container.runtimeUserUuid) {
+                const runtimeUser = await services.userService.get(container.runtimeUserUuid);
+                dispatch<any>(updateResources([runtimeUser]));
+            }
             return { containerRequest, container };
         }
         return { containerRequest };
diff --git a/src/views/process-panel/process-details-attributes.tsx b/src/views/process-panel/process-details-attributes.tsx
index 1e3e5591..4d42d42a 100644
--- a/src/views/process-panel/process-details-attributes.tsx
+++ b/src/views/process-panel/process-details-attributes.tsx
@@ -101,6 +101,16 @@ export const ProcessDetailsAttributes = withStyles(styles, { withTheme: true })(
                         <ContainerRunTime uuid={containerRequest.uuid} />
                     </DetailsAttribute>
                 </Grid>
+                {(containerRequest && containerRequest.modifiedByUserUuid) && <Grid item xs={12} md={mdSize} data-cy="process-details-attributes-modifiedby-user">
+                    <DetailsAttribute
+                        label='Submitted by' linkToUuid={containerRequest.modifiedByUserUuid}
+                        uuidEnhancer={(uuid: string) => <ResourceWithName uuid={uuid} />} />
+                </Grid>}
+                {(container && container.runtimeUserUuid && container.runtimeUserUuid !== containerRequest.modifiedByUserUuid) && <Grid item xs={12} md={mdSize} data-cy="process-details-attributes-runtime-user">
+                    <DetailsAttribute
+                        label='Run as' linkToUuid={container.runtimeUserUuid}
+                        uuidEnhancer={(uuid: string) => <ResourceWithName uuid={uuid} />} />
+                </Grid>}
                 <Grid item xs={12} md={mdSize}>
                     <DetailsAttribute label='Requesting Container UUID' value={containerRequest.requestingContainerUuid || "(none)"} />
                 </Grid>

commit cda1302343f358d0ebc616faa7d8072f03147e72
Author: Lucas Di Pentima <lucas.dipentima at curii.com>
Date:   Thu Oct 27 12:35:57 2022 -0300

    Merge branch '19300-unmaximize-panels'. Closes #19300
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima at curii.com>

diff --git a/src/components/data-explorer/data-explorer.tsx b/src/components/data-explorer/data-explorer.tsx
index 40617f73..02532011 100644
--- a/src/components/data-explorer/data-explorer.tsx
+++ b/src/components/data-explorer/data-explorer.tsx
@@ -11,7 +11,13 @@ import { SearchInput } from 'components/search-input/search-input';
 import { ArvadosTheme } from "common/custom-theme";
 import { createTree } from 'models/tree';
 import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
-import { CloseIcon, IconType, MaximizeIcon, MoreOptionsIcon } from 'components/icon/icon';
+import {
+    CloseIcon,
+    IconType,
+    MaximizeIcon,
+    UnMaximizeIcon,
+    MoreOptionsIcon
+} from 'components/icon/icon';
 import { PaperProps } from '@material-ui/core/Paper';
 import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
 
@@ -152,7 +158,7 @@ export const DataExplorer = withStyles(styles)(
                 items, itemsAvailable, onRowClick, onRowDoubleClick, classes,
                 defaultViewIcon, defaultViewMessages, hideColumnSelector, actions, paperProps, hideSearchInput,
                 paperKey, fetchMode, currentItemUuid, title,
-                doHidePanel, doMaximizePanel, panelName, panelMaximized, elementPath
+                doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName, panelMaximized, elementPath
             } = this.props;
 
             return <Paper className={classes.root} {...paperProps} key={paperKey} data-cy={this.props["data-cy"]}>
@@ -176,13 +182,17 @@ export const DataExplorer = withStyles(styles)(
                                             columns={columns}
                                             onColumnToggle={onColumnToggle} />}
                                     </Grid>
+                                    { doUnMaximizePanel && panelMaximized &&
+                                    <Tooltip title={`Unmaximize ${panelName || 'panel'}`} disableFocusListener>
+                                        <IconButton onClick={doUnMaximizePanel}><UnMaximizeIcon /></IconButton>
+                                    </Tooltip> }
                                     { doMaximizePanel && !panelMaximized &&
                                         <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
                                             <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
                                         </Tooltip> }
                                     { doHidePanel &&
                                         <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
-                                            <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
+                                            <IconButton disabled={panelMaximized} onClick={doHidePanel}><CloseIcon /></IconButton>
                                         </Tooltip> }
                                 </Toolbar>
                             </Grid>
diff --git a/src/components/icon/icon.tsx b/src/components/icon/icon.tsx
index a64ed0a8..b3cb8e8b 100644
--- a/src/components/icon/icon.tsx
+++ b/src/components/icon/icon.tsx
@@ -66,7 +66,8 @@ import Computer from '@material-ui/icons/Computer';
 import WrapText from '@material-ui/icons/WrapText';
 import TextIncrease from '@material-ui/icons/ZoomIn';
 import TextDecrease from '@material-ui/icons/ZoomOut';
-import CropFreeSharp from '@material-ui/icons/CropFreeSharp';
+import FullscreenSharp from '@material-ui/icons/FullscreenSharp';
+import FullscreenExitSharp from '@material-ui/icons/FullscreenExitSharp';
 import ExitToApp from '@material-ui/icons/ExitToApp';
 import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
 import RemoveCircleOutline from '@material-ui/icons/RemoveCircleOutline';
@@ -140,7 +141,8 @@ export const InputIcon: IconType = (props) => <InsertDriveFile {...props} />;
 export const KeyIcon: IconType = (props) => <VpnKey {...props} />;
 export const LogIcon: IconType = (props) => <SettingsEthernet {...props} />;
 export const MailIcon: IconType = (props) => <Mail {...props} />;
-export const MaximizeIcon: IconType = (props) => <CropFreeSharp {...props} />;
+export const MaximizeIcon: IconType = (props) => <FullscreenSharp {...props} />;
+export const UnMaximizeIcon: IconType = (props) => <FullscreenExitSharp {...props} />;
 export const MoreOptionsIcon: IconType = (props) => <MoreVert {...props} />;
 export const MoveToIcon: IconType = (props) => <Input {...props} />;
 export const NewProjectIcon: IconType = (props) => <CreateNewFolder {...props} />;
diff --git a/src/components/multi-panel-view/multi-panel-view.test.tsx b/src/components/multi-panel-view/multi-panel-view.test.tsx
index d690e82f..3f4911c2 100644
--- a/src/components/multi-panel-view/multi-panel-view.test.tsx
+++ b/src/components/multi-panel-view/multi-panel-view.test.tsx
@@ -10,7 +10,7 @@ import { Button } from "@material-ui/core";
 
 configure({ adapter: new Adapter() });
 
-const PanelMock = ({panelName, panelMaximized, doHidePanel, doMaximizePanel, panelIlluminated, panelRef, children, ...rest}) =>
+const PanelMock = ({panelName, panelMaximized, doHidePanel, doMaximizePanel, doUnMaximizePanel, panelIlluminated, panelRef, children, ...rest}) =>
     <div {...rest}>{children}</div>;
 
 describe('<MPVContainer />', () => {
diff --git a/src/components/multi-panel-view/multi-panel-view.tsx b/src/components/multi-panel-view/multi-panel-view.tsx
index f4c3f3ba..f0cbcf56 100644
--- a/src/components/multi-panel-view/multi-panel-view.tsx
+++ b/src/components/multi-panel-view/multi-panel-view.tsx
@@ -48,14 +48,15 @@ interface MPVHideablePanelDataProps {
 interface MPVHideablePanelActionProps {
     doHidePanel: () => void;
     doMaximizePanel: () => void;
+    doUnMaximizePanel: () => void;
 }
 
 type MPVHideablePanelProps = MPVHideablePanelDataProps & MPVHideablePanelActionProps;
 
-const MPVHideablePanel = ({doHidePanel, doMaximizePanel, name, visible, maximized, illuminated, ...props}: MPVHideablePanelProps) =>
+const MPVHideablePanel = ({doHidePanel, doMaximizePanel, doUnMaximizePanel, name, visible, maximized, illuminated, ...props}: MPVHideablePanelProps) =>
     visible
     ? <>
-        {React.cloneElement((props.children as ReactElement), { doHidePanel, doMaximizePanel, panelName: name, panelMaximized: maximized, panelIlluminated: illuminated, panelRef: props.panelRef })}
+        {React.cloneElement((props.children as ReactElement), { doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName: name, panelMaximized: maximized, panelIlluminated: illuminated, panelRef: props.panelRef })}
     </>
     : null;
 
@@ -71,6 +72,7 @@ interface MPVPanelDataProps {
 interface MPVPanelActionProps {
     doHidePanel?: () => void;
     doMaximizePanel?: () => void;
+    doUnMaximizePanel?: () => void;
 }
 
 // Props received by panel implementors
@@ -79,12 +81,12 @@ export type MPVPanelProps = MPVPanelDataProps & MPVPanelActionProps;
 type MPVPanelContentProps = {children: ReactElement} & MPVPanelProps & GridProps;
 
 // Grid item compatible component for layout and MPV props passing
-export const MPVPanelContent = ({doHidePanel, doMaximizePanel, panelName,
+export const MPVPanelContent = ({doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName,
     panelMaximized, panelIlluminated, panelRef, forwardProps, maxHeight,
     ...props}: MPVPanelContentProps) => {
     useEffect(() => {
         if (panelRef && panelRef.current) {
-            panelRef.current.scrollIntoView({behavior: 'smooth'});
+            panelRef.current.scrollIntoView({alignToTop: true});
         }
     }, [panelRef]);
 
@@ -96,7 +98,7 @@ export const MPVPanelContent = ({doHidePanel, doMaximizePanel, panelName,
         <span ref={panelRef} /> {/* Element to scroll to when the panel is selected */}
         <Paper style={{height: '100%'}} elevation={panelIlluminated ? 8 : 0}>
             { forwardProps
-                ? React.cloneElement(props.children, { doHidePanel, doMaximizePanel, panelName, panelMaximized })
+                ? React.cloneElement(props.children, { doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName, panelMaximized })
                 : props.children }
         </Paper>
     </Grid>;
@@ -118,11 +120,12 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
     } else if (!isArray(children)) {
         children = [children];
     }
-    const visibility = (children as ReactNodeArray).map((_, idx) =>
+    const initialVisibility = (children as ReactNodeArray).map((_, idx) =>
         !panelStates || // if panelStates wasn't passed, default to all visible panels
             (panelStates[idx] &&
                 (panelStates[idx].visible || panelStates[idx].visible === undefined)));
-    const [panelVisibility, setPanelVisibility] = useState<boolean[]>(visibility);
+    const [panelVisibility, setPanelVisibility] = useState<boolean[]>(initialVisibility);
+    const [previousPanelVisibility, setPreviousPanelVisibility] = useState<boolean[]>(initialVisibility);
     const [highlightedPanel, setHighlightedPanel] = useState<number>(-1);
     const [selectedPanel, setSelectedPanel] = useState<number>(-1);
     const panelRef = useRef<any>(null);
@@ -133,6 +136,7 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
     if (isArray(children)) {
         for (let idx = 0; idx < children.length; idx++) {
             const showFn = (idx: number) => () => {
+                setPreviousPanelVisibility(initialVisibility);
                 setPanelVisibility([
                     ...panelVisibility.slice(0, idx),
                     true,
@@ -141,6 +145,7 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
                 setSelectedPanel(idx);
             };
             const hideFn = (idx: number) => () => {
+                setPreviousPanelVisibility(initialVisibility);
                 setPanelVisibility([
                     ...panelVisibility.slice(0, idx),
                     false,
@@ -148,13 +153,18 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
                 ])
             };
             const maximizeFn = (idx: number) => () => {
+                setPreviousPanelVisibility(panelVisibility);
                 // Maximize X == hide all but X
                 setPanelVisibility([
                     ...panelVisibility.slice(0, idx).map(() => false),
                     true,
                     ...panelVisibility.slice(idx+1).map(() => false),
-                ])
+                ]);
             };
+            const unMaximizeFn = (idx: number) => () => {
+                setPanelVisibility(previousPanelVisibility);
+                setSelectedPanel(idx);
+            }
             const panelName = panelStates === undefined
                 ? `Panel ${idx+1}`
                 : (panelStates[idx] && panelStates[idx].name) || `Panel ${idx+1}`;
@@ -188,7 +198,7 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
                 <MPVHideablePanel key={idx} visible={panelVisibility[idx]} name={panelName}
                     panelRef={(idx === selectedPanel) ? panelRef : undefined}
                     maximized={panelIsMaximized} illuminated={idx === highlightedPanel}
-                    doHidePanel={hideFn(idx)} doMaximizePanel={maximizeFn(idx)}>
+                    doHidePanel={hideFn(idx)} doMaximizePanel={maximizeFn(idx)} doUnMaximizePanel={panelIsMaximized ? unMaximizeFn(idx) : () => null}>
                     {children[idx]}
                 </MPVHideablePanel>;
             panels = [...panels, aPanel];
diff --git a/src/views/process-panel/process-log-card.tsx b/src/views/process-panel/process-log-card.tsx
index 936b31a5..03739699 100644
--- a/src/views/process-panel/process-log-card.tsx
+++ b/src/views/process-panel/process-log-card.tsx
@@ -22,6 +22,7 @@ import {
     CopyIcon,
     LogIcon,
     MaximizeIcon,
+    UnMaximizeIcon,
     TextDecreaseIcon,
     TextIncreaseIcon,
     WordWrapOffIcon,
@@ -92,7 +93,7 @@ type ProcessLogsCardProps = ProcessLogsCardDataProps
 export const ProcessLogsCard = withStyles(styles)(
     ({ classes, process, filters, selectedFilter, lines,
         onLogFilterChange, navigateToLog, onCopy,
-        doHidePanel, doMaximizePanel, panelMaximized, panelName }: ProcessLogsCardProps) => {
+        doHidePanel, doMaximizePanel, doUnMaximizePanel, panelMaximized, panelName }: ProcessLogsCardProps) => {
         const [wordWrap, setWordWrap] = useState<boolean>(true);
         const [fontSize, setFontSize] = useState<number>(3);
         const fontBaseSize = 10;
@@ -144,15 +145,18 @@ export const ProcessLogsCard = withStyles(styles)(
                                 </IconButton>
                             </Tooltip>
                         </Grid>
+                        { doUnMaximizePanel && panelMaximized &&
+                        <Tooltip title={`Unmaximize ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton onClick={doUnMaximizePanel}><UnMaximizeIcon /></IconButton>
+                        </Tooltip> }
                         { doMaximizePanel && !panelMaximized &&
                         <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
                             <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
                         </Tooltip> }
-                        { doHidePanel && <Grid item>
-                            <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
-                                <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
-                            </Tooltip>
-                        </Grid> }
+                        { doHidePanel &&
+                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton disabled={panelMaximized} onClick={doHidePanel}><CloseIcon /></IconButton>
+                        </Tooltip> }
                     </Grid>}
                     title={
                         <Typography noWrap variant='h6' className={classes.title}>
diff --git a/src/views/subprocess-panel/subprocess-panel-root.tsx b/src/views/subprocess-panel/subprocess-panel-root.tsx
index d4ccae9c..52fbc51f 100644
--- a/src/views/subprocess-panel/subprocess-panel-root.tsx
+++ b/src/views/subprocess-panel/subprocess-panel-root.tsx
@@ -91,6 +91,7 @@ export const SubprocessPanelRoot = (props: SubprocessPanelProps & MPVPanelProps)
         defaultViewMessages={DEFAULT_VIEW_MESSAGES}
         doHidePanel={props.doHidePanel}
         doMaximizePanel={props.doMaximizePanel}
+        doUnMaximizePanel={props.doUnMaximizePanel}
         panelMaximized={props.panelMaximized}
         panelName={props.panelName} />;
 };

commit a3b0cad2c654924a6667c03e70a47e9baa1a43d6
Author: Stephen Smith <stephen at curii.com>
Date:   Wed Oct 26 14:51:47 2022 -0400

    Merge branch '19569-process-open-newtab' into main. Closes #19569
    
    Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen at curii.com>

diff --git a/src/views-components/context-menu/action-sets/process-resource-action-set.ts b/src/views-components/context-menu/action-sets/process-resource-action-set.ts
index 56cfee85..78b2f340 100644
--- a/src/views-components/context-menu/action-sets/process-resource-action-set.ts
+++ b/src/views-components/context-menu/action-sets/process-resource-action-set.ts
@@ -8,7 +8,8 @@ import { toggleFavorite } from "store/favorites/favorites-actions";
 import {
     RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon,
     RemoveIcon, ReRunProcessIcon, OutputIcon,
-    AdvancedIcon
+    AdvancedIcon,
+    OpenIcon
 } from "components/icon/icon";
 import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
 import { openMoveProcessDialog } from 'store/processes/process-move-actions';
@@ -23,6 +24,7 @@ import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
 import { TogglePublicFavoriteAction } from "../actions/public-favorite-action";
 import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
 import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
+import { openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
 
 export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[
     {
@@ -33,6 +35,13 @@ export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[
             });
         }
     },
+    {
+        icon: OpenIcon,
+        name: "Open in new tab",
+        execute: (dispatch, resource) => {
+            dispatch<any>(openInNewTabAction(resource));
+        }
+    },
     {
         icon: CopyIcon,
         name: "Copy to project",

commit f4a79b20e7804f3d9be174776cddc02a3f42756b
Author: Daniel Kutyła <daniel.kutyla at contractors.roche.com>
Date:   Wed Oct 19 21:40:28 2022 +0200

    Merge branch '19311-Project-Search-field-auto-clears' into main
    closes #19311
    
    Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla at contractors.roche.com>

diff --git a/src/components/search-input/search-input.test.tsx b/src/components/search-input/search-input.test.tsx
index c57d3608..ba70f752 100644
--- a/src/components/search-input/search-input.test.tsx
+++ b/src/components/search-input/search-input.test.tsx
@@ -98,11 +98,22 @@ describe("<SearchInput />", () => {
     describe("on input target change", () => {
         it("clears the input value on selfClearProp change", () => {
             const searchInput = mount(<SearchInput selfClearProp="abc" value="123" onSearch={onSearch} debounce={1000}/>);
-            searchInput.setProps({ selfClearProp: 'aaa' });
+
+            // component should clear value upon creation
             jest.runTimersToTime(1000);
             expect(onSearch).toBeCalledWith("");
             expect(onSearch).toHaveBeenCalledTimes(1);
+
+            // component should not clear on same selfClearProp
+            searchInput.setProps({ selfClearProp: 'abc' });
+            jest.runTimersToTime(1000);
+            expect(onSearch).toHaveBeenCalledTimes(1);
+
+            // component should clear on selfClearProp change
+            searchInput.setProps({ selfClearProp: '111' });
+            jest.runTimersToTime(1000);
+            expect(onSearch).toBeCalledWith("");
+            expect(onSearch).toHaveBeenCalledTimes(2);
         });
     });
-
 });
diff --git a/src/components/search-input/search-input.tsx b/src/components/search-input/search-input.tsx
index 50338f40..8d86307a 100644
--- a/src/components/search-input/search-input.tsx
+++ b/src/components/search-input/search-input.tsx
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
+import React, {useState, useEffect} from 'react';
 import { IconButton, StyleRulesCallback, withStyles, WithStyles, FormControl, InputLabel, Input, InputAdornment, Tooltip } from '@material-ui/core';
 import SearchIcon from '@material-ui/icons/Search';
 
@@ -45,82 +45,75 @@ interface SearchInputActionProps {
 
 type SearchInputProps = SearchInputDataProps & SearchInputActionProps & WithStyles<CssRules>;
 
-interface SearchInputState {
-    value: string;
-    label: string;
-    selfClearProp: string;
-}
-
 export const DEFAULT_SEARCH_DEBOUNCE = 1000;
 
-export const SearchInput = withStyles(styles)(
-    class extends React.Component<SearchInputProps> {
-        state: SearchInputState = {
-            value: "",
-            label: "",
-            selfClearProp: ""
-        };
+const SearchInputComponent = (props: SearchInputProps) => {
+    const [timeout, setTimeout] = useState(0);
+    const [value, setValue] = useState("");
+    const [label, setLabel] = useState("Search");
+    const [selfClearProp, setSelfClearProp] = useState("");
 
-        timeout: number;
-
-        render() {
-            return <form onSubmit={this.handleSubmit}>
-                <FormControl>
-                    <InputLabel>{this.state.label}</InputLabel>
-                    <Input
-                        type="text"
-                        data-cy="search-input"
-                        value={this.state.value}
-                        onChange={this.handleChange}
-                        endAdornment={
-                            <InputAdornment position="end">
-                                <Tooltip title='Search'>
-                                    <IconButton
-                                        onClick={this.handleSubmit}>
-                                        <SearchIcon />
-                                    </IconButton>
-                                </Tooltip>
-                            </InputAdornment>
-                        } />
-                </FormControl>
-            </form>;
+    useEffect(() => {
+        if (props.value) {
+            setValue(props.value);
         }
-
-        componentDidMount() {
-            this.setState({
-                value: this.props.value,
-                label: this.props.label || 'Search'
-            });
+        if (props.label) {
+            setLabel(props.label);
         }
 
-        componentWillReceiveProps(nextProps: SearchInputProps) {
-            if (nextProps.value !== this.props.value) {
-                this.setState({ value: nextProps.value });
-            }
-            if (this.state.value !== '' && nextProps.selfClearProp && nextProps.selfClearProp !== this.state.selfClearProp) {
-                this.props.onSearch('');
-                this.setState({ selfClearProp: nextProps.selfClearProp });
-            }
-        }
+        return () => {
+            setValue("");
+            clearTimeout(timeout);
+        };
+    }, [props.value, props.label]); // eslint-disable-line react-hooks/exhaustive-deps
 
-        componentWillUnmount() {
-            clearTimeout(this.timeout);
+    useEffect(() => {
+        if (selfClearProp !== props.selfClearProp) {
+            setValue("");
+            setSelfClearProp(props.selfClearProp);
+            handleChange({ target: { value: "" } } as any);
         }
+    }, [props.selfClearProp]); // eslint-disable-line react-hooks/exhaustive-deps
 
-        handleSubmit = (event: React.FormEvent<HTMLElement>) => {
-            event.preventDefault();
-            clearTimeout(this.timeout);
-            this.props.onSearch(this.state.value);
-        }
+    const handleSubmit = (event: React.FormEvent<HTMLElement>) => {
+        event.preventDefault();
+        clearTimeout(timeout);
+        props.onSearch(value);
+    };
 
-        handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
-            clearTimeout(this.timeout);
-            this.setState({ value: event.target.value });
-            this.timeout = window.setTimeout(
-                () => this.props.onSearch(this.state.value),
-                this.props.debounce || DEFAULT_SEARCH_DEBOUNCE
-            );
+    const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+        const { target: { value: eventValue } } = event;
+        clearTimeout(timeout);
+        setValue(eventValue);
+
+        setTimeout(window.setTimeout(
+            () => {
+                props.onSearch(eventValue);
+            },
+             props.debounce || DEFAULT_SEARCH_DEBOUNCE
+        ));
+    };
 
-        }
-    }
-);
+    return <form onSubmit={handleSubmit}>
+        <FormControl>
+            <InputLabel>{label}</InputLabel>
+            <Input
+                type="text"
+                data-cy="search-input"
+                value={value}
+                onChange={handleChange}
+                endAdornment={
+                    <InputAdornment position="end">
+                        <Tooltip title='Search'>
+                            <IconButton
+                                onClick={handleSubmit}>
+                                <SearchIcon />
+                            </IconButton>
+                        </Tooltip>
+                    </InputAdornment>
+                } />
+        </FormControl>
+    </form>;
+}
+
+export const SearchInput = withStyles(styles)(SearchInputComponent);
\ No newline at end of file
diff --git a/src/store/sharing-dialog/sharing-dialog-actions.ts b/src/store/sharing-dialog/sharing-dialog-actions.ts
index cdc6c0c7..c0fdeda5 100644
--- a/src/store/sharing-dialog/sharing-dialog-actions.ts
+++ b/src/store/sharing-dialog/sharing-dialog-actions.ts
@@ -152,11 +152,11 @@ export const initializeManagementForm = async (dispatch: Dispatch, getState: ()
         const { items: permissionLinks } = await permissionService.listResourcePermissions(resourceUuid);
         dispatch<any>(initializePublicAccessForm(permissionLinks));
         const filters = new FilterBuilder()
-            .addIn('uuid', permissionLinks.map(({ tailUuid }) => tailUuid))
+            .addIn('uuid', Array.from(new Set(permissionLinks.map(({ tailUuid }) => tailUuid))))
             .getFilters();
 
-        const { items: users } = await userService.list({ filters, count: "none" });
-        const { items: groups } = await groupsService.list({ filters, count: "none" });
+        const { items: users } = await userService.list({ filters, count: "none", limit: 1000 });
+        const { items: groups } = await groupsService.list({ filters, count: "none", limit: 1000 });
 
         const getEmail = (tailUuid: string) => {
             const user = users.find(({ uuid }) => uuid === tailUuid);

commit 990de1d4ca607d7bd7e77b42cd17e3fbf567bd40
Author: Stephen Smith <stephen at curii.com>
Date:   Thu Sep 22 11:45:24 2022 -0400

    Merge branch '19567-copy-to-clipboard' into main. Closes #19567
    
    Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen at curii.com>

diff --git a/cypress/integration/project.spec.js b/cypress/integration/project.spec.js
index 9c5e791c..ea795e6c 100644
--- a/cypress/integration/project.spec.js
+++ b/cypress/integration/project.spec.js
@@ -311,4 +311,30 @@ describe('Project tests', function() {
                 });
         });
     });
+
+    it('copies project URL to clipboard', () => {
+        const projectName = `Test project (${Math.floor(999999 * Math.random())})`;
+
+        cy.loginAs(activeUser);
+        cy.get('[data-cy=side-panel-button]').click();
+        cy.get('[data-cy=side-panel-new-project]').click();
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'New Project')
+            .within(() => {
+                cy.get('[data-cy=name-field]').within(() => {
+                    cy.get('input').type(projectName);
+                });
+                cy.get('[data-cy=form-submit-btn]').click();
+            });
+
+        cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
+        cy.get('[data-cy=project-panel]').contains(projectName).rightclick();
+        cy.get('[data-cy=context-menu]').contains('Copy to clipboard').click();
+        cy.window().then((win) => (
+            win.navigator.clipboard.readText().then((text) => {
+                expect(text).to.match(/https\:\/\/localhost\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/,);
+            })
+        ));
+
+    });
 });

commit cd7c0634f08f7d14bec7d020a93c252f4b5759c3
Author: Daniel Kutyła <daniel.kutyla at contractors.roche.com>
Date:   Thu Sep 22 00:39:57 2022 +0200

    Merge branch '19051-handle-quotes-in-search' into main
    closes #19051
    
    Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla at contractors.roche.com>

diff --git a/cypress/integration/search.spec.js b/cypress/integration/search.spec.js
index da33c7df..c8e262f0 100644
--- a/cypress/integration/search.spec.js
+++ b/cypress/integration/search.spec.js
@@ -105,6 +105,39 @@ describe('Search tests', function() {
         });
     });
 
+    it('can search items using quotes', function() {
+        const random = Math.floor(Math.random() * Math.floor(999999));
+        const colName = `Collection ${random}`;
+        const colName2 = `Collection test ${random}`;
+
+        // Creates the collection using the admin token so we can set up
+        // a bogus manifest text without block signatures.
+        cy.createCollection(adminUser.token, {
+            name: colName,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        }).as('collection1');
+
+        cy.createCollection(adminUser.token, {
+            name: colName2,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        }).as('collection2');
+
+        cy.getAll('@collection1', '@collection2')
+            .then(function() {
+                cy.loginAs(activeUser);
+
+                cy.doSearch(colName);
+                cy.get('[data-cy=search-results] table tbody tr').should('have.length', 2);
+
+                cy.doSearch(`"${colName}"`);
+                cy.get('[data-cy=search-results] table tbody tr').should('have.length', 1);
+            });
+    });
+
     it('can display owner of the item', function() {
         const colName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
 
diff --git a/src/services/api/filter-builder.ts b/src/services/api/filter-builder.ts
index 4809e7a8..da67935a 100644
--- a/src/services/api/filter-builder.ts
+++ b/src/services/api/filter-builder.ts
@@ -65,7 +65,18 @@ export class FilterBuilder {
     }
 
     public addFullTextSearch(value: string) {
-        const terms = value.trim().split(/(\s+)/);
+        const regex = /"[^"]*"/;
+        const matches: any[] = [];
+
+        let match = value.match(regex);
+
+        while (match) {
+            value = value.replace(match[0], "");
+            matches.push(match[0].replace(/"/g, ''));
+            match = value.match(regex);
+        }
+
+        const terms = value.trim().split(/(\s+)/).concat(matches);
         terms.forEach(term => {
             if (term !== " ") {
                 this.addCondition("any", "ilike", term, "%", "%");

commit 96b461be487b32343c730068d24c481c175f8f45
Author: Lucas Di Pentima <lucas.dipentima at curii.com>
Date:   Thu Sep 15 17:39:34 2022 -0300

    Merge branch '19465-minimal-fix'. Closes #19465
    
    Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima at curii.com>

diff --git a/src/components/icon/icon.tsx b/src/components/icon/icon.tsx
index db603597..a64ed0a8 100644
--- a/src/components/icon/icon.tsx
+++ b/src/components/icon/icon.tsx
@@ -59,8 +59,6 @@ import SettingsEthernet from '@material-ui/icons/SettingsEthernet';
 import Star from '@material-ui/icons/Star';
 import StarBorder from '@material-ui/icons/StarBorder';
 import Warning from '@material-ui/icons/Warning';
-import Visibility from '@material-ui/icons/Visibility';
-import VisibilityOff from '@material-ui/icons/VisibilityOff';
 import VpnKey from '@material-ui/icons/VpnKey';
 import LinkOutlined from '@material-ui/icons/LinkOutlined';
 import RemoveRedEye from '@material-ui/icons/RemoveRedEye';
@@ -171,8 +169,6 @@ export const SidePanelRightArrowIcon: IconType = (props) => <PlayArrow {...props
 export const TrashIcon: IconType = (props) => <Delete {...props} />;
 export const UserPanelIcon: IconType = (props) => <Person {...props} />;
 export const UsedByIcon: IconType = (props) => <Folder {...props} />;
-export const VisibleIcon: IconType = (props) => <Visibility {...props} />;
-export const InvisibleIcon: IconType = (props) => <VisibilityOff {...props} />;
 export const WorkflowIcon: IconType = (props) => <Code {...props} />;
 export const WarningIcon: IconType = (props) => <Warning style={{ color: '#fbc02d', height: '30px', width: '30px' }} {...props} />;
 export const Link: IconType = (props) => <LinkOutlined {...props} />;
diff --git a/src/components/multi-panel-view/multi-panel-view.tsx b/src/components/multi-panel-view/multi-panel-view.tsx
index de824990..f4c3f3ba 100644
--- a/src/components/multi-panel-view/multi-panel-view.tsx
+++ b/src/components/multi-panel-view/multi-panel-view.tsx
@@ -15,7 +15,7 @@ import {
 import { GridProps } from '@material-ui/core/Grid';
 import { isArray } from 'lodash';
 import { DefaultView } from 'components/default-view/default-view';
-import { InfoIcon, InvisibleIcon, VisibleIcon } from 'components/icon/icon';
+import { InfoIcon } from 'components/icon/icon';
 import { ReactNodeArray } from 'prop-types';
 import classNames from 'classnames';
 
@@ -123,11 +123,12 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
             (panelStates[idx] &&
                 (panelStates[idx].visible || panelStates[idx].visible === undefined)));
     const [panelVisibility, setPanelVisibility] = useState<boolean[]>(visibility);
-    const [brightenedPanel, setBrightenedPanel] = useState<number>(-1);
+    const [highlightedPanel, setHighlightedPanel] = useState<number>(-1);
+    const [selectedPanel, setSelectedPanel] = useState<number>(-1);
     const panelRef = useRef<any>(null);
 
     let panels: JSX.Element[] = [];
-    let toggles: JSX.Element[] = [];
+    let buttons: JSX.Element[] = [];
 
     if (isArray(children)) {
         for (let idx = 0; idx < children.length; idx++) {
@@ -137,6 +138,7 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
                     true,
                     ...panelVisibility.slice(idx+1)
                 ]);
+                setSelectedPanel(idx);
             };
             const hideFn = (idx: number) => () => {
                 setPanelVisibility([
@@ -153,44 +155,39 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
                     ...panelVisibility.slice(idx+1).map(() => false),
                 ])
             };
-            const toggleIcon = panelVisibility[idx]
-                ? <VisibleIcon className={classNames(classes.buttonIcon)} />
-                : <InvisibleIcon className={classNames(classes.buttonIcon)}/>
             const panelName = panelStates === undefined
                 ? `Panel ${idx+1}`
                 : (panelStates[idx] && panelStates[idx].name) || `Panel ${idx+1}`;
-            const toggleVariant = "outlined";
-            const toggleTooltip = panelVisibility[idx]
-                ? ''
-                :`Show ${panelName} panel`;
+            const btnVariant = panelVisibility[idx]
+                ? "contained"
+                : "outlined";
+            const btnTooltip = panelVisibility[idx]
+                ? ``
+                :`Open ${panelName} panel`;
             const panelIsMaximized = panelVisibility[idx] &&
                 panelVisibility.filter(e => e).length === 1;
 
-            let brightenerTimer: NodeJS.Timer;
-            toggles = [
-                ...toggles,
-                <Tooltip title={toggleTooltip} disableFocusListener>
-                    <Button variant={toggleVariant} size="small" color="primary"
+            buttons = [
+                ...buttons,
+                <Tooltip title={btnTooltip} disableFocusListener>
+                    <Button variant={btnVariant} size="small" color="primary"
                         className={classNames(classes.button)}
                         onMouseEnter={() => {
-                            brightenerTimer = setTimeout(
-                                () => setBrightenedPanel(idx), 100);
+                            setHighlightedPanel(idx);
                         }}
                         onMouseLeave={() => {
-                            brightenerTimer && clearTimeout(brightenerTimer);
-                            setBrightenedPanel(-1);
+                            setHighlightedPanel(-1);
                         }}
                         onClick={showFn(idx)}>
                             {panelName}
-                            {toggleIcon}
                     </Button>
                 </Tooltip>
             ];
 
             const aPanel =
                 <MPVHideablePanel key={idx} visible={panelVisibility[idx]} name={panelName}
-                    panelRef={(idx === brightenedPanel) ? panelRef : undefined}
-                    maximized={panelIsMaximized} illuminated={idx === brightenedPanel}
+                    panelRef={(idx === selectedPanel) ? panelRef : undefined}
+                    maximized={panelIsMaximized} illuminated={idx === highlightedPanel}
                     doHidePanel={hideFn(idx)} doMaximizePanel={maximizeFn(idx)}>
                     {children[idx]}
                 </MPVHideablePanel>;
@@ -200,9 +197,10 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
 
     return <Grid container {...props}>
         <Grid container item direction="row">
-            { toggles.map((tgl, idx) => <Grid item key={idx}>{tgl}</Grid>) }
+            { buttons.map((tgl, idx) => <Grid item key={idx}>{tgl}</Grid>) }
         </Grid>
-        <Grid container item {...props} xs className={classes.content}>
+        <Grid container item {...props} xs className={classes.content}
+            onScroll={() => setSelectedPanel(-1)}>
             { panelVisibility.includes(true)
                 ? panels
                 : <Grid container item alignItems='center' justify='center'>

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list