From d1ab6bfdf750a1127f106cf3c77f3cebca94f53e Mon Sep 17 00:00:00 2001 From: csirius <85753828+csirius@users.noreply.github.com> Date: Tue, 7 Dec 2021 10:12:11 -0800 Subject: [PATCH] feat: update workflow search UX (#245) * feat: update workflow search UX Signed-off-by: csirius <85753828+csirius@users.noreply.github.com> * feat: update search UX and add outputs Signed-off-by: csirius <85753828+csirius@users.noreply.github.com> * fix: change the limit to unlimited Signed-off-by: csirius <85753828+csirius@users.noreply.github.com> * fix: unit test failure Signed-off-by: csirius <85753828+csirius@users.noreply.github.com> --- .../Entities/EntityExecutionsBarChart.tsx | 2 - src/components/Launch/LaunchForm/getInputs.ts | 16 ++ src/components/Project/ProjectStatusBar.tsx | 70 +++++ src/components/Project/ProjectWorkflows.tsx | 8 +- src/components/Theme/colorSpectrum.ts | 5 + src/components/Theme/constants.ts | 2 + .../Workflow/SearchableWorkflowNameList.tsx | 253 +++++++++++++++--- .../SearchableWorkflowNameList.stories.tsx | 2 +- src/components/Workflow/types.ts | 19 ++ .../Workflow/useWorkflowInfoItem.ts | 109 ++++++++ .../Workflow/useWorkflowInfoList.ts | 30 +++ src/components/common/SearchableList.tsx | 23 +- src/components/common/Shimmer.tsx | 32 +++ src/models/__mocks__/sampleWorkflowNames.ts | 27 +- 14 files changed, 539 insertions(+), 59 deletions(-) create mode 100644 src/components/Project/ProjectStatusBar.tsx create mode 100644 src/components/Workflow/types.ts create mode 100644 src/components/Workflow/useWorkflowInfoItem.ts create mode 100644 src/components/Workflow/useWorkflowInfoList.ts create mode 100644 src/components/common/Shimmer.tsx diff --git a/src/components/Entities/EntityExecutionsBarChart.tsx b/src/components/Entities/EntityExecutionsBarChart.tsx index 4f2f4c647..497ae38ed 100644 --- a/src/components/Entities/EntityExecutionsBarChart.tsx +++ b/src/components/Entities/EntityExecutionsBarChart.tsx @@ -116,8 +116,6 @@ export const EntityExecutionsBarChart: React.FC = } ); - console.log(executions); - const handleClickItem = React.useCallback(item => { if (item.metadata) { onToggle(item.metadata.name); diff --git a/src/components/Launch/LaunchForm/getInputs.ts b/src/components/Launch/LaunchForm/getInputs.ts index 9339026cc..99853e54b 100644 --- a/src/components/Launch/LaunchForm/getInputs.ts +++ b/src/components/Launch/LaunchForm/getInputs.ts @@ -61,6 +61,22 @@ export function getInputsForWorkflow( }); } +export function getOutputsForWorkflow(launchPlan: LaunchPlan): string[] { + if (!launchPlan.closure) { + return []; + } + + const launchPlanInputs = launchPlan.closure.expectedOutputs.variables; + return sortedObjectEntries(launchPlanInputs).map(value => { + const [name, parameter] = value; + + const typeDefinition = getInputDefintionForLiteralType(parameter.type); + const typeLabel = formatLabelWithType(name, typeDefinition); + + return typeLabel; + }); +} + export function getInputsForTask( task: Task, initialValues: LiteralValueMap = new Map() diff --git a/src/components/Project/ProjectStatusBar.tsx b/src/components/Project/ProjectStatusBar.tsx new file mode 100644 index 000000000..4569f8c24 --- /dev/null +++ b/src/components/Project/ProjectStatusBar.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; +import { WorkflowExecutionPhase } from 'models/Execution/enums'; +import { barChartColors } from 'components/common/constants'; +import { useCommonStyles } from 'components/common/styles'; + +const useStyles = makeStyles(() => ({ + barContainer: { + display: 'block' + }, + barItem: { + display: 'inline-block', + marginRight: 2, + borderRadius: 2, + width: 10, + height: 12, + backgroundColor: barChartColors.default + }, + successBarItem: { + backgroundColor: barChartColors.success + }, + failedBarItem: { + backgroundColor: barChartColors.failure + } +})); + +interface ProjectStatusBarProps { + items: WorkflowExecutionPhase[]; + paths: string[]; +} + +/** + * Renders status of executions + * @param items + * @constructor + */ +const ProjectStatusBar: React.FC = ({ + items, + paths +}) => { + const styles = useStyles(); + const commonStyles = useCommonStyles(); + + return ( +
+ {items.map((item, idx) => { + return ( + +
= WorkflowExecutionPhase.FAILED + })} + /> + + ); + })} +
+ ); +}; + +export default ProjectStatusBar; diff --git a/src/components/Project/ProjectWorkflows.tsx b/src/components/Project/ProjectWorkflows.tsx index cc5c26b1a..324af28d2 100644 --- a/src/components/Project/ProjectWorkflows.tsx +++ b/src/components/Project/ProjectWorkflows.tsx @@ -1,11 +1,11 @@ import { WaitForData } from 'components/common/WaitForData'; -import { useWorkflowNameList } from 'components/hooks/useNamedEntity'; import { SearchableWorkflowNameList } from 'components/Workflow/SearchableWorkflowNameList'; import { Admin } from 'flyteidl'; import { limits } from 'models/AdminEntity/constants'; import { FilterOperationName, SortDirection } from 'models/AdminEntity/types'; import { workflowSortFields } from 'models/Workflow/constants'; import * as React from 'react'; +import { useWorkflowInfoList } from '../Workflow/useWorkflowInfoList'; export interface ProjectWorkflowsProps { projectId: string; @@ -17,7 +17,7 @@ export const ProjectWorkflows: React.FC = ({ domainId: domain, projectId: project }) => { - const workflowNames = useWorkflowNameList( + const workflows = useWorkflowInfoList( { domain, project }, { limit: limits.NONE, @@ -37,8 +37,8 @@ export const ProjectWorkflows: React.FC = ({ ); return ( - - + + ); }; diff --git a/src/components/Theme/colorSpectrum.ts b/src/components/Theme/colorSpectrum.ts index cfee5503a..2112ab21e 100644 --- a/src/components/Theme/colorSpectrum.ts +++ b/src/components/Theme/colorSpectrum.ts @@ -198,6 +198,7 @@ export type ColorSpectrumType = | 'gray10' | 'gray15' | 'gray20' + | 'gray25' | 'gray30' | 'gray40' | 'gray50' @@ -1006,6 +1007,10 @@ export const COLOR_SPECTRUM: { color: '#CACAD9', value: 20 }, + gray25: { + color: '#B3B3B3', + value: 25 + }, gray30: { color: '#ACACC0', value: 30 diff --git a/src/components/Theme/constants.ts b/src/components/Theme/constants.ts index 22fc8e470..7619e04b1 100644 --- a/src/components/Theme/constants.ts +++ b/src/components/Theme/constants.ts @@ -44,6 +44,8 @@ export const mutedButtonHoverColor = COLOR_SPECTRUM.gray60.color; export const errorBackgroundColor = '#FBFBFC'; +export const workflowLabelColor = COLOR_SPECTRUM.gray25.color; + export const statusColors = { FAILURE: COLOR_SPECTRUM.red20.color, RUNNING: COLOR_SPECTRUM.blue20.color, diff --git a/src/components/Workflow/SearchableWorkflowNameList.tsx b/src/components/Workflow/SearchableWorkflowNameList.tsx index 9ce6d1071..326c72b1f 100644 --- a/src/components/Workflow/SearchableWorkflowNameList.tsx +++ b/src/components/Workflow/SearchableWorkflowNameList.tsx @@ -1,54 +1,233 @@ -import { Typography } from '@material-ui/core'; -import ChevronRight from '@material-ui/icons/ChevronRight'; -import { SearchResult } from 'components/common/SearchableList'; -import { - SearchableNamedEntity, - SearchableNamedEntityList, - SearchableNamedEntityListProps, - useNamedEntityListStyles -} from 'components/common/SearchableNamedEntityList'; +import { makeStyles } from '@material-ui/core/styles'; +import DeviceHub from '@material-ui/icons/DeviceHub'; +import classNames from 'classnames'; +import { useNamedEntityListStyles } from 'components/common/SearchableNamedEntityList'; import { useCommonStyles } from 'components/common/styles'; +import { + separatorColor, + primaryTextColor, + workflowLabelColor +} from 'components/Theme/constants'; import * as React from 'react'; import { Link } from 'react-router-dom'; import { Routes } from 'routes/routes'; +import { WorkflowListStructureItem } from './types'; +import ProjectStatusBar from '../Project/ProjectStatusBar'; +import { WorkflowExecutionPhase } from 'models/Execution/enums'; +import { workflowNoInputsString } from '../Launch/LaunchForm/constants'; +import { SearchableInput } from '../common/SearchableList'; +import { useSearchableListState } from '../common/useSearchableListState'; +import { useWorkflowInfoItem } from './useWorkflowInfoItem'; +import { Shimmer } from 'components/common/Shimmer'; +import { WorkflowExecutionIdentifier } from 'models/Execution/types'; + +interface SearchableWorkflowNameItemProps { + item: WorkflowListStructureItem; +} + +interface SearchableWorkflowNameListProps { + workflows: WorkflowListStructureItem[]; +} + +const useStyles = makeStyles(() => ({ + container: { + padding: 13, + paddingRight: 71 + }, + itemContainer: { + marginBottom: 15, + borderRadius: 16, + padding: '23px 30px', + border: `1px solid ${separatorColor}`, + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start' + }, + itemName: { + display: 'flex', + fontWeight: 600, + color: primaryTextColor, + marginBottom: 10 + }, + itemDescriptionRow: { + color: '#757575', + marginBottom: 30, + width: '100%' + }, + itemIcon: { + marginRight: 14, + color: '#636379' + }, + itemRow: { + display: 'flex', + marginBottom: 10, + '&:last-child': { + marginBottom: 0 + }, + alignItems: 'center', + width: '100%' + }, + itemLabel: { + width: 140, + fontSize: 14, + color: workflowLabelColor + }, + searchInputContainer: { + padding: '0 13px', + margin: '32px 0 23px' + }, + w100: { + flex: 1 + } +})); + +const padExecutions = (items: WorkflowExecutionPhase[]) => { + if (items.length >= 10) { + return items.slice(0, 10).reverse(); + } + const emptyExecutions = new Array(10 - items.length).fill( + WorkflowExecutionPhase.QUEUED + ); + return [...items, ...emptyExecutions].reverse(); +}; + +const padExecutionPaths = (items: WorkflowExecutionIdentifier[]) => { + if (items.length >= 10) { + return items + .slice(0, 10) + .map(id => Routes.ExecutionDetails.makeUrl(id)) + .reverse(); + } + const emptyExecutions = new Array(10 - items.length).fill(null); + return [ + ...items.map(id => Routes.ExecutionDetails.makeUrl(id)), + ...emptyExecutions + ].reverse(); +}; -/** Renders a searchable list of Workflow names, with associated descriptions */ -export const SearchableWorkflowNameList: React.FC> = props => { +/** + * Renders individual searchable workflow item + * @param item + * @returns + */ +const SearchableWorkflowNameItem: React.FC = ({ + item +}) => { const commonStyles = useCommonStyles(); const listStyles = useNamedEntityListStyles(); + const styles = useStyles(); + const { id, description } = item; + const { data: workflow, isLoading } = useWorkflowInfoItem(id); - const renderItem = ({ - key, - value, - content - }: SearchResult) => ( + return ( -
-
-
{content}
- {!!value.metadata.description && ( - - {value.metadata.description} - +
+
+ +
{id.name}
+
+
+ {description?.length + ? description + : 'This workflow has no description.'} +
+
+
Last execution time
+
+ {isLoading ? ( + + ) : workflow.latestExecutionTime ? ( + workflow.latestExecutionTime + ) : ( + No executions found + )} +
+
+
+
Last 10 executions
+ {isLoading ? ( + + ) : ( + )}
- +
+
Inputs
+
+ {isLoading ? ( + + ) : ( + workflow.inputs ?? {workflowNoInputsString} + )} +
+
+
+
Outputs
+
+ {isLoading ? ( + + ) : ( + workflow?.outputs ?? No output data found. + )} +
+
); - return ; +}; + +/** + * Renders a searchable list of Workflow names, with associated descriptions + * @param workflows + * @constructor + */ +export const SearchableWorkflowNameList: React.FC = ({ + workflows +}) => { + const styles = useStyles(); + const { results, searchString, setSearchString } = useSearchableListState({ + items: workflows, + propertyGetter: ({ id }) => id.name + }); + const onSearchChange = (event: React.ChangeEvent) => { + const searchString = event.target.value; + setSearchString(searchString); + }; + const onClear = () => setSearchString(''); + + return ( + <> + +
+ {results.map((id, idx) => ( + + ))} +
+ + ); }; diff --git a/src/components/Workflow/__stories__/SearchableWorkflowNameList.stories.tsx b/src/components/Workflow/__stories__/SearchableWorkflowNameList.stories.tsx index 7bc07546b..0c30a1e6b 100644 --- a/src/components/Workflow/__stories__/SearchableWorkflowNameList.stories.tsx +++ b/src/components/Workflow/__stories__/SearchableWorkflowNameList.stories.tsx @@ -3,7 +3,7 @@ import { sampleWorkflowNames } from 'models/__mocks__/sampleWorkflowNames'; import * as React from 'react'; import { SearchableWorkflowNameList } from '../SearchableWorkflowNameList'; -const baseProps = { names: [...sampleWorkflowNames] }; +const baseProps = { workflows: [...sampleWorkflowNames] }; const stories = storiesOf('Workflow/SearchableWorkflowNameList', module); stories.addDecorator(story =>
{story()}
); diff --git a/src/components/Workflow/types.ts b/src/components/Workflow/types.ts new file mode 100644 index 000000000..aa9a743f7 --- /dev/null +++ b/src/components/Workflow/types.ts @@ -0,0 +1,19 @@ +import { WorkflowId } from 'models/Workflow/types'; +import { WorkflowExecutionPhase } from 'models/Execution/enums'; +import { WorkflowExecutionIdentifier } from 'models/Execution/types'; +import { NamedEntityIdentifier } from 'models/Common/types'; + +export type WorkflowListItem = { + id: WorkflowId; + inputs?: string; + outputs?: string; + latestExecutionTime?: string; + executionStatus?: WorkflowExecutionPhase[]; + executionIds?: WorkflowExecutionIdentifier[]; + description?: string; +}; + +export type WorkflowListStructureItem = { + id: NamedEntityIdentifier; + description: string; +}; diff --git a/src/components/Workflow/useWorkflowInfoItem.ts b/src/components/Workflow/useWorkflowInfoItem.ts new file mode 100644 index 000000000..a866f6947 --- /dev/null +++ b/src/components/Workflow/useWorkflowInfoItem.ts @@ -0,0 +1,109 @@ +import { NamedEntityIdentifier } from 'models/Common/types'; +import { FilterOperationName, SortDirection } from 'models/AdminEntity/types'; +import { executionSortFields } from 'models/Execution/constants'; +import { listExecutions } from 'models/Execution/api'; +import { listWorkflows } from 'models/Workflow/api'; +import { listLaunchPlans } from 'models/Launch/api'; +import { workflowSortFields } from 'models/Workflow/constants'; +import { + getInputsForWorkflow, + getOutputsForWorkflow +} from '../Launch/LaunchForm/getInputs'; +import * as Long from 'long'; +import { formatDateUTC } from 'common/formatters'; +import { timestampToDate } from 'common/utils'; +import { useQuery } from 'react-query'; + +export const useWorkflowInfoItem = ({ + domain, + project, + name +}: NamedEntityIdentifier) => { + const { + data: executionInfo, + isLoading: executionLoading, + error: executionError + } = useQuery(['workflow-executions', domain, project, name], async () => { + const { entities: executions } = await listExecutions( + { domain, project }, + { + sort: { + key: executionSortFields.createdAt, + direction: SortDirection.DESCENDING + }, + filter: [ + { + key: 'workflow.name', + operation: FilterOperationName.EQ, + value: name + } + ], + limit: 10 + } + ); + const executionIds = executions.map(execution => execution.id); + let latestExecutionTime; + const hasExecutions = executions.length > 0; + if (hasExecutions) { + const latestExecution = executions[0].closure.createdAt; + const timeStamp = { + nanos: latestExecution.nanos, + seconds: Long.fromValue(latestExecution.seconds!) + }; + latestExecutionTime = formatDateUTC(timestampToDate(timeStamp)); + } + const executionStatus = executions.map( + execution => execution.closure.phase + ); + return { + latestExecutionTime, + executionStatus, + executionIds + }; + }); + + const { + data: workflowInfo, + isLoading: workflowLoading, + error: workflowError + } = useQuery(['workflow-info', domain, project, name], async () => { + const { + entities: [workflow] + } = await listWorkflows( + { domain, project, name }, + { + limit: 1, + sort: { + key: workflowSortFields.createdAt, + direction: SortDirection.DESCENDING + } + } + ); + const { id } = workflow; + const { + entities: [launchPlan] + } = await listLaunchPlans({ domain, project, name }, { limit: 1 }); + const parsedInputs = getInputsForWorkflow( + workflow, + launchPlan, + undefined + ); + const inputs = + parsedInputs.length > 0 + ? parsedInputs.map(input => input.label).join(', ') + : undefined; + const parsedOutputs = getOutputsForWorkflow(launchPlan); + const outputs = + parsedOutputs.length > 0 ? parsedOutputs.join(', ') : undefined; + return { id, inputs, outputs }; + }); + + return { + data: { + ...workflowInfo, + ...executionInfo + }, + isLoading: executionLoading || workflowLoading, + error: executionError ?? workflowError + }; +}; diff --git a/src/components/Workflow/useWorkflowInfoList.ts b/src/components/Workflow/useWorkflowInfoList.ts new file mode 100644 index 000000000..eb78a1142 --- /dev/null +++ b/src/components/Workflow/useWorkflowInfoList.ts @@ -0,0 +1,30 @@ +import { DomainIdentifierScope, ResourceType } from 'models/Common/types'; +import { RequestConfig } from 'models/AdminEntity/types'; +import { usePagination } from 'components/hooks/usePagination'; +import { WorkflowListStructureItem } from './types'; +import { useAPIContext } from 'components/data/apiContext'; + +export const useWorkflowInfoList = ( + scope: DomainIdentifierScope, + config?: RequestConfig +) => { + const { listNamedEntities } = useAPIContext(); + + return usePagination( + { ...config, fetchArg: scope }, + async (scope, requestConfig) => { + const { entities, ...rest } = await listNamedEntities( + { ...scope, resourceType: ResourceType.WORKFLOW }, + requestConfig + ); + + return { + entities: entities.map(({ id, metadata: { description } }) => ({ + id, + description + })), + ...rest + }; + } + ); +}; diff --git a/src/components/common/SearchableList.tsx b/src/components/common/SearchableList.tsx index 27dd8add3..24a4217f2 100644 --- a/src/components/common/SearchableList.tsx +++ b/src/components/common/SearchableList.tsx @@ -11,6 +11,7 @@ import { SearchResult, useSearchableListState } from './useSearchableListState'; +import classNames from 'classnames'; const useStyles = makeStyles((theme: Theme) => ({ container: { @@ -43,13 +44,23 @@ export interface SearchableListProps { renderContent(results: SearchResult[]): JSX.Element; } -const SearchableInput: React.FC<{ +/** + * Show searchable text input field. + * @param onClear + * @param onSearchChange + * @param placeholder + * @param value + * @param variant + * @constructor + */ +export const SearchableInput: React.FC<{ onClear: () => void; onSearchChange: React.ChangeEventHandler; placeholder?: string; variant: SearchableListVariant; value?: string; -}> = ({ onClear, onSearchChange, placeholder, value, variant }) => { + className?: string; +}> = ({ onClear, onSearchChange, placeholder, value, variant, className }) => { const styles = useStyles(); const startAdornment = ( @@ -82,7 +93,13 @@ const SearchableInput: React.FC<{ switch (variant) { case 'normal': return ( -
+
({ + animate: { + height: 10, + animation: '$shimmer 4s infinite linear', + background: + 'linear-gradient(to right, #eff1f3 4%, #e2e2e2 25%, #eff1f3 36%)', + backgroundSize: '1000px 100%', + borderRadius: 8, + width: '100%' + }, + '@keyframes shimmer': { + '0%': { + backgroundPosition: '-1000px 0' + }, + '100%': { + backgroundPosition: '1000px 0' + } + } +})); + +interface ShimmerProps { + height?: number; +} + +export const Shimmer: React.FC = () => { + const styles = useStyles(); + + return
; +}; diff --git a/src/models/__mocks__/sampleWorkflowNames.ts b/src/models/__mocks__/sampleWorkflowNames.ts index 185f05f15..8c25f424e 100644 --- a/src/models/__mocks__/sampleWorkflowNames.ts +++ b/src/models/__mocks__/sampleWorkflowNames.ts @@ -1,7 +1,7 @@ -import { Admin, Core } from 'flyteidl'; -import { NamedEntity, NamedEntityIdentifier } from 'models/Common/types'; +import { WorkflowListStructureItem } from 'components/Workflow/types'; +import { WorkflowId } from 'models/Workflow/types'; -export const sampleWorkflowIds: NamedEntityIdentifier[] = [ +export const sampleWorkflowIds: WorkflowId[] = [ 'batch_workflow.BatchTasksWorkflow', 'failing_workflows.DivideByZeroWf', 'failing_workflows.RetrysWf', @@ -27,13 +27,16 @@ export const sampleWorkflowIds: NamedEntityIdentifier[] = [ 'workflows.notifications.BasicWorkflow', 'workflows-python-python-tasks-workflow', 'workflows.python.PythonTasksWorkflow' -].map(name => ({ name, project: 'flytekit', domain: 'development' })); - -export const sampleWorkflowNames: NamedEntity[] = sampleWorkflowIds.map(id => ({ - id, - resourceType: Core.ResourceType.WORKFLOW, - metadata: { - description: `A description for ${id.name}`, - state: Admin.NamedEntityState.NAMED_ENTITY_ACTIVE - } +].map(name => ({ + name, + project: 'flytekit', + domain: 'development', + version: '' })); + +export const sampleWorkflowNames: WorkflowListStructureItem[] = sampleWorkflowIds.map( + id => ({ + id, + description: '' + }) +);