From cfe64057ba4495bca187335cec032bf3c39433d3 Mon Sep 17 00:00:00 2001 From: csirius <85753828+csirius@users.noreply.github.com> Date: Wed, 24 Nov 2021 20:26:33 -0500 Subject: [PATCH 1/4] feat: update workflow search UX Signed-off-by: csirius <85753828+csirius@users.noreply.github.com> --- src/components/Project/ProjectStatusBar.tsx | 58 +++++ src/components/Project/ProjectWorkflows.tsx | 10 +- src/components/Theme/colorSpectrum.ts | 5 + src/components/Theme/constants.ts | 2 + .../Workflow/SearchableWorkflowNameList.tsx | 216 ++++++++++++++---- .../SearchableWorkflowNameList.stories.tsx | 2 +- src/components/Workflow/types.ts | 11 + .../Workflow/useWorkflowInfoList.ts | 116 ++++++++++ src/components/common/SearchableList.tsx | 23 +- src/models/__mocks__/sampleWorkflowNames.ts | 26 ++- 10 files changed, 408 insertions(+), 61 deletions(-) create mode 100644 src/components/Project/ProjectStatusBar.tsx create mode 100644 src/components/Workflow/types.ts create mode 100644 src/components/Workflow/useWorkflowInfoList.ts diff --git a/src/components/Project/ProjectStatusBar.tsx b/src/components/Project/ProjectStatusBar.tsx new file mode 100644 index 000000000..9628370f3 --- /dev/null +++ b/src/components/Project/ProjectStatusBar.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { makeStyles, Theme } from '@material-ui/core/styles'; +import classNames from 'classnames'; +import { WorkflowExecutionPhase } from 'models/Execution/enums'; +import { barChartColors } from 'components/common/constants'; + +const useStyles = makeStyles((theme: Theme) => ({ + 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[]; +} + +/** + * Renders status of executions + * @param items + * @constructor + */ +const ProjectStatusBar: React.FC = ({ items }) => { + const styles = useStyles(); + + return ( +
+ {items.map((item, idx) => { + return ( +
= WorkflowExecutionPhase.FAILED + })} + key={`bar-item-${idx}`} + /> + ); + })} +
+ ); +}; + +export default ProjectStatusBar; diff --git a/src/components/Project/ProjectWorkflows.tsx b/src/components/Project/ProjectWorkflows.tsx index cc5c26b1a..2299b16cf 100644 --- a/src/components/Project/ProjectWorkflows.tsx +++ b/src/components/Project/ProjectWorkflows.tsx @@ -1,11 +1,13 @@ import { WaitForData } from 'components/common/WaitForData'; -import { useWorkflowNameList } from 'components/hooks/useNamedEntity'; import { SearchableWorkflowNameList } from 'components/Workflow/SearchableWorkflowNameList'; +import { SearchableInput } from 'components/common/SearchableList'; 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 { makeStyles } from '@material-ui/core/styles'; +import { useWorkflowInfoList } from '../Workflow/useWorkflowInfoList'; export interface ProjectWorkflowsProps { projectId: string; @@ -17,7 +19,7 @@ export const ProjectWorkflows: React.FC = ({ domainId: domain, projectId: project }) => { - const workflowNames = useWorkflowNameList( + const workflows = useWorkflowInfoList( { domain, project }, { limit: limits.NONE, @@ -37,8 +39,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..e92b4943c 100644 --- a/src/components/Workflow/SearchableWorkflowNameList.tsx +++ b/src/components/Workflow/SearchableWorkflowNameList.tsx @@ -1,54 +1,188 @@ -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, Theme } 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 { WorkflowListItem } 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'; + +interface SearchableWorkflowNameListProps { + workflows: WorkflowListItem[]; +} + +const useStyles = makeStyles((theme: Theme) => ({ + 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 + }, + itemIcon: { + marginRight: 14, + color: '#636379' + }, + itemRow: { + display: 'flex', + marginBottom: 10, + '&:last-child': { + marginBottom: 0 + } + }, + itemLabel: { + width: 140, + fontSize: 14, + color: workflowLabelColor + }, + searchInputContainer: { + padding: '0 13px', + margin: '32px 0 23px' + } +})); -/** Renders a searchable list of Workflow names, with associated descriptions */ -export const SearchableWorkflowNameList: React.FC> = props => { +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(); +}; + +/** + * Renders a searchable list of Workflow names, with associated descriptions + * @param workflows + * @constructor + */ +export const SearchableWorkflowNameList: React.FC = ({ + workflows +}) => { const commonStyles = useCommonStyles(); const listStyles = useNamedEntityListStyles(); + const styles = useStyles(); + const { results, searchString, setSearchString } = useSearchableListState({ + items: workflows, + propertyGetter: ({ id, description, inputs }) => + id.name + description + inputs + }); + const onSearchChange = (event: React.ChangeEvent) => { + const searchString = event.target.value; + setSearchString(searchString); + }; + const onClear = () => setSearchString(''); - const renderItem = ({ - key, - value, - content - }: SearchResult) => ( - -
-
-
{content}
- {!!value.metadata.description && ( - - {value.metadata.description} - + const renderItem = (workflowItem: WorkflowListItem, idx: number) => { + const { + id, + inputs, + outputs, + latestExecutionTime, + description, + executionStatus + } = workflowItem; + const key = `${id.project}/${id.domain}/${id.name}/${idx}`; + return ( + +
+
+ +
{id.name}
+
+
+ {description?.length + ? description + : 'This workflow has no description.'} +
+
+
+ Last execution time +
+
+ {latestExecutionTime ? ( + latestExecutionTime + ) : ( + No executions found + )} +
+
+
+
+ Last 10 executions +
+ +
+
+
Inputs
+
{inputs ?? {workflowNoInputsString}}
+
+
+
Outputs
+
{outputs ?? No output data found.}
+
- + + ); + }; + + return ( + <> + +
+ {results.map((workflow, idx) => + renderItem(workflow.value, idx) + )}
- + ); - return ; }; 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..164fd1131 --- /dev/null +++ b/src/components/Workflow/types.ts @@ -0,0 +1,11 @@ +import { WorkflowId } from 'models/Workflow/types'; +import { WorkflowExecutionPhase } from 'models/Execution/enums'; + +export type WorkflowListItem = { + id: WorkflowId; + inputs?: string; + outputs?: string; + latestExecutionTime?: string; + executionStatus?: WorkflowExecutionPhase[]; + description?: string; +}; diff --git a/src/components/Workflow/useWorkflowInfoList.ts b/src/components/Workflow/useWorkflowInfoList.ts new file mode 100644 index 000000000..16ffd94e0 --- /dev/null +++ b/src/components/Workflow/useWorkflowInfoList.ts @@ -0,0 +1,116 @@ +import { DomainIdentifierScope, ResourceType } from 'models/Common/types'; +import { + FilterOperationName, + RequestConfig, + SortDirection +} from 'models/AdminEntity/types'; +import { useAPIContext } from '../data/apiContext'; +import { usePagination } from 'components/hooks/usePagination'; +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 { WorkflowListItem } from './types'; +import { getInputsForWorkflow } from '../Launch/LaunchForm/getInputs'; +import * as Long from 'long'; +import { formatDateUTC } from 'common/formatters'; +import { timestampToDate } from 'common/utils'; + +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 + ); + + const metadata = await Promise.all( + entities.map(async entity => { + const { + id: { domain, project, name }, + metadata: { description } + } = entity; + 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 { + 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; + 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 { + description, + id, + inputs, + latestExecutionTime, + executionStatus + }; + }) + ); + + return { + entities: metadata, + ...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 ( -
+
({ 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: WorkflowListItem[] = sampleWorkflowIds.map( + id => ({ + id + }) +); From 53623692f08816c335f41945bd173dde02be7121 Mon Sep 17 00:00:00 2001 From: csirius <85753828+csirius@users.noreply.github.com> Date: Mon, 6 Dec 2021 12:53:46 -0500 Subject: [PATCH 2/4] feat: update search UX and add outputs 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 | 34 ++- src/components/Project/ProjectWorkflows.tsx | 4 +- .../Workflow/SearchableWorkflowNameList.tsx | 207 +++++++++++------- src/components/Workflow/types.ts | 8 + .../Workflow/useWorkflowInfoItem.ts | 109 +++++++++ .../Workflow/useWorkflowInfoList.ts | 104 +-------- src/components/common/Shimmer.tsx | 32 +++ src/models/__mocks__/sampleWorkflowNames.ts | 7 +- 10 files changed, 327 insertions(+), 196 deletions(-) create mode 100644 src/components/Workflow/useWorkflowInfoItem.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 index 9628370f3..4569f8c24 100644 --- a/src/components/Project/ProjectStatusBar.tsx +++ b/src/components/Project/ProjectStatusBar.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { makeStyles, Theme } from '@material-ui/core/styles'; +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((theme: Theme) => ({ +const useStyles = makeStyles(() => ({ barContainer: { display: 'block' }, @@ -26,6 +28,7 @@ const useStyles = makeStyles((theme: Theme) => ({ interface ProjectStatusBarProps { items: WorkflowExecutionPhase[]; + paths: string[]; } /** @@ -33,22 +36,31 @@ interface ProjectStatusBarProps { * @param items * @constructor */ -const ProjectStatusBar: React.FC = ({ items }) => { +const ProjectStatusBar: React.FC = ({ + items, + paths +}) => { const styles = useStyles(); + const commonStyles = useCommonStyles(); return (
{items.map((item, idx) => { return ( -
= WorkflowExecutionPhase.FAILED - })} + + > +
= WorkflowExecutionPhase.FAILED + })} + /> + ); })}
diff --git a/src/components/Project/ProjectWorkflows.tsx b/src/components/Project/ProjectWorkflows.tsx index 2299b16cf..c04e1a2e5 100644 --- a/src/components/Project/ProjectWorkflows.tsx +++ b/src/components/Project/ProjectWorkflows.tsx @@ -1,12 +1,10 @@ import { WaitForData } from 'components/common/WaitForData'; import { SearchableWorkflowNameList } from 'components/Workflow/SearchableWorkflowNameList'; -import { SearchableInput } from 'components/common/SearchableList'; 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 { makeStyles } from '@material-ui/core/styles'; import { useWorkflowInfoList } from '../Workflow/useWorkflowInfoList'; export interface ProjectWorkflowsProps { @@ -22,7 +20,7 @@ export const ProjectWorkflows: React.FC = ({ const workflows = useWorkflowInfoList( { domain, project }, { - limit: limits.NONE, + limit: limits.DEFAULT, sort: { direction: SortDirection.ASCENDING, key: workflowSortFields.name diff --git a/src/components/Workflow/SearchableWorkflowNameList.tsx b/src/components/Workflow/SearchableWorkflowNameList.tsx index e92b4943c..326c72b1f 100644 --- a/src/components/Workflow/SearchableWorkflowNameList.tsx +++ b/src/components/Workflow/SearchableWorkflowNameList.tsx @@ -1,4 +1,4 @@ -import { makeStyles, Theme } from '@material-ui/core/styles'; +import { makeStyles } from '@material-ui/core/styles'; import DeviceHub from '@material-ui/icons/DeviceHub'; import classNames from 'classnames'; import { useNamedEntityListStyles } from 'components/common/SearchableNamedEntityList'; @@ -11,18 +11,25 @@ import { import * as React from 'react'; import { Link } from 'react-router-dom'; import { Routes } from 'routes/routes'; -import { WorkflowListItem } from './types'; +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: WorkflowListItem[]; + workflows: WorkflowListStructureItem[]; } -const useStyles = makeStyles((theme: Theme) => ({ +const useStyles = makeStyles(() => ({ container: { padding: 13, paddingRight: 71 @@ -44,7 +51,8 @@ const useStyles = makeStyles((theme: Theme) => ({ }, itemDescriptionRow: { color: '#757575', - marginBottom: 30 + marginBottom: 30, + width: '100%' }, itemIcon: { marginRight: 14, @@ -55,7 +63,9 @@ const useStyles = makeStyles((theme: Theme) => ({ marginBottom: 10, '&:last-child': { marginBottom: 0 - } + }, + alignItems: 'center', + width: '100%' }, itemLabel: { width: 140, @@ -65,6 +75,9 @@ const useStyles = makeStyles((theme: Theme) => ({ searchInputContainer: { padding: '0 13px', margin: '32px 0 23px' + }, + w100: { + flex: 1 } })); @@ -78,6 +91,106 @@ const padExecutions = (items: WorkflowExecutionPhase[]) => { 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 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); + + return ( + +
+
+ +
{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. + )} +
+
+
+ + ); +}; + /** * Renders a searchable list of Workflow names, with associated descriptions * @param workflows @@ -86,13 +199,10 @@ const padExecutions = (items: WorkflowExecutionPhase[]) => { export const SearchableWorkflowNameList: React.FC = ({ workflows }) => { - const commonStyles = useCommonStyles(); - const listStyles = useNamedEntityListStyles(); const styles = useStyles(); const { results, searchString, setSearchString } = useSearchableListState({ items: workflows, - propertyGetter: ({ id, description, inputs }) => - id.name + description + inputs + propertyGetter: ({ id }) => id.name }); const onSearchChange = (event: React.ChangeEvent) => { const searchString = event.target.value; @@ -100,74 +210,6 @@ export const SearchableWorkflowNameList: React.FC setSearchString(''); - const renderItem = (workflowItem: WorkflowListItem, idx: number) => { - const { - id, - inputs, - outputs, - latestExecutionTime, - description, - executionStatus - } = workflowItem; - const key = `${id.project}/${id.domain}/${id.name}/${idx}`; - return ( - -
-
- -
{id.name}
-
-
- {description?.length - ? description - : 'This workflow has no description.'} -
-
-
- Last execution time -
-
- {latestExecutionTime ? ( - latestExecutionTime - ) : ( - No executions found - )} -
-
-
-
- Last 10 executions -
- -
-
-
Inputs
-
{inputs ?? {workflowNoInputsString}}
-
-
-
Outputs
-
{outputs ?? No output data found.}
-
-
- - ); - }; - return ( <>
- {results.map((workflow, idx) => - renderItem(workflow.value, idx) - )} + {results.map((id, idx) => ( + + ))}
); diff --git a/src/components/Workflow/types.ts b/src/components/Workflow/types.ts index 164fd1131..aa9a743f7 100644 --- a/src/components/Workflow/types.ts +++ b/src/components/Workflow/types.ts @@ -1,5 +1,7 @@ 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; @@ -7,5 +9,11 @@ export type WorkflowListItem = { 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 index 16ffd94e0..241a280c1 100644 --- a/src/components/Workflow/useWorkflowInfoList.ts +++ b/src/components/Workflow/useWorkflowInfoList.ts @@ -1,28 +1,14 @@ import { DomainIdentifierScope, ResourceType } from 'models/Common/types'; -import { - FilterOperationName, - RequestConfig, - SortDirection -} from 'models/AdminEntity/types'; -import { useAPIContext } from '../data/apiContext'; +import { RequestConfig } from 'models/AdminEntity/types'; import { usePagination } from 'components/hooks/usePagination'; -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 { WorkflowListItem } from './types'; -import { getInputsForWorkflow } from '../Launch/LaunchForm/getInputs'; -import * as Long from 'long'; -import { formatDateUTC } from 'common/formatters'; -import { timestampToDate } from 'common/utils'; +import { listNamedEntities } from 'models/Common/api'; +import { WorkflowListStructureItem } from './types'; export const useWorkflowInfoList = ( scope: DomainIdentifierScope, config?: RequestConfig ) => { - const { listNamedEntities } = useAPIContext(); - return usePagination( + return usePagination( { ...config, fetchArg: scope }, async (scope, requestConfig) => { const { entities, ...rest } = await listNamedEntities( @@ -30,85 +16,11 @@ export const useWorkflowInfoList = ( requestConfig ); - const metadata = await Promise.all( - entities.map(async entity => { - const { - id: { domain, project, name }, - metadata: { description } - } = entity; - 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 { - 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; - 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 { - description, - id, - inputs, - latestExecutionTime, - executionStatus - }; - }) - ); - return { - entities: metadata, + entities: entities.map(({ id, metadata: { description } }) => ({ + id, + description + })), ...rest }; } diff --git a/src/components/common/Shimmer.tsx b/src/components/common/Shimmer.tsx new file mode 100644 index 000000000..0f1b56983 --- /dev/null +++ b/src/components/common/Shimmer.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { makeStyles, Theme } from '@material-ui/core/styles'; + +const useStyles = makeStyles((theme: Theme) => ({ + 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 309c93852..8c25f424e 100644 --- a/src/models/__mocks__/sampleWorkflowNames.ts +++ b/src/models/__mocks__/sampleWorkflowNames.ts @@ -1,4 +1,4 @@ -import { WorkflowListItem } from 'components/Workflow/types'; +import { WorkflowListStructureItem } from 'components/Workflow/types'; import { WorkflowId } from 'models/Workflow/types'; export const sampleWorkflowIds: WorkflowId[] = [ @@ -34,8 +34,9 @@ export const sampleWorkflowIds: WorkflowId[] = [ version: '' })); -export const sampleWorkflowNames: WorkflowListItem[] = sampleWorkflowIds.map( +export const sampleWorkflowNames: WorkflowListStructureItem[] = sampleWorkflowIds.map( id => ({ - id + id, + description: '' }) ); From 0724ddad579e20492e98a8fed13fa5aedace3498 Mon Sep 17 00:00:00 2001 From: csirius <85753828+csirius@users.noreply.github.com> Date: Mon, 6 Dec 2021 14:21:18 -0500 Subject: [PATCH 3/4] fix: change the limit to unlimited Signed-off-by: csirius <85753828+csirius@users.noreply.github.com> --- src/components/Project/ProjectWorkflows.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Project/ProjectWorkflows.tsx b/src/components/Project/ProjectWorkflows.tsx index c04e1a2e5..324af28d2 100644 --- a/src/components/Project/ProjectWorkflows.tsx +++ b/src/components/Project/ProjectWorkflows.tsx @@ -20,7 +20,7 @@ export const ProjectWorkflows: React.FC = ({ const workflows = useWorkflowInfoList( { domain, project }, { - limit: limits.DEFAULT, + limit: limits.NONE, sort: { direction: SortDirection.ASCENDING, key: workflowSortFields.name From 5fa0bb060455df4be22105ffccafc978bd38141b Mon Sep 17 00:00:00 2001 From: csirius <85753828+csirius@users.noreply.github.com> Date: Mon, 6 Dec 2021 14:40:32 -0500 Subject: [PATCH 4/4] fix: unit test failure Signed-off-by: csirius <85753828+csirius@users.noreply.github.com> --- src/components/Workflow/useWorkflowInfoList.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/Workflow/useWorkflowInfoList.ts b/src/components/Workflow/useWorkflowInfoList.ts index 241a280c1..eb78a1142 100644 --- a/src/components/Workflow/useWorkflowInfoList.ts +++ b/src/components/Workflow/useWorkflowInfoList.ts @@ -1,13 +1,15 @@ import { DomainIdentifierScope, ResourceType } from 'models/Common/types'; import { RequestConfig } from 'models/AdminEntity/types'; import { usePagination } from 'components/hooks/usePagination'; -import { listNamedEntities } from 'models/Common/api'; 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) => {