Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(ui): InputFilter and WorkflowTimeline components from class to functional #11899

108 changes: 50 additions & 58 deletions ui/src/app/shared/components/input-filter.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Autocomplete} from 'argo-ui';
import * as React from 'react';
import React, {useState} from 'react';

interface InputProps {
value: string;
Expand All @@ -8,68 +8,60 @@ interface InputProps {
onChange: (input: string) => void;
}

interface InputState {
value: string;
localCache: string[];
error?: Error;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error state isn't used, so removed it.

}
export function InputFilter(props: InputProps) {
const [value, setValue] = useState(props.value);
const [localCache, setLocalCache] = useState((localStorage.getItem(props.name + '_inputs') || '').split(',').filter(item => item !== ''));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ts infers the type of localCache correctly, so I omitted type specification. (useState<string[]>)
but at the same time, I think it also looks good to specify the type for readability.
which should I choose 😟

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea same as your previous PR, I think when the inference is correct and straightforward (string in this case), there's no need to annotate it.

I also have experience working in ML-family languages like OCaml, which are even stronger typed and where the compiler infers the types of the entire program (so there are no type annotations at all). On the other spectrum would be something like Java, which requires on type annotations on almost everything and feels very verbose as a result (despite having weaker guarantees than OCaml et al).

For TS, I prefer to have strict typing on, which we'll be able to do in this codebase once there is some progress on decoupling argo-ui (see argoproj/argo-ui#453). With strict typing, if a type can't be correctly inferred, the compiler will tell you (right now I believe it only has a warning).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. unless it's a complex type, I'll try to omit the type annotations where TS correctly infers. thanks for detailed description.😄

and I had no idea about argo-ui! Thank you for letting me know. I'm really hopeful for the progress in decoupling. It sounds it could bring great improvements.


export class InputFilter extends React.Component<InputProps, InputState> {
constructor(props: Readonly<InputProps>) {
super(props);
this.state = {
value: props.value,
localCache: (localStorage.getItem(this.props.name + '_inputs') || '').split(',').filter(value => value !== '')
};
function setValueAndCache(newValue: string) {
setLocalCache(currentCache => {
const updatedCache = [...currentCache];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch to make this an immutable operation!

if (!updatedCache.includes(newValue)) {
updatedCache.unshift(newValue);
}
while (updatedCache.length > 5) {
updatedCache.pop();
}
localStorage.setItem(props.name + '_inputs', updatedCache.join(','));
return updatedCache;
});
setValue(newValue);
}

public render() {
function renderInput(inputProps: React.HTMLProps<HTMLInputElement>) {
return (
<>
<Autocomplete
items={this.state.localCache}
value={this.state.value}
onChange={(e, value) => this.setState({value})}
onSelect={value => {
this.setState({value});
this.props.onChange(value);
}}
renderInput={inputProps => (
<input
{...inputProps}
onKeyUp={event => {
if (event.keyCode === 13) {
this.setValueAndCache(event.currentTarget.value);
this.props.onChange(this.state.value);
}
}}
className='argo-field'
placeholder={this.props.placeholder}
/>
)}
/>
<a
onClick={() => {
this.setState({value: ''});
this.props.onChange('');
}}>
<i className='fa fa-times-circle' />
</a>
</>
<input
{...inputProps}
onKeyUp={event => {
if (event.keyCode === 13) {
setValueAndCache(event.currentTarget.value);
props.onChange(value);
}
}}
className='argo-field'
placeholder={props.placeholder}
/>
);
}

private setValueAndCache(value: string) {
this.setState(state => {
const localCache = state.localCache;
if (!state.localCache.includes(value)) {
localCache.unshift(value);
}
while (localCache.length > 5) {
localCache.pop();
}
localStorage.setItem(this.props.name + '_inputs', localCache.join(','));
return {value, localCache};
});
}
return (
<>
<Autocomplete
items={localCache}
value={value}
onChange={(e, newValue) => setValue(newValue)}
onSelect={newValue => {
setValue(newValue);
props.onChange(newValue);
}}
renderInput={renderInput}
/>
<a
onClick={() => {
setValue('');
props.onChange('');
}}>
<i className='fa fa-times-circle' />
</a>
</>
);
}
207 changes: 97 additions & 110 deletions ui/src/app/workflows/components/workflow-timeline/workflow-timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import classNames from 'classnames';
import moment from 'moment';
import * as React from 'react';
import React, {useEffect, useRef, useState} from 'react';
import {fromEvent, interval, Subscription} from 'rxjs';

import * as models from '../../../../models';
Expand All @@ -11,135 +11,122 @@ require('./workflow-timeline.scss');
const ROUND_START_DIFF_MS = 1000;
const NODE_NAME_WIDTH = 250;
const MIN_WIDTH = 800;
const COMPLETED_PHASES = [models.NODE_PHASE.ERROR, models.NODE_PHASE.SUCCEEDED, models.NODE_PHASE.SKIPPED, models.NODE_PHASE.OMITTED, models.NODE_PHASE.FAILED];

interface WorkflowTimelineProps {
workflow: models.Workflow;
selectedNodeId: string;
nodeClicked?: (node: models.NodeStatus) => any;
}

interface WorkflowTimelineState {
parentWidth: number;
now: moment.Moment;
}

export class WorkflowTimeline extends React.Component<WorkflowTimelineProps, WorkflowTimelineState> {
private container: HTMLElement;
private resizeSubscription: Subscription;
private refreshSubscription: Subscription;
export function WorkflowTimeline(props: WorkflowTimelineProps) {
const [parentWidth, setParentWidth] = useState(0);
const [now, setNow] = useState(moment());

constructor(props: WorkflowTimelineProps) {
super(props);
this.state = {parentWidth: 0, now: moment()};
this.ensureRunningWorkflowRefreshing(props.workflow);
}
const containerRef = useRef<HTMLDivElement>(null);
const resizeSubscription = useRef<Subscription>(null);
const refreshSubscription = useRef<Subscription>(null);

public componentDidMount() {
this.resizeSubscription = fromEvent(window, 'resize').subscribe(() => this.updateWidth());
this.updateWidth();
}
useEffect(() => {
resizeSubscription.current = fromEvent(window, 'resize').subscribe(updateWidth);
updateWidth();

public componentWillReceiveProps(nextProps: WorkflowTimelineProps) {
this.ensureRunningWorkflowRefreshing(nextProps.workflow);
}
return () => {
resizeSubscription.current?.unsubscribe();
refreshSubscription.current?.unsubscribe();
};
}, []);

public componentWillUnmount() {
if (this.resizeSubscription) {
this.resizeSubscription.unsubscribe();
this.resizeSubscription = null;
useEffect(() => {
const isCompleted = props.workflow?.status && COMPLETED_PHASES.includes(props.workflow.status.phase);
if (!refreshSubscription.current && !isCompleted) {
refreshSubscription.current = interval(1000).subscribe(() => {
setNow(moment());
});
} else if (refreshSubscription.current && isCompleted) {
refreshSubscription.current.unsubscribe();
refreshSubscription.current = null;
}
if (this.refreshSubscription) {
this.refreshSubscription.unsubscribe();
this.refreshSubscription = null;
}, [props.workflow]);

function updateWidth() {
if (containerRef.current) {
setParentWidth((containerRef.current.offsetParent || window.document.body).clientWidth - NODE_NAME_WIDTH);
}
}

public render() {
if (!this.props.workflow.status.nodes) {
return <p>No nodes</p>;
}
const nodes = Object.keys(this.props.workflow.status.nodes)
.map(id => {
const node = this.props.workflow.status.nodes[id];
node.finishedAt = node.finishedAt || this.state.now.format();
node.startedAt = node.startedAt || this.state.now.format();
return node;
})
.filter(node => node.startedAt && node.type === 'Pod')
.sort((first, second) => {
const diff = moment(first.startedAt).diff(second.startedAt);
// If node started almost at the same time then sort by end time
if (diff <= 2) {
return moment(first.finishedAt).diff(second.finishedAt);
}
return diff;
});
if (nodes.length === 0) {
return <div />;
}
const timelineStart = moment(nodes[0].startedAt).valueOf();
const timelineEnd = nodes.map(node => moment(node.finishedAt).valueOf()).reduce((first, second) => Math.max(first, second), moment(timelineStart).valueOf());

const timeToLeft = (time: number) => ((time - timelineStart) / (timelineEnd - timelineStart)) * Math.max(this.state.parentWidth, MIN_WIDTH) + NODE_NAME_WIDTH;
const groups = nodes.map(node => ({
startedAt: moment(node.startedAt).valueOf(),
finishedAt: moment(node.finishedAt).valueOf(),
nodes: [
Object.assign({}, node, {
left: timeToLeft(moment(node.startedAt).valueOf()),
width: timeToLeft(moment(node.finishedAt).valueOf()) - timeToLeft(moment(node.startedAt).valueOf())
})
]
}));
for (let i = groups.length - 1; i >= 1; i--) {
const cur = groups[i];
const next = groups[i - 1];
if (moment(cur.startedAt).diff(next.finishedAt, 'milliseconds') < 0 && moment(next.startedAt).diff(cur.startedAt, 'milliseconds') < ROUND_START_DIFF_MS) {
next.nodes = next.nodes.concat(cur.nodes);
next.finishedAt = nodes.map(node => moment(node.finishedAt).valueOf()).reduce((first, second) => Math.max(first, second), next.finishedAt.valueOf());
groups.splice(i, 1);
if (!props.workflow.status.nodes) {
return <p>No nodes</p>;
}

const nodes = Object.keys(props.workflow.status.nodes)
.map(id => {
const node = props.workflow.status.nodes[id];
node.finishedAt = node.finishedAt || now.format();
node.startedAt = node.startedAt || now.format();
return node;
})
.filter(node => node.startedAt && node.type === 'Pod')
.sort((first, second) => {
const diff = moment(first.startedAt).diff(second.startedAt);
if (diff <= 2) {
return moment(first.finishedAt).diff(second.finishedAt);
}
}
return (
<div className='workflow-timeline' ref={container => (this.container = container)} style={{width: Math.max(this.state.parentWidth, MIN_WIDTH) + NODE_NAME_WIDTH}}>
<div style={{left: NODE_NAME_WIDTH}} className='workflow-timeline__start-line' />
<div className='workflow-timeline__row workflow-timeline__row--header' />
{groups.map(group => [
<div style={{left: timeToLeft(group.startedAt)}} key={`group-${group.startedAt}`} className={classNames('workflow-timeline__start-line')}>
<span className='workflow-timeline__start-line__time'>{moment(group.startedAt).format('hh:mm')}</span>
</div>,
...group.nodes.map(node => (
<div
key={node.id}
className={classNames('workflow-timeline__row', {'workflow-timeline__row--selected': node.id === this.props.selectedNodeId})}
onClick={() => this.props.nodeClicked && this.props.nodeClicked(node)}>
<div className='workflow-timeline__node-name'>
<span title={Utils.shortNodeName(node)}>{Utils.shortNodeName(node)}</span>
</div>
<div style={{left: node.left, width: node.width}} className={`workflow-timeline__node workflow-timeline__node--${node.phase.toLocaleLowerCase()}`} />
</div>
))
])}
</div>
);
return diff;
});

if (nodes.length === 0) {
return <div />;
}

public updateWidth() {
if (this.container) {
this.setState({parentWidth: (this.container.offsetParent || window.document.body).clientWidth - NODE_NAME_WIDTH});
}
const timelineStart = moment(nodes[0].startedAt).valueOf();
const timelineEnd = nodes.map(node => moment(node.finishedAt).valueOf()).reduce((first, second) => Math.max(first, second), moment(timelineStart).valueOf());

function timeToLeft(time: number) {
return ((time - timelineStart) / (timelineEnd - timelineStart)) * Math.max(parentWidth, MIN_WIDTH) + NODE_NAME_WIDTH;
}

private ensureRunningWorkflowRefreshing(workflow: models.Workflow) {
const completedPhases = [models.NODE_PHASE.ERROR, models.NODE_PHASE.SUCCEEDED, models.NODE_PHASE.SKIPPED, models.NODE_PHASE.OMITTED, models.NODE_PHASE.FAILED];
const isCompleted = workflow && workflow.status && completedPhases.indexOf(workflow.status.phase) > -1;
if (!this.refreshSubscription && !isCompleted) {
this.refreshSubscription = interval(1000).subscribe(() => {
this.setState({now: moment()});
});
} else if (this.refreshSubscription && isCompleted) {
this.refreshSubscription.unsubscribe();
this.refreshSubscription = null;
const groups = nodes.map(node => ({
startedAt: moment(node.startedAt).valueOf(),
finishedAt: moment(node.finishedAt).valueOf(),
nodes: [
Object.assign({}, node, {
left: timeToLeft(moment(node.startedAt).valueOf()),
width: timeToLeft(moment(node.finishedAt).valueOf()) - timeToLeft(moment(node.startedAt).valueOf())
})
]
}));

for (let i = groups.length - 1; i >= 1; i--) {
const cur = groups[i];
const next = groups[i - 1];
if (moment(cur.startedAt).diff(next.finishedAt, 'milliseconds') < 0 && moment(next.startedAt).diff(cur.startedAt, 'milliseconds') < ROUND_START_DIFF_MS) {
next.nodes = next.nodes.concat(cur.nodes);
next.finishedAt = nodes.map(node => moment(node.finishedAt).valueOf()).reduce((first, second) => Math.max(first, second), next.finishedAt.valueOf());
groups.splice(i, 1);
}
}

return (
<div className='workflow-timeline' ref={containerRef} style={{width: Math.max(parentWidth, MIN_WIDTH) + NODE_NAME_WIDTH}}>
<div style={{left: NODE_NAME_WIDTH}} className='workflow-timeline__start-line' />
<div className='workflow-timeline__row workflow-timeline__row--header' />
{groups.map(group => [
<div style={{left: timeToLeft(group.startedAt)}} key={`group-${group.startedAt}`} className={classNames('workflow-timeline__start-line')}>
<span className='workflow-timeline__start-line__time'>{moment(group.startedAt).format('hh:mm')}</span>
</div>,
...group.nodes.map(node => (
<div
key={node.id}
className={classNames('workflow-timeline__row', {'workflow-timeline__row--selected': node.id === props.selectedNodeId})}
onClick={() => props.nodeClicked && props.nodeClicked(node)}>
<div className='workflow-timeline__node-name'>
<span title={Utils.shortNodeName(node)}>{Utils.shortNodeName(node)}</span>
</div>
<div style={{left: node.left, width: node.width}} className={`workflow-timeline__node workflow-timeline__node--${node.phase.toLocaleLowerCase()}`} />
</div>
))
])}
</div>
);
}