Skip to content

Commit

Permalink
feat: make node info side panel resizable. Fixes argoproj#8917 (argop…
Browse files Browse the repository at this point in the history
…roj#8963)

Signed-off-by: Dakota Lillie <[email protected]>
Signed-off-by: Reddy <[email protected]>
  • Loading branch information
dakotalillie authored and Reddy committed Jan 2, 2023
1 parent 391ec0e commit 490285b
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 44 deletions.
4 changes: 2 additions & 2 deletions ui/src/app/shared/components/phase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import {PhaseIcon} from './phase-icon';

export const Phase = ({value}: {value: string}) => {
return (
<>
<span>
<PhaseIcon value={value} /> {value}
</>
</span>
);
};
135 changes: 135 additions & 0 deletions ui/src/app/shared/use-resizable-width.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {MutableRefObject, useCallback, useEffect, useRef, useState} from 'react';

export interface UseResizableWidthArgs {
/**
* Whether or not the resizing behavior should be disabled
*/
disabled?: boolean;
/**
* The initial width of the element being resized
*/
initialWidth?: number;
/**
* The maximum width of the element being resized
*/
maxWidth?: number;
/**
* The minimum width of the element being resized
*/
minWidth?: number;
/**
* A ref object which points to the element being resized
*/
resizedElementRef?: MutableRefObject<HTMLElement>;
}

/**
* useResizableWidth is a React hook containing the requisite logic for allowing a user to resize an element's width by
* clicking and dragging another element called the "drag handle". At the time of writing, it is primarily used for
* resizable side panels.
*/
export function useResizableWidth({disabled, initialWidth, maxWidth, minWidth, resizedElementRef}: UseResizableWidthArgs) {
const [width, setWidth] = useState(initialWidth ?? 0);

// The following properties are all maintained as refs (instead of state) because we don't want updating them to
// trigger a re-render. Usually, only updating the width should trigger a re-render.

// widthBeforeResize and clientXBeforeResize are needed to calculate the updated width value. they will be set when
// resizing begins and unset once it ends.
const widthBeforeResize = useRef(null);
const clientXBeforeResize = useRef(null);

// dragOverListener holds the listener for the dragover event which is attached to the document. We need this
// listener because Firefox has a bug where the event.clientX will always be 0 for drag (not dragover) events.
const dragOverListener = useRef(null);

// clientX holds the clientX value from the document.dragover event, which is used in the drag event handler for the
// "drag handle" element.
const clientX = useRef(null);

const handleDragStart = useCallback(
(event: React.DragEvent<HTMLElement>) => {
clientXBeforeResize.current = event.clientX;
widthBeforeResize.current = width;

function listener(ev: DragEvent) {
clientX.current = ev.clientX;
}

document.addEventListener('dragover', listener);
dragOverListener.current = listener;
},
[width]
);

const handleDrag = useCallback(() => {
if (disabled || clientX.current === null || clientX.current <= 0 || widthBeforeResize.current === null || clientXBeforeResize.current === null) {
return;
}

const newWidth = widthBeforeResize.current + clientXBeforeResize.current - clientX.current;

if (typeof minWidth === 'number' && newWidth < minWidth) {
setWidth(minWidth);
} else if (typeof maxWidth === 'number' && newWidth > maxWidth) {
setWidth(maxWidth);
} else {
setWidth(newWidth);
}
}, [disabled, minWidth, maxWidth]);

const handleDragEnd = useCallback(() => {
clientXBeforeResize.current = null;
widthBeforeResize.current = null;
document.removeEventListener('dragover', dragOverListener.current);
}, []);

/**
* Since the width value is supposed to be the source of truth for the width of the resizable element, we need to
* make sure to update the width value whenever the width of the resizable element changes for any reason other
* than the user explicitly resizing it. For instance, if the user shrinks the window and this causes a the
* resizable element to shrink accordingly, then the width value needs to be updated. If it isn't, then we get
* weird behavior where the user will start dragging the drag handle, but the resizable element won't resize because
* the width value maintained by this hook is no longer reflective of reality.
*/
useEffect(() => {
if (!resizedElementRef.current || !('ResizeObserver' in window)) {
return;
}

const observer = new ResizeObserver(([element]) => {
if (disabled) {
return;
}

const observedSize = element.borderBoxSize[0].inlineSize;

if (observedSize === width) {
return;
}

if (observedSize > maxWidth) {
setWidth(maxWidth);
} else if (observedSize < minWidth) {
setWidth(minWidth);
} else {
setWidth(observedSize);
}
});

observer.observe(resizedElementRef.current);

return () => observer.disconnect();
}, [disabled, width, minWidth, maxWidth]);

return {
width,
dragHandleProps: {
draggable: true,
hidden: disabled,
onDragStart: handleDragStart,
onDrag: handleDrag,
onDragEnd: handleDragEnd
}
};
}
22 changes: 22 additions & 0 deletions ui/src/app/shared/use-transition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {useEffect, useRef, useState} from 'react';

/**
* useTransition is a React hook which returns a boolean indicaating whether a transition which takes a fixed amount of
* time is currently underway. At the time of writing, it is primarily used for syncing JavaScript logic with CSS
* transitions.
*/
export function useTransition(input: unknown, transitionTime: number) {
const [transitioning, setTransitioning] = useState(false);
const prevInput = useRef(input);

useEffect(() => {
if (input !== prevInput.current) {
setTransitioning(true);
const timeout = setTimeout(() => setTransitioning(false), transitionTime);
prevInput.current = input;
return () => clearTimeout(timeout);
}
}, [input, transitionTime]);

return transitioning;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

overflow-y: hidden;

&, & > .row {
&,
&>.row {
height: calc(100vh - 2 * #{$top-bar-height});
}

Expand Down Expand Up @@ -43,11 +44,14 @@
overflow: auto;
}

&__graph-container-wrapper {
display: flex;
}

&__graph-container {
flex: 1;
position: relative;
height: calc(100vh - 2 * #{$top-bar-height});
width: 100%;
transition: width 0.2s;
float: left;
overflow: auto;

Expand All @@ -63,17 +67,16 @@
&__step-info {
min-height: calc(100vh - 2 * #{$top-bar-height});
border-left: 1px solid $argo-color-gray-4;
width: 0;
transition: width 0.2s;
float: right
position: relative;
}

&__step-info-close {
display: block;
display: none;
position: absolute;
cursor: pointer;
top: 1em;
right: -1em;
right: 1em;
z-index: 8;
border-radius: 50%;
color: $argo-color-gray-5;
Expand All @@ -89,30 +92,46 @@
}
}

&--step-node-expanded &__graph-container {
width: calc(100% - 570px);
}
&__step-info-drag-handle {
left: -6px;
height: 100%;
position: absolute;
width: 12px;
z-index: 1;

&--step-node-expanded &__step-info {
width: 570px;
&:hover {
cursor: ew-resize;
}
}

&--step-node-expanded &__step-info-close {
right: 1em;
}
&__attribute-grid {
color: $argo-color-gray-8;
display: grid;
grid-template-columns: auto minmax(50%, 1fr);

&--artifact-expanded &__graph-container {
width: calc(50%);
}
& > * {
align-items: center;
display: flex;
min-height: 50px;
padding: 14px 0;
}

&--artifact-expanded &__step-info {
width: 50%;
}
& > *:nth-child(odd) {
padding-right: 4em;
}

&--artifact-expanded &__step-info-close {
right: 1em;
& > *:not(:nth-last-child(-n+2)) {
border-bottom: 1px solid $argo-color-gray-3;
}

pre {
overflow: unset;
}
}

&--step-node-expanded &__step-info-close {
display: block;
}
}

.badge {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {Page, SlidingPanel} from 'argo-ui';
import * as classNames from 'classnames';
import * as React from 'react';
import {useContext, useEffect, useState} from 'react';
import {useContext, useEffect, useRef, useState} from 'react';
import {RouteComponentProps} from 'react-router';
import {execSpec, Link, NodeStatus, Parameter, Workflow} from '../../../../models';
import {ANNOTATION_KEY_POD_NAME_VERSION} from '../../../shared/annotations';
Expand All @@ -19,6 +19,8 @@ import {getPodName, getTemplateNameFromNode} from '../../../shared/pod-name';
import {RetryWatch} from '../../../shared/retry-watch';
import {services} from '../../../shared/services';
import {useQueryParams} from '../../../shared/use-query-params';
import {useResizableWidth} from '../../../shared/use-resizable-width';
import {useTransition} from '../../../shared/use-transition';
import * as Operations from '../../../shared/workflow-operations-map';
import {WorkflowOperations} from '../../../shared/workflow-operations-map';
import {WidgetGallery} from '../../../widgets/widget-gallery';
Expand All @@ -42,6 +44,12 @@ function parseSidePanelParam(param: string) {
return {type, nodeId, container: container || 'main'};
}

const LEFT_NAV_WIDTH = 60;
const GRAPH_CONTAINER_MIN_WIDTH = 490;
const INITIAL_SIDE_PANEL_WIDTH = 570;
const ANIMATION_MS = 200;
const ANIMATION_BUFFER_MS = 20;

export const WorkflowDetails = ({history, location, match}: RouteComponentProps<any>) => {
// boiler-plate
const {navigation, popup} = useContext(Context);
Expand All @@ -54,6 +62,21 @@ export const WorkflowDetails = ({history, location, match}: RouteComponentProps<
const [nodePanelView, setNodePanelView] = useState(queryParams.get('nodePanelView'));
const [sidePanel, setSidePanel] = useState(queryParams.get('sidePanel'));
const [parameters, setParameters] = useState<Parameter[]>([]);
const sidePanelRef = useRef<HTMLDivElement>(null);
const [workflow, setWorkflow] = useState<Workflow>();
const [links, setLinks] = useState<Link[]>();
const [error, setError] = useState<Error>();
const selectedNode = workflow && workflow.status && workflow.status.nodes && workflow.status.nodes[nodeId];
const selectedArtifact = workflow && workflow.status && findArtifact(workflow.status, nodeId);
const isSidePanelExpanded = !!(selectedNode || selectedArtifact);
const isSidePanelAnimating = useTransition(isSidePanelExpanded, ANIMATION_MS + ANIMATION_BUFFER_MS);
const {width: sidePanelWidth, dragHandleProps: sidePanelDragHandleProps} = useResizableWidth({
disabled: isSidePanelAnimating || !isSidePanelExpanded,
initialWidth: INITIAL_SIDE_PANEL_WIDTH,
maxWidth: globalThis.innerWidth - LEFT_NAV_WIDTH - GRAPH_CONTAINER_MIN_WIDTH,
minWidth: INITIAL_SIDE_PANEL_WIDTH,
resizedElementRef: sidePanelRef
});

useEffect(
useQueryParams(history, p => {
Expand Down Expand Up @@ -82,9 +105,6 @@ export const WorkflowDetails = ({history, location, match}: RouteComponentProps<
history.push(historyUrl('workflows/{namespace}/{name}', {namespace, name, tab, nodeId, nodePanelView, sidePanel}));
}, [namespace, name, tab, nodeId, nodePanelView, sidePanel]);

const [workflow, setWorkflow] = useState<Workflow>();
const [links, setLinks] = useState<Link[]>();
const [error, setError] = useState<Error>();
useEffect(() => {
services.info
.getInfo()
Expand Down Expand Up @@ -359,11 +379,8 @@ export const WorkflowDetails = ({history, location, match}: RouteComponentProps<
return nodeID;
};

const selectedNode = workflow && workflow.status && workflow.status.nodes && workflow.status.nodes[nodeId];
const podName = ensurePodName(workflow, selectedNode, nodeId);

const selectedArtifact = workflow && workflow.status && findArtifact(workflow.status, nodeId);

return (
<Page
title={'Workflow Details'}
Expand Down Expand Up @@ -394,23 +411,31 @@ export const WorkflowDetails = ({history, location, match}: RouteComponentProps<
</div>
)
}}>
<div className={classNames('workflow-details', {'workflow-details--step-node-expanded': selectedNode, 'workflow-details--artifact-expanded': selectedArtifact})}>
<div className={classNames('workflow-details', {'workflow-details--step-node-expanded': isSidePanelExpanded})}>
<ErrorNotice error={error} />
{(tab === 'summary' && renderSummaryTab()) ||
(workflow && (
<div>
<div className='workflow-details__graph-container'>
<div className='workflow-details__graph-container-wrapper'>
<div className='workflow-details__graph-container' style={{minWidth: GRAPH_CONTAINER_MIN_WIDTH}}>
{(tab === 'workflow' && (
<WorkflowPanel workflowMetadata={workflow.metadata} workflowStatus={workflow.status} selectedNodeId={nodeId} nodeClicked={setNodeId} />
)) ||
(tab === 'events' && <EventsPanel namespace={workflow.metadata.namespace} kind='Workflow' name={workflow.metadata.name} />) || (
<WorkflowTimeline workflow={workflow} selectedNodeId={nodeId} nodeClicked={node => setNodeId(node.id)} />
)}
</div>
<div className='workflow-details__step-info'>
<div
className='workflow-details__step-info'
ref={sidePanelRef}
style={{
minWidth: !isSidePanelExpanded || isSidePanelAnimating ? 0 : `${INITIAL_SIDE_PANEL_WIDTH}px`,
transition: isSidePanelAnimating ? `width ${ANIMATION_MS}ms` : 'unset',
width: isSidePanelExpanded ? `${sidePanelWidth}px` : 0
}}>
<button className='workflow-details__step-info-close' onClick={() => setNodeId(null)}>
<i className='argo-icon-close' />
</button>
<div className='workflow-details__step-info-drag-handle' {...sidePanelDragHandleProps} />
{selectedNode && (
<WorkflowNodeInfo
node={selectedNode}
Expand Down
Loading

0 comments on commit 490285b

Please sign in to comment.