From 96d05f2300468608da0e98ae6a76be22f5b371a8 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Wed, 6 Jan 2021 15:44:36 -0800 Subject: [PATCH 1/7] feat: adds project-level executions view --- src/components/Entities/EntityExecutions.tsx | 4 +- .../Tables/WorkflowExecutionsTable.tsx | 37 +++++--- src/components/Executions/Tables/constants.ts | 2 +- .../Executions/workflowExecutionQueries.ts | 36 ++++++++ .../Navigation/ProjectNavigation.tsx | 18 ++++ src/components/Project/ProjectDetails.tsx | 13 ++- src/components/Project/ProjectExecutions.tsx | 89 ++++++++++++++----- src/components/data/queries.ts | 3 +- src/components/data/queryUtils.ts | 9 ++ src/components/data/types.ts | 12 ++- src/routes/routes.ts | 8 ++ 11 files changed, 191 insertions(+), 40 deletions(-) create mode 100644 src/components/Executions/workflowExecutionQueries.ts diff --git a/src/components/Entities/EntityExecutions.tsx b/src/components/Entities/EntityExecutions.tsx index cdeaa620a..8c3b1c895 100644 --- a/src/components/Entities/EntityExecutions.tsx +++ b/src/components/Entities/EntityExecutions.tsx @@ -3,7 +3,7 @@ import { makeStyles, Theme } from '@material-ui/core/styles'; import { contentMarginGridUnits } from 'common/layout'; import { WaitForData } from 'components/common'; import { ExecutionFilters } from 'components/Executions/ExecutionFilters'; -import { useWorkflowExecutionFiltersState as useExecutionFiltersState } from 'components/Executions/filters/useExecutionFiltersState'; +import { useWorkflowExecutionFiltersState } from 'components/Executions/filters/useExecutionFiltersState'; import { WorkflowExecutionsTable as ExecutionsTable } from 'components/Executions/Tables/WorkflowExecutionsTable'; import { useWorkflowExecutions as useExecutions } from 'components/hooks'; import { ResourceIdentifier } from 'models'; @@ -30,7 +30,7 @@ export interface EntityExecutionsProps { export const EntityExecutions: React.FC = ({ id }) => { const { domain, project, resourceType } = id; const styles = useStyles(); - const filtersState = useExecutionFiltersState(); + const filtersState = useWorkflowExecutionFiltersState(); const sort = { key: executionSortFields.createdAt, direction: SortDirection.DESCENDING diff --git a/src/components/Executions/Tables/WorkflowExecutionsTable.tsx b/src/components/Executions/Tables/WorkflowExecutionsTable.tsx index 05e600525..d01a09d14 100644 --- a/src/components/Executions/Tables/WorkflowExecutionsTable.tsx +++ b/src/components/Executions/Tables/WorkflowExecutionsTable.tsx @@ -33,7 +33,8 @@ const useStyles = makeStyles((theme: Theme) => ({ paddingLeft: theme.spacing(1) }, columnName: { - flexBasis: workflowExecutionsTableColumnWidths.name + flexBasis: workflowExecutionsTableColumnWidths.name, + whiteSpace: 'normal' }, columnLastRun: { flexBasis: workflowExecutionsTableColumnWidths.lastRun @@ -79,22 +80,34 @@ type WorkflowExecutionColumnDefinition = ColumnDefinition< WorkflowExecutionCellRendererData >; +interface WorkflowExecutionColumnOptions { + showWorkflowName: boolean; +} function generateColumns( styles: ReturnType, - tableStyles: ReturnType + commonStyles: ReturnType, + { showWorkflowName }: WorkflowExecutionColumnOptions ): WorkflowExecutionColumnDefinition[] { return [ { cellRenderer: ({ execution: { id, - closure: { startedAt } + closure: { startedAt, workflowId } } }) => ( <> - - {startedAt + + {showWorkflowName + ? workflowId.name + : startedAt ? `Last run ${dateFromNow( timestampToDate(startedAt) )}` @@ -222,13 +235,15 @@ const WorkflowExecutionRow: React.FC = ({ ); }; -export type WorkflowExecutionsTableProps = ListProps +export interface WorkflowExecutionsTableProps extends ListProps { + showWorkflowName?: boolean; +} /** Renders a table of WorkflowExecution records. Executions with errors will * have an expanadable container rendered as part of the table row. */ export const WorkflowExecutionsTable: React.FC = props => { - const executions = props.value; + const { value: executions, showWorkflowName = false } = props; const [expandedErrors, setExpandedErrors] = React.useState< Dictionary >({}); @@ -244,10 +259,10 @@ export const WorkflowExecutionsTable: React.FC = p }, [executions]); // Memoizing columns so they won't be re-generated unless the styles change - const columns = React.useMemo(() => generateColumns(styles, tableStyles), [ - styles, - commonStyles - ]); + const columns = React.useMemo( + () => generateColumns(styles, commonStyles, { showWorkflowName }), + [styles, commonStyles, showWorkflowName] + ); const retry = () => props.fetch(); const onCloseIOModal = () => state.setSelectedIOExecution(null); diff --git a/src/components/Executions/Tables/constants.ts b/src/components/Executions/Tables/constants.ts index 6f6e5ad82..3853b92d0 100644 --- a/src/components/Executions/Tables/constants.ts +++ b/src/components/Executions/Tables/constants.ts @@ -2,7 +2,7 @@ export const workflowExecutionsTableColumnWidths = { duration: 100, inputsOutputs: 140, lastRun: 130, - name: 270, + name: 360, phase: 120, startedAt: 200 }; diff --git a/src/components/Executions/workflowExecutionQueries.ts b/src/components/Executions/workflowExecutionQueries.ts new file mode 100644 index 000000000..9310cc6cf --- /dev/null +++ b/src/components/Executions/workflowExecutionQueries.ts @@ -0,0 +1,36 @@ +import { QueryType } from 'components/data/queries'; +import { createPaginationQuery } from 'components/data/queryUtils'; +import { InfiniteQueryInput, InfiniteQueryPage } from 'components/data/types'; +import { + DomainIdentifierScope, + Execution, + listExecutions, + RequestConfig +} from 'models'; +import { InfiniteData } from 'react-query'; + +export type ExecutionListQueryData = InfiniteData>; + +/** A query for fetching a list of workflow executions belonging to a project/domain */ +export function makeWorkflowExecutionListQuery( + { domain, project }: DomainIdentifierScope, + config?: RequestConfig +): InfiniteQueryInput { + return createPaginationQuery({ + queryKey: [ + QueryType.WorkflowExecutionList, + { domain, project }, + config + ], + queryFn: async ({ pageParam }) => { + const finalConfig = pageParam + ? { ...config, token: pageParam } + : config; + const { entities: data, token } = await listExecutions( + { domain, project }, + finalConfig + ); + return { data, token }; + }, + }); +} diff --git a/src/components/Navigation/ProjectNavigation.tsx b/src/components/Navigation/ProjectNavigation.tsx index 854491265..fe44ae2d3 100644 --- a/src/components/Navigation/ProjectNavigation.tsx +++ b/src/components/Navigation/ProjectNavigation.tsx @@ -3,6 +3,7 @@ import { SvgIconProps } from '@material-ui/core/SvgIcon'; import ChevronRight from '@material-ui/icons/ChevronRight'; import DeviceHub from '@material-ui/icons/DeviceHub'; import LinearScale from '@material-ui/icons/LinearScale'; +import TrendingFlat from '@material-ui/icons/TrendingFlat'; import * as classnames from 'classnames'; import { withRouteParams } from 'components/common'; import { useCommonStyles } from 'components/common/styles'; @@ -103,6 +104,23 @@ const ProjectNavigationImpl: React.FC = ({ domainId ), text: 'Tasks' + }, + { + icon: TrendingFlat, + isActive: (match, location) => { + const finalMatch = match + ? match + : matchPath(location.pathname, { + path: Routes.ProjectExecutions.path, + exact: false + }); + return !!finalMatch; + }, + path: Routes.ProjectDetails.sections.executions.makeUrl( + project.value.id, + domainId + ), + text: 'Executions' } ]; diff --git a/src/components/Project/ProjectDetails.tsx b/src/components/Project/ProjectDetails.tsx index 04716c16d..9b5876b8e 100644 --- a/src/components/Project/ProjectDetails.tsx +++ b/src/components/Project/ProjectDetails.tsx @@ -6,6 +6,7 @@ import { Project } from 'models'; import * as React from 'react'; import { Redirect, Route, Switch } from 'react-router'; import { Routes } from 'routes'; +import { ProjectExecutions } from './ProjectExecutions'; import { ProjectTasks } from './ProjectTasks'; import { ProjectWorkflows } from './ProjectWorkflows'; @@ -24,13 +25,14 @@ export interface ProjectDetailsRouteParams { export type ProjectDetailsProps = ProjectDetailsRouteParams; const entityTypeToComponent = { + executions: ProjectExecutions, tasks: ProjectTasks, workflows: ProjectWorkflows }; const ProjectEntitiesByDomain: React.FC<{ project: Project; - entityType: 'tasks' | 'workflows'; + entityType: 'executions' | 'tasks' | 'workflows'; }> = ({ entityType, project }) => { const styles = useStyles(); const { params, setQueryState } = useQueryState<{ domain: string }>(); @@ -38,7 +40,7 @@ const ProjectEntitiesByDomain: React.FC<{ throw new Error('No domains exist for this project'); } const domainId = params.domain || project.domains[0].id; - const handleTabChange = (event: React.ChangeEvent<{}>, tabId: string) => + const handleTabChange = (_event: React.ChangeEvent, tabId: string) => setQueryState({ domain: tabId }); @@ -64,6 +66,10 @@ const ProjectEntitiesByDomain: React.FC<{ ); }; +const ProjectExecutionsByDomain: React.FC<{ project: Project}> = ({ + project +}) => ; + const ProjectWorkflowsByDomain: React.FC<{ project: Project }> = ({ project }) => ; @@ -90,6 +96,9 @@ export const ProjectDetailsContainer: React.FC = ({ + + + ({ + container: { + display: 'flex', + flex: '1 1 auto', + flexDirection: 'column' + } +})); +export interface ProjectExecutionsProps { projectId: string; domainId: string; } -/** The tab/page content for viewing a project's executions */ -export const ProjectExecutionsContainer: React.FC = ({ - projectId: project, - domainId: domain +/** A listing of all executions across a project/domain combo */ +export const ProjectExecutions: React.FC = ({ + domainId: domain, + projectId: project }) => { + const styles = useStyles(); + const filtersState = useWorkflowExecutionFiltersState(); const sort = { key: executionSortFields.createdAt, direction: SortDirection.DESCENDING }; - const executions = useWorkflowExecutions({ domain, project }, { sort }); + + const config = { + sort, + filter: filtersState.appliedFilters + }; + + const tableKey = `executions_${project}_${domain}`; + + const query = useInfiniteQuery({ + ...makeWorkflowExecutionListQuery({ domain, project }, config) + }); + + const executions = React.useMemo( + () => + query.data?.pages + ? query.data.pages.reduce( + (acc, { data }) => acc.concat(data), + [] + ) + : [], + [query.data?.pages] + ); + + const renderTable = () => { + const props: WorkflowExecutionsTableProps = { + fetch: () => query.fetchNextPage(), + value: executions, + lastError: query.error, + moreItemsAvailable: !!query.hasNextPage, + showWorkflowName: true, + state: State.from( + query.isLoading ? fetchStates.LOADING : fetchStates.LOADED + ) + }; + return ; + }; return ( - <> - - - - - +
+ + {renderTable} +
); }; - -export const ProjectExecutions = withRouteParams( - ProjectExecutionsContainer -); diff --git a/src/components/data/queries.ts b/src/components/data/queries.ts index 6016db0c2..a6edb7fdd 100644 --- a/src/components/data/queries.ts +++ b/src/components/data/queries.ts @@ -8,5 +8,6 @@ export enum QueryType { TaskExecutionChildList = 'taskExecutionChildList', TaskTemplate = 'taskTemplate', Workflow = 'workflow', - WorkflowExecution = 'workflowExecution' + WorkflowExecution = 'workflowExecution', + WorkflowExecutionList = 'workflowExecutionList' } diff --git a/src/components/data/queryUtils.ts b/src/components/data/queryUtils.ts index 4bf82c34a..71438b12c 100644 --- a/src/components/data/queryUtils.ts +++ b/src/components/data/queryUtils.ts @@ -1,5 +1,6 @@ import { log } from 'common/log'; import { QueryClient, QueryFunction, QueryKey } from 'react-query'; +import { InfiniteQueryInput, InfiniteQueryPage } from './types'; const defaultRefetchInterval = 1000; const defaultTimeout = 30000; @@ -49,3 +50,11 @@ export async function waitForQueryState({ }); return Promise.race([queryWaitPromise, timeoutPromise]); } + +function getNextPageParam({ token }: InfiniteQueryPage) { + return token != null && token.length > 0 ? token : undefined; +} + +export function createPaginationQuery(queryOptions: InfiniteQueryInput) { + return { ...queryOptions, getNextPageParam }; +} diff --git a/src/components/data/types.ts b/src/components/data/types.ts index 0e24e1cf1..e64360ceb 100644 --- a/src/components/data/types.ts +++ b/src/components/data/types.ts @@ -1,4 +1,4 @@ -import { QueryObserverOptions } from 'react-query'; +import { InfiniteQueryObserverOptions, QueryObserverOptions } from 'react-query'; import { QueryType } from './queries'; type QueryKeyArray = [QueryType, ...unknown[]]; @@ -6,3 +6,13 @@ export interface QueryInput extends QueryObserverOptions { queryKey: QueryKeyArray; queryFn: QueryObserverOptions['queryFn']; } + +export interface InfiniteQueryInput extends InfiniteQueryObserverOptions, Error> { + queryKey: QueryKeyArray; + queryFn: InfiniteQueryObserverOptions, Error>['queryFn']; +} + +export interface InfiniteQueryPage { + data: T[]; + token?: string; +} diff --git a/src/routes/routes.ts b/src/routes/routes.ts index d05ffd8e5..8a94778c3 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -31,6 +31,14 @@ export class Routes { makeProjectBoundPath(project, section ? `/${section}` : ''), path: projectBasePath, sections: { + executions: { + makeUrl: (project: string, domain?: string) => + makeProjectBoundPath( + project, + `/executions${domain ? `?domain=${domain}` : ''}` + ), + path: `${projectBasePath}/executions` + }, tasks: { makeUrl: (project: string, domain?: string) => makeProjectBoundPath( From e3daccfb449e1b61be19fd23250b8fb5b208be31 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Thu, 7 Jan 2021 14:24:13 -0800 Subject: [PATCH 2/7] refactor: make table compatible with refresh logic from queryies --- src/components/Entities/EntityExecutions.tsx | 3 +- .../WorkflowExecutionsTable.stories.tsx | 2 +- src/components/Project/ProjectExecutions.tsx | 48 ++++++++++--------- src/components/Project/ProjectLaunchPlans.tsx | 10 ++-- .../Project/ProjectRecentExecutions.tsx | 2 + src/components/Project/ProjectSchedules.tsx | 10 ++-- src/components/Tables/DataList.tsx | 4 +- src/components/Tables/DataTable.tsx | 4 +- src/components/Tables/LoadMoreRowContent.tsx | 10 ++-- src/components/common/types.ts | 3 +- 10 files changed, 52 insertions(+), 44 deletions(-) diff --git a/src/components/Entities/EntityExecutions.tsx b/src/components/Entities/EntityExecutions.tsx index 8c3b1c895..63710781b 100644 --- a/src/components/Entities/EntityExecutions.tsx +++ b/src/components/Entities/EntityExecutions.tsx @@ -6,6 +6,7 @@ import { ExecutionFilters } from 'components/Executions/ExecutionFilters'; import { useWorkflowExecutionFiltersState } from 'components/Executions/filters/useExecutionFiltersState'; import { WorkflowExecutionsTable as ExecutionsTable } from 'components/Executions/Tables/WorkflowExecutionsTable'; import { useWorkflowExecutions as useExecutions } from 'components/hooks'; +import { isLoadingState } from 'components/hooks/fetchMachine'; import { ResourceIdentifier } from 'models'; import { SortDirection } from 'models/AdminEntity'; import { executionSortFields } from 'models/Execution'; @@ -58,7 +59,7 @@ export const EntityExecutions: React.FC = ({ id }) => { - + ); diff --git a/src/components/Executions/Tables/__stories__/WorkflowExecutionsTable.stories.tsx b/src/components/Executions/Tables/__stories__/WorkflowExecutionsTable.stories.tsx index 5ea6c4abb..dac0b96cf 100644 --- a/src/components/Executions/Tables/__stories__/WorkflowExecutionsTable.stories.tsx +++ b/src/components/Executions/Tables/__stories__/WorkflowExecutionsTable.stories.tsx @@ -25,7 +25,7 @@ const fetchAction = action('fetch'); const props: WorkflowExecutionsTableProps = { value: createMockWorkflowExecutionsListResponse(10).executions, lastError: null, - state: State.from(fetchStates.LOADED), + isFetching: false, moreItemsAvailable: false, fetch: () => Promise.resolve(() => fetchAction() as unknown) }; diff --git a/src/components/Project/ProjectExecutions.tsx b/src/components/Project/ProjectExecutions.tsx index b8c5c51c4..d9799986f 100644 --- a/src/components/Project/ProjectExecutions.tsx +++ b/src/components/Project/ProjectExecutions.tsx @@ -1,17 +1,13 @@ -import { WaitForQuery } from 'components/common/WaitForQuery'; import { ExecutionFilters } from 'components/Executions/ExecutionFilters'; import { useWorkflowExecutionFiltersState } from 'components/Executions/filters/useExecutionFiltersState'; -import { - WorkflowExecutionsTable, - WorkflowExecutionsTableProps -} from 'components/Executions/Tables/WorkflowExecutionsTable'; +import { WorkflowExecutionsTable } from 'components/Executions/Tables/WorkflowExecutionsTable'; import { makeWorkflowExecutionListQuery } from 'components/Executions/workflowExecutionQueries'; -import { fetchStates } from 'components/hooks'; import { Execution, executionSortFields, SortDirection } from 'models'; import * as React from 'react'; import { useInfiniteQuery } from 'react-query'; -import { State } from 'xstate'; import { makeStyles } from '@material-ui/core/styles'; +import { DataError } from 'components/Errors/DataError'; +import { ErrorBoundary, LargeLoadingSpinner } from 'components/common'; const useStyles = makeStyles(() => ({ container: { @@ -25,7 +21,7 @@ export interface ProjectExecutionsProps { domainId: string; } -/** A listing of all executions across a project/domain combo */ +/** A listing of all executions across a project/domain combination. */ export const ProjectExecutions: React.FC = ({ domainId: domain, projectId: project @@ -59,24 +55,32 @@ export const ProjectExecutions: React.FC = ({ [query.data?.pages] ); - const renderTable = () => { - const props: WorkflowExecutionsTableProps = { - fetch: () => query.fetchNextPage(), - value: executions, - lastError: query.error, - moreItemsAvailable: !!query.hasNextPage, - showWorkflowName: true, - state: State.from( - query.isLoading ? fetchStates.LOADING : fetchStates.LOADED - ) - }; - return ; - }; + const fetch = React.useCallback(() => query.fetchNextPage(), [query]); + + const content = query.isLoadingError ? ( + + ) : query.isLoading ? ( + + ) : ( + + ); return (
- {renderTable} + {content}
); }; diff --git a/src/components/Project/ProjectLaunchPlans.tsx b/src/components/Project/ProjectLaunchPlans.tsx index e9acbe8fb..68d735c9c 100644 --- a/src/components/Project/ProjectLaunchPlans.tsx +++ b/src/components/Project/ProjectLaunchPlans.tsx @@ -1,11 +1,10 @@ -import * as React from 'react'; - import { SectionHeader, WaitForData, withRouteParams } from 'components/common'; import { useLaunchPlans } from 'components/hooks'; +import { isLoadingState } from 'components/hooks/fetchMachine'; import { LaunchPlansTable } from 'components/Launch/LaunchPlansTable'; - import { SortDirection } from 'models/AdminEntity'; import { launchSortFields } from 'models/Launch'; +import * as React from 'react'; export interface ProjectLaunchPlansRouteParams { projectId: string; @@ -31,7 +30,10 @@ export const ProjectLaunchPlansContainer: React.FC - + ); diff --git a/src/components/Project/ProjectRecentExecutions.tsx b/src/components/Project/ProjectRecentExecutions.tsx index 00cd90340..56010743d 100644 --- a/src/components/Project/ProjectRecentExecutions.tsx +++ b/src/components/Project/ProjectRecentExecutions.tsx @@ -2,6 +2,7 @@ import { makeStyles, Theme } from '@material-ui/core/styles'; import { SectionHeader, WaitForData } from 'components/common'; import { WorkflowExecutionsTable } from 'components/Executions/Tables/WorkflowExecutionsTable'; import { useWorkflowExecutions } from 'components/hooks'; +import { isLoadingState } from 'components/hooks/fetchMachine'; import { SortDirection } from 'models/AdminEntity'; import { executionSortFields } from 'models/Execution'; import * as React from 'react'; @@ -44,6 +45,7 @@ export const ProjectRecentExecutions: React.FC = ( = <> - + ); diff --git a/src/components/Tables/DataList.tsx b/src/components/Tables/DataList.tsx index c03766a75..1474ae5fb 100644 --- a/src/components/Tables/DataList.tsx +++ b/src/components/Tables/DataList.tsx @@ -73,7 +73,7 @@ const DataListImplComponent: React.RefForwardingComponent< height, value: items, lastError, - state, + isFetching, moreItemsAvailable, onScrollbarPresenceChange, noRowsContent: NoRowsContent = 'No items found.', @@ -145,7 +145,7 @@ const DataListImplComponent: React.RefForwardingComponent< content = ( diff --git a/src/components/Tables/DataTable.tsx b/src/components/Tables/DataTable.tsx index 45188095d..dca6d7685 100644 --- a/src/components/Tables/DataTable.tsx +++ b/src/components/Tables/DataTable.tsx @@ -127,7 +127,7 @@ export const DataTableImpl: React.FC = props => { height, value: items, lastError, - state, + isFetching, moreItemsAvailable, rowContentRenderer = defaultTableRowRenderer, width @@ -149,7 +149,7 @@ export const DataTableImpl: React.FC = props => { diff --git a/src/components/Tables/LoadMoreRowContent.tsx b/src/components/Tables/LoadMoreRowContent.tsx index 07152db8e..6e4e08c6c 100644 --- a/src/components/Tables/LoadMoreRowContent.tsx +++ b/src/components/Tables/LoadMoreRowContent.tsx @@ -1,8 +1,6 @@ import { Button } from '@material-ui/core'; import { makeStyles, Theme } from '@material-ui/core/styles'; import { useCommonStyles } from 'components/common/styles'; -import { isLoadingState } from 'components/hooks/fetchMachine'; -import { FetchableState } from 'components/hooks/types'; import * as React from 'react'; import { ButtonCircularProgress } from '../common'; import { loadMoreRowGridHeight } from './constants'; @@ -25,7 +23,7 @@ export const useStyles = makeStyles((theme: Theme) => ({ export interface LoadMoreRowContentProps { className?: string; lastError: string | Error | null; - state: FetchableState; + isFetching: boolean; style?: any; loadMoreRows: () => void; } @@ -36,12 +34,12 @@ export interface LoadMoreRowContentProps { export const LoadMoreRowContent: React.FC = props => { const commonStyles = useCommonStyles(); const styles = useStyles(); - const { loadMoreRows, lastError, state, style } = props; + const { loadMoreRows, lastError, isFetching, style } = props; const button = ( - ); const errorContent = lastError ? ( diff --git a/src/components/common/types.ts b/src/components/common/types.ts index f21800754..2dc09c70e 100644 --- a/src/components/common/types.ts +++ b/src/components/common/types.ts @@ -1,4 +1,3 @@ -import { FetchableState } from 'components/hooks/types'; import { ScrollbarPresenceParams } from 'react-virtualized'; export interface ListProps { @@ -7,7 +6,7 @@ export interface ListProps { height?: number; value: T[]; lastError: string | Error | null; - state: FetchableState; + isFetching: boolean; moreItemsAvailable: boolean; onScrollbarPresenceChange?: (params: ScrollbarPresenceParams) => any; width?: number; From 39edb10a8caf6158a010c310e7c58c836d845691 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Thu, 7 Jan 2021 14:34:52 -0800 Subject: [PATCH 3/7] fix: rendering error when changing filters --- .../__stories__/WorkflowExecutionsTable.stories.tsx | 2 -- src/components/Project/ProjectExecutions.tsx | 13 ++++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/Executions/Tables/__stories__/WorkflowExecutionsTable.stories.tsx b/src/components/Executions/Tables/__stories__/WorkflowExecutionsTable.stories.tsx index dac0b96cf..395e85ace 100644 --- a/src/components/Executions/Tables/__stories__/WorkflowExecutionsTable.stories.tsx +++ b/src/components/Executions/Tables/__stories__/WorkflowExecutionsTable.stories.tsx @@ -1,10 +1,8 @@ import { makeStyles, Theme } from '@material-ui/core/styles'; import { action } from '@storybook/addon-actions'; import { storiesOf } from '@storybook/react'; -import { fetchStates } from 'components/hooks/types'; import { createMockWorkflowExecutionsListResponse } from 'models/Execution/__mocks__/mockWorkflowExecutionsData'; import * as React from 'react'; -import { State } from 'xstate'; import { WorkflowExecutionsTable, WorkflowExecutionsTableProps diff --git a/src/components/Project/ProjectExecutions.tsx b/src/components/Project/ProjectExecutions.tsx index d9799986f..6bd7cee5e 100644 --- a/src/components/Project/ProjectExecutions.tsx +++ b/src/components/Project/ProjectExecutions.tsx @@ -8,6 +8,7 @@ import { useInfiniteQuery } from 'react-query'; import { makeStyles } from '@material-ui/core/styles'; import { DataError } from 'components/Errors/DataError'; import { ErrorBoundary, LargeLoadingSpinner } from 'components/common'; +import { getCacheKey } from 'components/Cache'; const useStyles = makeStyles(() => ({ container: { @@ -38,7 +39,17 @@ export const ProjectExecutions: React.FC = ({ filter: filtersState.appliedFilters }; - const tableKey = `executions_${project}_${domain}`; + // Remount the table whenever we change project/domain/filters to ensure + // things are virtualized correctly. + const tableKey = React.useMemo( + () => + getCacheKey({ + domain, + project, + filters: filtersState.appliedFilters + }), + [domain, project, filtersState.appliedFilters] + ); const query = useInfiniteQuery({ ...makeWorkflowExecutionListQuery({ domain, project }, config) From eeee4a11b31cc04f1ebf0b66a995219898c18575 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Thu, 7 Jan 2021 14:48:17 -0800 Subject: [PATCH 4/7] chore: docs --- src/components/Project/ProjectExecutions.tsx | 2 ++ src/components/data/queryUtils.ts | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/components/Project/ProjectExecutions.tsx b/src/components/Project/ProjectExecutions.tsx index 6bd7cee5e..ea18fdb97 100644 --- a/src/components/Project/ProjectExecutions.tsx +++ b/src/components/Project/ProjectExecutions.tsx @@ -55,6 +55,8 @@ export const ProjectExecutions: React.FC = ({ ...makeWorkflowExecutionListQuery({ domain, project }, config) }); + // useInfiniteQuery returns pages of items, but the table would like a single + // flat list. const executions = React.useMemo( () => query.data?.pages diff --git a/src/components/data/queryUtils.ts b/src/components/data/queryUtils.ts index 71438b12c..18dc3cee9 100644 --- a/src/components/data/queryUtils.ts +++ b/src/components/data/queryUtils.ts @@ -52,9 +52,14 @@ export async function waitForQueryState({ } function getNextPageParam({ token }: InfiniteQueryPage) { + // An empty token will cause pagination code to think there are more results. + // Only return a defined value if it is a non-zero-length string. return token != null && token.length > 0 ? token : undefined; } +/** Composes a `queryOptions` object with generic options which make our API responses + * compatible with `useInfiniteQuery` + */ export function createPaginationQuery(queryOptions: InfiniteQueryInput) { return { ...queryOptions, getNextPageParam }; } From 01f29b39771eca2ba4fa2071be6800a399af7459 Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 8 Jan 2021 13:44:13 -0800 Subject: [PATCH 5/7] test: adding test for createPaginationQuery --- .../useTerminateExecutionState.ts | 2 +- src/components/Executions/nodeExecutionQueries.ts | 3 +-- src/components/Executions/taskExecutionQueries.ts | 3 +-- .../Executions/useNodeExecutionDetails.ts | 2 +- src/components/Executions/useWorkflowExecution.ts | 3 +-- .../Executions/workflowExecutionQueries.ts | 3 +-- src/components/Task/taskQueries.ts | 3 +-- src/components/Workflow/workflowQueries.ts | 3 +-- src/components/data/queries.ts | 13 ------------- src/components/data/test/queryUtils.test.ts | 15 +++++++++++++++ src/components/data/types.ts | 15 ++++++++++++++- 11 files changed, 37 insertions(+), 28 deletions(-) delete mode 100644 src/components/data/queries.ts create mode 100644 src/components/data/test/queryUtils.test.ts diff --git a/src/components/Executions/TerminateExecution/useTerminateExecutionState.ts b/src/components/Executions/TerminateExecution/useTerminateExecutionState.ts index 7effa42ce..389476530 100644 --- a/src/components/Executions/TerminateExecution/useTerminateExecutionState.ts +++ b/src/components/Executions/TerminateExecution/useTerminateExecutionState.ts @@ -1,5 +1,5 @@ import { useAPIContext } from 'components/data/apiContext'; -import { QueryType } from 'components/data/queries'; +import { QueryType } from 'components/data/types'; import { waitForQueryState } from 'components/data/queryUtils'; import { Execution } from 'models'; import { useContext, useState } from 'react'; diff --git a/src/components/Executions/nodeExecutionQueries.ts b/src/components/Executions/nodeExecutionQueries.ts index 06e718f0e..94109a3a6 100644 --- a/src/components/Executions/nodeExecutionQueries.ts +++ b/src/components/Executions/nodeExecutionQueries.ts @@ -1,5 +1,4 @@ -import { QueryType } from 'components/data/queries'; -import { QueryInput } from 'components/data/types'; +import { QueryInput, QueryType } from 'components/data/types'; import { useConditionalQuery } from 'components/hooks/useConditionalQuery'; import { isEqual } from 'lodash'; import { diff --git a/src/components/Executions/taskExecutionQueries.ts b/src/components/Executions/taskExecutionQueries.ts index 869e26dd9..a6e0e16fa 100644 --- a/src/components/Executions/taskExecutionQueries.ts +++ b/src/components/Executions/taskExecutionQueries.ts @@ -1,5 +1,4 @@ -import { QueryType } from 'components/data/queries'; -import { QueryInput } from 'components/data/types'; +import { QueryInput, QueryType } from 'components/data/types'; import { getTaskExecution, listTaskExecutions, diff --git a/src/components/Executions/useNodeExecutionDetails.ts b/src/components/Executions/useNodeExecutionDetails.ts index 534847e67..42706be5c 100644 --- a/src/components/Executions/useNodeExecutionDetails.ts +++ b/src/components/Executions/useNodeExecutionDetails.ts @@ -1,5 +1,5 @@ import { log } from 'common/log'; -import { QueryType } from 'components/data/queries'; +import { QueryType } from 'components/data/types'; import { fetchTaskTemplate } from 'components/Task/taskQueries'; import { fetchWorkflow } from 'components/Workflow/workflowQueries'; import { diff --git a/src/components/Executions/useWorkflowExecution.ts b/src/components/Executions/useWorkflowExecution.ts index 8a2d9a116..88c10adc8 100644 --- a/src/components/Executions/useWorkflowExecution.ts +++ b/src/components/Executions/useWorkflowExecution.ts @@ -1,6 +1,5 @@ import { APIContextValue, useAPIContext } from 'components/data/apiContext'; -import { QueryType } from 'components/data/queries'; -import { QueryInput } from 'components/data/types'; +import { QueryInput, QueryType } from 'components/data/types'; import { useConditionalQuery } from 'components/hooks/useConditionalQuery'; import { maxBlobDownloadSizeBytes } from 'components/Literals/constants'; import { diff --git a/src/components/Executions/workflowExecutionQueries.ts b/src/components/Executions/workflowExecutionQueries.ts index 9310cc6cf..57d43ee0b 100644 --- a/src/components/Executions/workflowExecutionQueries.ts +++ b/src/components/Executions/workflowExecutionQueries.ts @@ -1,6 +1,5 @@ -import { QueryType } from 'components/data/queries'; import { createPaginationQuery } from 'components/data/queryUtils'; -import { InfiniteQueryInput, InfiniteQueryPage } from 'components/data/types'; +import { InfiniteQueryInput, InfiniteQueryPage, QueryType } from 'components/data/types'; import { DomainIdentifierScope, Execution, diff --git a/src/components/Task/taskQueries.ts b/src/components/Task/taskQueries.ts index 6448b3ef4..0f234a27e 100644 --- a/src/components/Task/taskQueries.ts +++ b/src/components/Task/taskQueries.ts @@ -1,5 +1,4 @@ -import { QueryType } from 'components/data/queries'; -import { QueryInput } from 'components/data/types'; +import { QueryInput, QueryType } from 'components/data/types'; import { getTask, Identifier, TaskTemplate } from 'models'; import { QueryClient } from 'react-query'; diff --git a/src/components/Workflow/workflowQueries.ts b/src/components/Workflow/workflowQueries.ts index e5d935d54..484f39a2b 100644 --- a/src/components/Workflow/workflowQueries.ts +++ b/src/components/Workflow/workflowQueries.ts @@ -1,5 +1,4 @@ -import { QueryType } from 'components/data/queries'; -import { QueryInput } from 'components/data/types'; +import { QueryInput, QueryType } from 'components/data/types'; import { extractTaskTemplates } from 'components/hooks/utils'; import { getWorkflow, Workflow, WorkflowId } from 'models'; import { QueryClient } from 'react-query'; diff --git a/src/components/data/queries.ts b/src/components/data/queries.ts deleted file mode 100644 index a6edb7fdd..000000000 --- a/src/components/data/queries.ts +++ /dev/null @@ -1,13 +0,0 @@ -export enum QueryType { - NodeExecutionDetails = 'NodeExecutionDetails', - NodeExecution = 'nodeExecution', - NodeExecutionList = 'nodeExecutionList', - NodeExecutionChildList = 'nodeExecutionChildList', - TaskExecution = 'taskExecution', - TaskExecutionList = 'taskExecutionList', - TaskExecutionChildList = 'taskExecutionChildList', - TaskTemplate = 'taskTemplate', - Workflow = 'workflow', - WorkflowExecution = 'workflowExecution', - WorkflowExecutionList = 'workflowExecutionList' -} diff --git a/src/components/data/test/queryUtils.test.ts b/src/components/data/test/queryUtils.test.ts new file mode 100644 index 000000000..d4858f835 --- /dev/null +++ b/src/components/data/test/queryUtils.test.ts @@ -0,0 +1,15 @@ +import { createPaginationQuery } from '../queryUtils'; +import { InfiniteQueryPage, QueryType } from '../types'; + +describe('queryUtils', () => { + describe('createPaginationQuery', () => { + it('should treat empty string token as undefined', () => { + const response: InfiniteQueryPage = { data: [], token: ''}; + const query = createPaginationQuery({ + queryKey: [QueryType.WorkflowExecutionList, 'test'], + queryFn: () => response + }); + expect(query.getNextPageParam(response)).toBeUndefined(); + }); + }); +}); diff --git a/src/components/data/types.ts b/src/components/data/types.ts index e64360ceb..4d8a34677 100644 --- a/src/components/data/types.ts +++ b/src/components/data/types.ts @@ -1,5 +1,18 @@ import { InfiniteQueryObserverOptions, QueryObserverOptions } from 'react-query'; -import { QueryType } from './queries'; + +export enum QueryType { + NodeExecutionDetails = 'NodeExecutionDetails', + NodeExecution = 'nodeExecution', + NodeExecutionList = 'nodeExecutionList', + NodeExecutionChildList = 'nodeExecutionChildList', + TaskExecution = 'taskExecution', + TaskExecutionList = 'taskExecutionList', + TaskExecutionChildList = 'taskExecutionChildList', + TaskTemplate = 'taskTemplate', + Workflow = 'workflow', + WorkflowExecution = 'workflowExecution', + WorkflowExecutionList = 'workflowExecutionList' +} type QueryKeyArray = [QueryType, ...unknown[]]; export interface QueryInput extends QueryObserverOptions { From c203f91cd269f850e15d572adac99c2af078178b Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 8 Jan 2021 17:09:57 -0800 Subject: [PATCH 6/7] refactor: split executions table to make project executions testable --- .../Tables/WorkflowExecutionRow.tsx | 67 +++++ .../Tables/WorkflowExecutionsTable.tsx | 236 +----------------- .../__mocks__/WorkflowExecutionsTable.tsx | 32 +++ src/components/Executions/Tables/styles.ts | 32 ++- src/components/Executions/Tables/types.ts | 7 +- .../Tables/useWorkflowExecutionTableState.ts | 14 ++ .../useWorkflowExecutionsTableColumns.tsx | 126 ++++++++++ src/components/Project/ProjectExecutions.tsx | 22 +- src/components/Project/constants.ts | 2 + .../Project/test/ProjectExecutions.test.tsx | 98 ++++++++ src/components/common/LoadingSpinner.tsx | 9 +- src/components/common/constants.ts | 1 + src/mocks/createAdminServer.ts | 70 +++++- 13 files changed, 465 insertions(+), 251 deletions(-) create mode 100644 src/components/Executions/Tables/WorkflowExecutionRow.tsx create mode 100644 src/components/Executions/Tables/__mocks__/WorkflowExecutionsTable.tsx create mode 100644 src/components/Executions/Tables/useWorkflowExecutionTableState.ts create mode 100644 src/components/Executions/Tables/useWorkflowExecutionsTableColumns.tsx create mode 100644 src/components/Project/test/ProjectExecutions.test.tsx diff --git a/src/components/Executions/Tables/WorkflowExecutionRow.tsx b/src/components/Executions/Tables/WorkflowExecutionRow.tsx new file mode 100644 index 000000000..44f3d97bc --- /dev/null +++ b/src/components/Executions/Tables/WorkflowExecutionRow.tsx @@ -0,0 +1,67 @@ +import { makeStyles, Theme } from "@material-ui/core"; +import classnames from "classnames"; +import { Execution } from "models"; +import * as React from "react"; +import { ListRowProps } from "react-virtualized"; +import { ExpandableExecutionError } from "./ExpandableExecutionError"; +import { useExecutionTableStyles } from "./styles"; +import { WorkflowExecutionColumnDefinition, WorkflowExecutionsTableState } from "./types"; + +const useStyles = makeStyles((theme: Theme) => ({ + row: { + paddingLeft: theme.spacing(2) + } +})); + +export interface WorkflowExecutionRowProps extends Partial { + columns: WorkflowExecutionColumnDefinition[]; + errorExpanded?: boolean; + execution: Execution; + onExpandCollapseError?(expanded: boolean): void; + state: WorkflowExecutionsTableState; +} + +export const WorkflowExecutionRow: React.FC = ({ + columns, + errorExpanded, + execution, + onExpandCollapseError, + state, + style +}) => { + const { error } = execution.closure; + const tableStyles = useExecutionTableStyles(); + const styles = useStyles(); + + return ( +
+
+ {columns.map(({ className, key: columnKey, cellRenderer }) => ( +
+ {cellRenderer({ + execution, + state + })} +
+ ))} +
+ {error ? ( + + ) : null} +
+ ); +}; diff --git a/src/components/Executions/Tables/WorkflowExecutionsTable.tsx b/src/components/Executions/Tables/WorkflowExecutionsTable.tsx index d01a09d14..868a313c3 100644 --- a/src/components/Executions/Tables/WorkflowExecutionsTable.tsx +++ b/src/components/Executions/Tables/WorkflowExecutionsTable.tsx @@ -1,239 +1,18 @@ -import { Typography } from '@material-ui/core'; -import Link from '@material-ui/core/Link'; -import { makeStyles, Theme } from '@material-ui/core/styles'; import * as classnames from 'classnames'; import { noExecutionsFoundString } from 'common/constants'; -import { - dateFromNow, - formatDateLocalTimezone, - formatDateUTC, - millisecondsToHMS -} from 'common/formatters'; -import { timestampToDate } from 'common/utils'; import { DataList, DataListRef } from 'components'; import { getCacheKey } from 'components/Cache'; import { ListProps } from 'components/common'; import { useCommonStyles } from 'components/common/styles'; import { Execution } from 'models'; -import { WorkflowExecutionPhase } from 'models/Execution/enums'; import * as React from 'react'; -import { ListRowProps, ListRowRenderer } from 'react-virtualized'; +import { ListRowRenderer } from 'react-virtualized'; import { ExecutionInputsOutputsModal } from '../ExecutionInputsOutputsModal'; -import { ExecutionStatusBadge } from '../ExecutionStatusBadge'; -import { getWorkflowExecutionTimingMS } from '../utils'; -import { workflowExecutionsTableColumnWidths } from './constants'; import { ExecutionsTableHeader } from './ExecutionsTableHeader'; -import { ExpandableExecutionError } from './ExpandableExecutionError'; import { useExecutionTableStyles } from './styles'; -import { ColumnDefinition } from './types'; -import { WorkflowExecutionLink } from './WorkflowExecutionLink'; - -const useStyles = makeStyles((theme: Theme) => ({ - cellName: { - paddingLeft: theme.spacing(1) - }, - columnName: { - flexBasis: workflowExecutionsTableColumnWidths.name, - whiteSpace: 'normal' - }, - columnLastRun: { - flexBasis: workflowExecutionsTableColumnWidths.lastRun - }, - columnStatus: { - flexBasis: workflowExecutionsTableColumnWidths.phase - }, - columnStartedAt: { - flexBasis: workflowExecutionsTableColumnWidths.startedAt - }, - columnDuration: { - flexBasis: workflowExecutionsTableColumnWidths.duration, - textAlign: 'right' - }, - columnInputsOutputs: { - flexGrow: 1, - flexBasis: workflowExecutionsTableColumnWidths.inputsOutputs, - marginLeft: theme.spacing(2), - marginRight: theme.spacing(2), - textAlign: 'left' - }, - row: { - paddingLeft: theme.spacing(2) - } -})); - -function useWorkflowExecutionsTableState() { - const [ - selectedIOExecution, - setSelectedIOExecution - ] = React.useState(null); - return { - selectedIOExecution, - setSelectedIOExecution - }; -} - -interface WorkflowExecutionCellRendererData { - execution: Execution; - state: ReturnType; -} -type WorkflowExecutionColumnDefinition = ColumnDefinition< - WorkflowExecutionCellRendererData ->; - -interface WorkflowExecutionColumnOptions { - showWorkflowName: boolean; -} -function generateColumns( - styles: ReturnType, - commonStyles: ReturnType, - { showWorkflowName }: WorkflowExecutionColumnOptions -): WorkflowExecutionColumnDefinition[] { - return [ - { - cellRenderer: ({ - execution: { - id, - closure: { startedAt, workflowId } - } - }) => ( - <> - - - {showWorkflowName - ? workflowId.name - : startedAt - ? `Last run ${dateFromNow( - timestampToDate(startedAt) - )}` - : ''} - - - ), - className: styles.columnName, - key: 'name', - label: 'execution id' - }, - { - cellRenderer: ({ - execution: { - closure: { phase = WorkflowExecutionPhase.UNDEFINED } - } - }) => , - className: styles.columnStatus, - key: 'phase', - label: 'status' - }, - { - cellRenderer: ({ execution: { closure } }) => { - const { startedAt } = closure; - if (!startedAt) { - return ''; - } - const startedAtDate = timestampToDate(startedAt); - return ( - <> - - {formatDateUTC(startedAtDate)} - - - {formatDateLocalTimezone(startedAtDate)} - - - ); - }, - className: styles.columnStartedAt, - key: 'startedAt', - label: 'start time' - }, - { - cellRenderer: ({ execution }) => { - const timing = getWorkflowExecutionTimingMS(execution); - return ( - - {timing !== null - ? millisecondsToHMS(timing.duration) - : ''} - - ); - }, - className: styles.columnDuration, - key: 'duration', - label: 'duration' - }, - { - cellRenderer: ({ execution, state }) => { - const onClick = () => state.setSelectedIOExecution(execution); - return ( - - View Inputs & Outputs - - ); - }, - className: styles.columnInputsOutputs, - key: 'inputsOutputs', - label: '' - } - ]; -} - -interface WorkflowExecutionRowProps extends ListRowProps { - columns: WorkflowExecutionColumnDefinition[]; - errorExpanded: boolean; - execution: Execution; - onExpandCollapseError(expanded: boolean): void; - state: ReturnType; -} - -const WorkflowExecutionRow: React.FC = ({ - columns, - errorExpanded, - execution, - onExpandCollapseError, - state, - style -}) => { - const { error } = execution.closure; - const tableStyles = useExecutionTableStyles(); - const styles = useStyles(); - - return ( -
-
- {columns.map(({ className, key: columnKey, cellRenderer }) => ( -
- {cellRenderer({ - execution, - state - })} -
- ))} -
- {error ? ( - - ) : null} -
- ); -}; +import { useWorkflowExecutionsTableColumns } from './useWorkflowExecutionsTableColumns'; +import { useWorkflowExecutionsTableState } from './useWorkflowExecutionTableState'; +import { WorkflowExecutionRow } from './WorkflowExecutionRow'; export interface WorkflowExecutionsTableProps extends ListProps { showWorkflowName?: boolean; @@ -249,7 +28,6 @@ export const WorkflowExecutionsTable: React.FC = p >({}); const state = useWorkflowExecutionsTableState(); const commonStyles = useCommonStyles(); - const styles = useStyles(); const tableStyles = useExecutionTableStyles(); const listRef = React.useRef(null); @@ -258,11 +36,7 @@ export const WorkflowExecutionsTable: React.FC = p setExpandedErrors({}); }, [executions]); - // Memoizing columns so they won't be re-generated unless the styles change - const columns = React.useMemo( - () => generateColumns(styles, commonStyles, { showWorkflowName }), - [styles, commonStyles, showWorkflowName] - ); + const columns = useWorkflowExecutionsTableColumns({ showWorkflowName }); const retry = () => props.fetch(); const onCloseIOModal = () => state.setSelectedIOExecution(null); diff --git a/src/components/Executions/Tables/__mocks__/WorkflowExecutionsTable.tsx b/src/components/Executions/Tables/__mocks__/WorkflowExecutionsTable.tsx new file mode 100644 index 000000000..4d02aa70a --- /dev/null +++ b/src/components/Executions/Tables/__mocks__/WorkflowExecutionsTable.tsx @@ -0,0 +1,32 @@ +import * as classnames from 'classnames'; +import { useCommonStyles } from 'components/common/styles'; +import * as React from 'react'; +import { ExecutionsTableHeader } from '../ExecutionsTableHeader'; +import { useExecutionTableStyles } from '../styles'; +import { useWorkflowExecutionsTableColumns } from '../useWorkflowExecutionsTableColumns'; +import { useWorkflowExecutionsTableState } from '../useWorkflowExecutionTableState'; +import { WorkflowExecutionRow } from '../WorkflowExecutionRow'; +import { WorkflowExecutionsTableProps } from '../WorkflowExecutionsTable'; + +/** Mocked, simpler version of WorkflowExecutionsTable which does not use a DataList since + * that will not work in a test environment. + */ +export const WorkflowExecutionsTable: React.FC = props => { + const { value: executions, showWorkflowName = false } = props; + const state = useWorkflowExecutionsTableState(); + const commonStyles = useCommonStyles(); + const tableStyles = useExecutionTableStyles(); + const columns = useWorkflowExecutionsTableColumns({ showWorkflowName }); + + return ( +
+ + {executions.map(execution => )} +
+ ); +}; diff --git a/src/components/Executions/Tables/styles.ts b/src/components/Executions/Tables/styles.ts index 7f376368d..e67b1e0e3 100644 --- a/src/components/Executions/Tables/styles.ts +++ b/src/components/Executions/Tables/styles.ts @@ -8,7 +8,7 @@ import { tableHeaderColor, tablePlaceholderColor } from 'components/Theme'; -import { nodeExecutionsTableColumnWidths } from './constants'; +import { nodeExecutionsTableColumnWidths, workflowExecutionsTableColumnWidths } from './constants'; export const selectedClassName = 'selected'; @@ -154,3 +154,33 @@ export const useColumnStyles = makeStyles((theme: Theme) => ({ fontWeight: 'bold' } })); + +export const useWorkflowExecutionsColumnStyles = makeStyles((theme: Theme) => ({ + cellName: { + paddingLeft: theme.spacing(1) + }, + columnName: { + flexBasis: workflowExecutionsTableColumnWidths.name, + whiteSpace: 'normal' + }, + columnLastRun: { + flexBasis: workflowExecutionsTableColumnWidths.lastRun + }, + columnStatus: { + flexBasis: workflowExecutionsTableColumnWidths.phase + }, + columnStartedAt: { + flexBasis: workflowExecutionsTableColumnWidths.startedAt + }, + columnDuration: { + flexBasis: workflowExecutionsTableColumnWidths.duration, + textAlign: 'right' + }, + columnInputsOutputs: { + flexGrow: 1, + flexBasis: workflowExecutionsTableColumnWidths.inputsOutputs, + marginLeft: theme.spacing(2), + marginRight: theme.spacing(2), + textAlign: 'left' + } +})); diff --git a/src/components/Executions/Tables/types.ts b/src/components/Executions/Tables/types.ts index 3d1e59bc1..ad21fd65f 100644 --- a/src/components/Executions/Tables/types.ts +++ b/src/components/Executions/Tables/types.ts @@ -4,6 +4,10 @@ import { NodeExecutionIdentifier } from 'models/Execution'; +export interface WorkflowExecutionsTableState { + selectedIOExecution: Execution | null; + setSelectedIOExecution(execution: Execution | null): void; +} export interface NodeExecutionsTableState { selectedExecution?: NodeExecutionIdentifier | null; setSelectedExecution: ( @@ -11,7 +15,6 @@ export interface NodeExecutionsTableState { ) => void; } -type LabelFn = () => JSX.Element; export interface ColumnDefinition { cellRenderer(data: CellRendererData): React.ReactNode; className?: string; @@ -29,7 +32,7 @@ export type NodeExecutionColumnDefinition = ColumnDefinition< export interface WorkflowExecutionCellRendererData { execution: Execution; - state: NodeExecutionsTableState; + state: WorkflowExecutionsTableState; } export type WorkflowExecutionColumnDefinition = ColumnDefinition< WorkflowExecutionCellRendererData diff --git a/src/components/Executions/Tables/useWorkflowExecutionTableState.ts b/src/components/Executions/Tables/useWorkflowExecutionTableState.ts new file mode 100644 index 000000000..461a797c9 --- /dev/null +++ b/src/components/Executions/Tables/useWorkflowExecutionTableState.ts @@ -0,0 +1,14 @@ +import { Execution } from 'models/Execution/types'; +import { useState } from 'react'; +import { WorkflowExecutionsTableState } from './types'; + +export function useWorkflowExecutionsTableState(): WorkflowExecutionsTableState { + const [ + selectedIOExecution, + setSelectedIOExecution + ] = useState(null); + return { + selectedIOExecution, + setSelectedIOExecution + }; +} diff --git a/src/components/Executions/Tables/useWorkflowExecutionsTableColumns.tsx b/src/components/Executions/Tables/useWorkflowExecutionsTableColumns.tsx new file mode 100644 index 000000000..2a53352c0 --- /dev/null +++ b/src/components/Executions/Tables/useWorkflowExecutionsTableColumns.tsx @@ -0,0 +1,126 @@ +import { Link, Typography } from '@material-ui/core'; +import { + dateFromNow, + formatDateLocalTimezone, + formatDateUTC, + millisecondsToHMS +} from 'common/formatters'; +import { timestampToDate } from 'common/utils'; +import { useCommonStyles } from 'components/common/styles'; +import { WorkflowExecutionPhase } from 'models/Execution/enums'; +import * as React from 'react'; +import { ExecutionStatusBadge, getWorkflowExecutionTimingMS } from '..'; +import { useWorkflowExecutionsColumnStyles } from './styles'; +import { WorkflowExecutionColumnDefinition } from './types'; +import { WorkflowExecutionLink } from './WorkflowExecutionLink'; + +interface WorkflowExecutionColumnOptions { + showWorkflowName: boolean; +} +export function useWorkflowExecutionsTableColumns({ + showWorkflowName +}: WorkflowExecutionColumnOptions): WorkflowExecutionColumnDefinition[] { + const styles = useWorkflowExecutionsColumnStyles(); + const commonStyles = useCommonStyles(); + return React.useMemo( + () => [ + { + cellRenderer: ({ + execution: { + id, + closure: { startedAt, workflowId } + } + }) => ( + <> + + + {showWorkflowName + ? workflowId.name + : startedAt + ? `Last run ${dateFromNow( + timestampToDate(startedAt) + )}` + : ''} + + + ), + className: styles.columnName, + key: 'name', + label: 'execution id' + }, + { + cellRenderer: ({ + execution: { + closure: { phase = WorkflowExecutionPhase.UNDEFINED } + } + }) => , + className: styles.columnStatus, + key: 'phase', + label: 'status' + }, + { + cellRenderer: ({ execution: { closure } }) => { + const { startedAt } = closure; + if (!startedAt) { + return ''; + } + const startedAtDate = timestampToDate(startedAt); + return ( + <> + + {formatDateUTC(startedAtDate)} + + + {formatDateLocalTimezone(startedAtDate)} + + + ); + }, + className: styles.columnStartedAt, + key: 'startedAt', + label: 'start time' + }, + { + cellRenderer: ({ execution }) => { + const timing = getWorkflowExecutionTimingMS(execution); + return ( + + {timing !== null + ? millisecondsToHMS(timing.duration) + : ''} + + ); + }, + className: styles.columnDuration, + key: 'duration', + label: 'duration' + }, + { + cellRenderer: ({ execution, state }) => { + const onClick = () => + state.setSelectedIOExecution(execution); + return ( + + View Inputs & Outputs + + ); + }, + className: styles.columnInputsOutputs, + key: 'inputsOutputs', + label: '' + } + ], + [styles, commonStyles, showWorkflowName] + ); +} diff --git a/src/components/Project/ProjectExecutions.tsx b/src/components/Project/ProjectExecutions.tsx index ea18fdb97..1b48e2571 100644 --- a/src/components/Project/ProjectExecutions.tsx +++ b/src/components/Project/ProjectExecutions.tsx @@ -1,3 +1,7 @@ +import { makeStyles } from '@material-ui/core/styles'; +import { getCacheKey } from 'components/Cache'; +import { ErrorBoundary, LargeLoadingSpinner } from 'components/common'; +import { DataError } from 'components/Errors/DataError'; import { ExecutionFilters } from 'components/Executions/ExecutionFilters'; import { useWorkflowExecutionFiltersState } from 'components/Executions/filters/useExecutionFiltersState'; import { WorkflowExecutionsTable } from 'components/Executions/Tables/WorkflowExecutionsTable'; @@ -5,10 +9,7 @@ import { makeWorkflowExecutionListQuery } from 'components/Executions/workflowEx import { Execution, executionSortFields, SortDirection } from 'models'; import * as React from 'react'; import { useInfiniteQuery } from 'react-query'; -import { makeStyles } from '@material-ui/core/styles'; -import { DataError } from 'components/Errors/DataError'; -import { ErrorBoundary, LargeLoadingSpinner } from 'components/common'; -import { getCacheKey } from 'components/Cache'; +import { failedToLoadExecutionsString } from './constants'; const useStyles = makeStyles(() => ({ container: { @@ -22,6 +23,11 @@ export interface ProjectExecutionsProps { domainId: string; } +const defaultSort = { + key: executionSortFields.createdAt, + direction: SortDirection.DESCENDING +}; + /** A listing of all executions across a project/domain combination. */ export const ProjectExecutions: React.FC = ({ domainId: domain, @@ -29,13 +35,9 @@ export const ProjectExecutions: React.FC = ({ }) => { const styles = useStyles(); const filtersState = useWorkflowExecutionFiltersState(); - const sort = { - key: executionSortFields.createdAt, - direction: SortDirection.DESCENDING - }; const config = { - sort, + sort: defaultSort, filter: filtersState.appliedFilters }; @@ -73,7 +75,7 @@ export const ProjectExecutions: React.FC = ({ const content = query.isLoadingError ? ( ) : query.isLoading ? ( diff --git a/src/components/Project/constants.ts b/src/components/Project/constants.ts index 30dde3cf0..b62cb7bab 100644 --- a/src/components/Project/constants.ts +++ b/src/components/Project/constants.ts @@ -1,2 +1,4 @@ /** Max number of executions to show in the "Recent Executions" list */ export const recentExecutionsLimit = 10; + +export const failedToLoadExecutionsString = 'Failed to load executions.'; diff --git a/src/components/Project/test/ProjectExecutions.test.tsx b/src/components/Project/test/ProjectExecutions.test.tsx new file mode 100644 index 000000000..ecd037e43 --- /dev/null +++ b/src/components/Project/test/ProjectExecutions.test.tsx @@ -0,0 +1,98 @@ +import { render, waitFor } from '@testing-library/react'; +import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; +import { oneFailedTaskWorkflow } from 'mocks/data/fixtures/oneFailedTaskWorkflow'; +import { insertFixture } from 'mocks/data/insertFixture'; +import { notFoundError, unexpectedError } from 'mocks/errors'; +import { mockServer } from 'mocks/server'; +import { + DomainIdentifierScope, + Execution, + executionSortFields, + SortDirection, + sortQueryKeys +} from 'models'; +import * as React from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { MemoryRouter } from 'react-router'; +import { createTestQueryClient, disableQueryLogger, enableQueryLogger } from 'test/utils'; +import { failedToLoadExecutionsString } from '../constants'; +import { ProjectExecutions } from '../ProjectExecutions'; + +jest.mock('components/Executions/Tables/WorkflowExecutionsTable'); + +const defaultQueryParams = { + [sortQueryKeys.direction]: SortDirection[SortDirection.DESCENDING], + [sortQueryKeys.key]: executionSortFields.createdAt +}; + +describe('ProjectExecutions', () => { + let basicPythonFixture: ReturnType; + let failedTaskFixture: ReturnType; + let executions: Execution[]; + let scope: DomainIdentifierScope; + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = createTestQueryClient(); + basicPythonFixture = basicPythonWorkflow.generate(); + failedTaskFixture = oneFailedTaskWorkflow.generate(); + insertFixture(mockServer, basicPythonFixture); + insertFixture(mockServer, failedTaskFixture); + executions = [ + basicPythonFixture.workflowExecutions.top.data, + failedTaskFixture.workflowExecutions.top.data + ]; + const { domain, project } = executions[0].id; + scope = { domain, project }; + mockServer.insertWorkflowExecutionList( + scope, + executions, + defaultQueryParams + ); + }); + + const renderView = () => + render( + + + , + { wrapper: MemoryRouter } + ); + + it('displays executions after successful load', async () => { + const { getByText } = renderView(); + await waitFor(() => expect(getByText(executions[0].id.name))); + }); + + it('shows name of corresponding workflow in row items', async () => { + const { getByText } = renderView(); + await waitFor(() => + expect(getByText(executions[0].closure.workflowId.name)) + ); + }); + + describe('when initial load fails', () => { + const errorMessage = 'Something went wrong.'; + // Disable react-query logger output to avoid a console.error + // when the request fails. + beforeEach(() => { + disableQueryLogger(); + mockServer.insertWorkflowExecutionList( + scope, + unexpectedError(errorMessage), + defaultQueryParams + ); + }); + afterEach(() => { + enableQueryLogger(); + }); + + it('shows error message', async () => { + const { getByText } = renderView(); + await waitFor(() => expect(getByText(failedToLoadExecutionsString))); + }); + }); +}); diff --git a/src/components/common/LoadingSpinner.tsx b/src/components/common/LoadingSpinner.tsx index 9644b6d86..c88f6c1ba 100644 --- a/src/components/common/LoadingSpinner.tsx +++ b/src/components/common/LoadingSpinner.tsx @@ -1,10 +1,9 @@ -import * as classnames from 'classnames'; -import * as React from 'react'; - import { CircularProgress } from '@material-ui/core'; - import { makeStyles } from '@material-ui/core/styles'; +import * as classnames from 'classnames'; import { useDelayedValue } from 'components/hooks/useDelayedValue'; +import * as React from 'react'; +import { loadingSpinnerDelayMs } from './constants'; const useStyles = makeStyles({ container: { @@ -33,7 +32,7 @@ export const LoadingSpinner: React.FC = ({ size = 'large' }) => { const styles = useStyles(); - const shouldRender = useDelayedValue(false, 1000, true); + const shouldRender = useDelayedValue(false, loadingSpinnerDelayMs, true); return shouldRender ? (
, resourceType: ResourceType @@ -82,7 +96,6 @@ function getItemKey(id: unknown) { return stableStringify(id); } - function getItem(store: Map, id: unknown): ItemType { const item = store.get(getItemKey(id)); if (!item) { @@ -199,6 +212,15 @@ export interface AdminServer { insertWorkflow(data: RequireIdField>): void; /** Inserts a single `Execution` record. */ insertWorkflowExecution(data: RequireIdField>): void; + /** Inserts a list of `Execution` records. + * Note: Does not insert single `Execution` records. Those must be inserted + * separately. + */ + insertWorkflowExecutionList( + scope: DomainIdentifierScope, + data: RequireIdField>[] | RequestError, + query?: Record + ): void; /** Debug utility which dumps the contents of the backing store. */ printEntities(): void; } @@ -217,6 +239,7 @@ enum EntityType { Workflow = 'workflow', Task = 'task', WorkflowExecution = 'workflowExecution', + WorkflowExecutionList = 'workflowExecutionList', NodeExecution = 'nodeExecution', NodeExecutionList = 'nodeExecutionList', TaskExecution = 'taskExecution', @@ -307,6 +330,30 @@ export function createAdminServer(): CreateAdminServerResult { responseEncoder: Admin.Projects }); + const getWorkflowExecutionListHandler = adminEntityHandler({ + path: '/executions/:project/:domain', + getDataForRequest: req => { + const { domain, project } = req.params; + const scope: DomainIdentifierScope = { domain, project }; + const data = getItem(entityMap, [ + EntityType.WorkflowExecutionList, + scope, + workflowExecutionListQueryParams(getQueryParams(req)) + ]); + return { + executions: data.map(executionId => + Admin.Execution.create( + getItem(entityMap, [ + EntityType.WorkflowExecution, + executionId + ]) + ) + ) + }; + }, + responseEncoder: Admin.ExecutionList + }); + const getWorkflowExecutionHandler = adminEntityHandler({ path: '/executions/:project/:domain/:name', getDataForRequest: req => { @@ -436,6 +483,7 @@ export function createAdminServer(): CreateAdminServerResult { getProjectListHandler, getWorkflowHandler, getTaskHandler, + getWorkflowExecutionListHandler, getWorkflowExecutionHandler, getNodeExecutionHandler, getNodeExecutionListHandler, @@ -473,6 +521,22 @@ export function createAdminServer(): CreateAdminServerResult { [EntityType.WorkflowExecution, execution.id], execution ), + insertWorkflowExecutionList: ( + scope, + data, + query: QueryParamsMap = {} + ) => + insertItem( + entityMap, + [ + EntityType.WorkflowExecutionList, + scope, + workflowExecutionListQueryParams(query) + ], + data instanceof RequestError + ? data + : data.map(({ id }) => id) + ), insertNodeExecution: execution => insertItem( entityMap, @@ -491,7 +555,9 @@ export function createAdminServer(): CreateAdminServerResult { parentExecutionId, nodeExecutionListQueryParams(query) ], - data instanceof RequestError ? data : data.map(({ id }) => id) + data instanceof RequestError + ? data + : data.map(({ id }) => id) ), insertTaskExecution: execution => insertItem( From d84390b65bdf0392c402e9119a4f08e1aec92d9c Mon Sep 17 00:00:00 2001 From: Randy Schott <1815175+schottra@users.noreply.github.com> Date: Fri, 8 Jan 2021 17:22:01 -0800 Subject: [PATCH 7/7] chore: docs --- src/components/Executions/Tables/WorkflowExecutionRow.tsx | 3 +++ src/components/Executions/Tables/styles.ts | 1 + .../Executions/Tables/useWorkflowExecutionsTableColumns.tsx | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/src/components/Executions/Tables/WorkflowExecutionRow.tsx b/src/components/Executions/Tables/WorkflowExecutionRow.tsx index 44f3d97bc..dc9cdfa37 100644 --- a/src/components/Executions/Tables/WorkflowExecutionRow.tsx +++ b/src/components/Executions/Tables/WorkflowExecutionRow.tsx @@ -21,6 +21,9 @@ export interface WorkflowExecutionRowProps extends Partial { state: WorkflowExecutionsTableState; } +/** Renders a single `Execution` record as a row. Designed to be used as a child + * of `WorkflowExecutionTable`. + */ export const WorkflowExecutionRow: React.FC = ({ columns, errorExpanded, diff --git a/src/components/Executions/Tables/styles.ts b/src/components/Executions/Tables/styles.ts index e67b1e0e3..026f941c1 100644 --- a/src/components/Executions/Tables/styles.ts +++ b/src/components/Executions/Tables/styles.ts @@ -155,6 +155,7 @@ export const useColumnStyles = makeStyles((theme: Theme) => ({ } })); +/** Style overrides specific to columns in `WorkflowExecutionsTable`. */ export const useWorkflowExecutionsColumnStyles = makeStyles((theme: Theme) => ({ cellName: { paddingLeft: theme.spacing(1) diff --git a/src/components/Executions/Tables/useWorkflowExecutionsTableColumns.tsx b/src/components/Executions/Tables/useWorkflowExecutionsTableColumns.tsx index 2a53352c0..e621bef89 100644 --- a/src/components/Executions/Tables/useWorkflowExecutionsTableColumns.tsx +++ b/src/components/Executions/Tables/useWorkflowExecutionsTableColumns.tsx @@ -17,6 +17,10 @@ import { WorkflowExecutionLink } from './WorkflowExecutionLink'; interface WorkflowExecutionColumnOptions { showWorkflowName: boolean; } +/** Returns a memoized list of column definitions to use when rendering a + * `WorkflowExecutionRow`. Memoization is based on common/column style objects + * and any fields in the incoming `WorkflowExecutionColumnOptions` object. + */ export function useWorkflowExecutionsTableColumns({ showWorkflowName }: WorkflowExecutionColumnOptions): WorkflowExecutionColumnDefinition[] {