diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx index 68d93fbd1..60fdac125 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx @@ -58,7 +58,7 @@ export const ExecutionNodeViews: React.FC = ({ executio const tabState = useTabState(tabs, defaultTab); const queryClient = useQueryClient(); const requestConfig = useContext(NodeExecutionsRequestConfigContext); - const [loading, setLoading] = useState(true); + const [nodeExecutionsLoading, setNodeExecutionsLoading] = useState(true); const { closure: { abortMetadata, workflowId }, @@ -82,9 +82,9 @@ export const ExecutionNodeViews: React.FC = ({ executio useEffect(() => { let isCurrent = true; - setLoading(true); async function fetchData(baseNodeExecutions, queryClient) { + setNodeExecutionsLoading(true); const newValue = await Promise.all( baseNodeExecutions.map(async (baseNodeExecution) => { const taskExecutions = await fetchTaskExecutionList(queryClient, baseNodeExecution.id); @@ -115,12 +115,16 @@ export const ExecutionNodeViews: React.FC = ({ executio if (isCurrent) { setNodeExecutionsWithResources(newValue); - setLoading(false); + setNodeExecutionsLoading(false); } } if (nodeExecutions.length > 0) { fetchData(nodeExecutions, queryClient); + } else { + if (isCurrent) { + setNodeExecutionsLoading(false); + } } return () => { isCurrent = false; @@ -146,19 +150,26 @@ export const ExecutionNodeViews: React.FC = ({ executio ); }; - const renderTab = (tabType) => ( - - {() => } - - ); - - if (loading) { - return ; - } + const renderTab = (tabType) => { + if (nodeExecutionsLoading) { + return ; + } + return ( + + {() => ( + + )} + + ); + }; return ( <> @@ -169,18 +180,20 @@ export const ExecutionNodeViews: React.FC = ({ executio - {nodeExecutions.length > 0 ? ( -
- {tabState.value === tabs.nodes.id && ( -
- -
- )} - - {() => renderTab(tabState.value)} - -
- ) : null} +
+ {tabState.value === tabs.nodes.id && ( +
+ +
+ )} + + {() => renderTab(tabState.value)} + +
diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTab.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTab.tsx index 5b40cd567..afa02226b 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTab.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTab.tsx @@ -5,6 +5,7 @@ import { Admin } from 'flyteidl'; import { Workflow } from 'models/Workflow/types'; import * as React from 'react'; import { useQuery, useQueryClient } from 'react-query'; +import { NodeExecution } from 'models/Execution/types'; import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; import { ScaleProvider } from './Timeline/scaleContext'; import { ExecutionTabContent } from './ExecutionTabContent'; @@ -12,10 +13,15 @@ import { ExecutionTabContent } from './ExecutionTabContent'; export interface ExecutionTabProps { tabType: string; abortMetadata?: Admin.IAbortMetadata; + filteredNodeExecutions: NodeExecution[]; } /** Contains the available ways to visualize the nodes of a WorkflowExecution */ -export const ExecutionTab: React.FC = ({ tabType, abortMetadata }) => { +export const ExecutionTab: React.FC = ({ + tabType, + abortMetadata, + filteredNodeExecutions, +}) => { const queryClient = useQueryClient(); const { workflowId } = useNodeExecutionContext(); const workflowQuery = useQuery(makeWorkflowQuery(queryClient, workflowId)); @@ -23,7 +29,13 @@ export const ExecutionTab: React.FC = ({ tabType, abortMetada return ( - {() => } + {() => ( + + )} ); diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx index c55f14ed7..2fd5a71a3 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx @@ -3,7 +3,7 @@ import { DetailsPanel } from 'components/common/DetailsPanel'; import { makeNodeExecutionDynamicWorkflowQuery } from 'components/Workflow/workflowQueries'; import { WorkflowGraph } from 'components/WorkflowGraph/WorkflowGraph'; import { TaskExecutionPhase } from 'models/Execution/enums'; -import { NodeExecutionIdentifier } from 'models/Execution/types'; +import { NodeExecution, NodeExecutionIdentifier } from 'models/Execution/types'; import { startNodeId, endNodeId } from 'models/Node/constants'; import { Admin } from 'flyteidl'; import * as React from 'react'; @@ -25,6 +25,7 @@ import { convertToPlainNodes, TimeZone } from './Timeline/helpers'; export interface ExecutionTabContentProps { tabType: string; abortMetadata?: Admin.IAbortMetadata; + filteredNodeExecutions: NodeExecution[]; } const useStyles = makeStyles(() => ({ @@ -43,6 +44,7 @@ const useStyles = makeStyles(() => ({ export const ExecutionTabContent: React.FC = ({ tabType, abortMetadata, + filteredNodeExecutions, }) => { const styles = useStyles(); const { compiledWorkflowClosure } = useNodeExecutionContext(); @@ -171,6 +173,7 @@ export const ExecutionTabContent: React.FC = ({ initialNodes={initialNodes} selectedExecution={selectedExecution} setSelectedExecution={onExecutionSelectionChanged} + filteredNodeExecutions={filteredNodeExecutions} /> ); default: diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx index 7deb282ed..d8a5a683f 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { filterLabels } from 'components/Executions/filters/constants'; import { nodeExecutionStatusFilters } from 'components/Executions/filters/statusFilters'; import { oneFailedTaskWorkflow } from 'mocks/data/fixtures/oneFailedTaskWorkflow'; @@ -21,6 +21,14 @@ jest.mock('chartjs-plugin-datalabels', () => ({ ChartDataLabels: null, })); +jest.mock('components/Executions/Tables/NodeExecutionRow', () => ({ + NodeExecutionRow: jest.fn(({ children, execution }) => ( +
+ {execution?.id?.nodeId} + {children} +
+ )), +})); // ExecutionNodeViews uses query params for NE list, so we must match them // for the list to be returned properly const baseQueryParams = { @@ -29,7 +37,7 @@ const baseQueryParams = { 'sort_by.key': 'created_at', }; -describe.skip('ExecutionNodeViews', () => { +describe('ExecutionNodeViews', () => { let queryClient: QueryClient; let execution: Execution; let fixture: ReturnType; @@ -64,35 +72,43 @@ describe.skip('ExecutionNodeViews', () => { const failedNodeName = nodeExecutions.failedNode.data.id.nodeId; const succeededNodeName = nodeExecutions.pythonNode.data.id.nodeId; - const { getByText, queryByText } = renderViews(); - const nodesTab = await waitFor(() => getByText(tabs.nodes.label)); - const graphTab = await waitFor(() => getByText(tabs.graph.label)); + const { getByText, queryByText, getByLabelText } = renderViews(); + + await waitFor(() => getByText(tabs.nodes.label)); + + const nodesTab = getByText(tabs.nodes.label); + const graphTab = getByText(tabs.graph.label); // Ensure we are on Nodes tab fireEvent.click(nodesTab); - await waitFor(() => getByText(succeededNodeName)); + await waitFor(() => queryByText(succeededNodeName)); const statusButton = await waitFor(() => getByText(filterLabels.status)); // Apply 'Failed' filter and wait for list to include only the failed item fireEvent.click(statusButton); const failedFilter = await waitFor(() => - screen.getByLabelText(nodeExecutionStatusFilters.failed.label), + getByLabelText(nodeExecutionStatusFilters.failed.label), ); // Wait for succeeded task to disappear and ensure failed task remains fireEvent.click(failedFilter); - await waitFor(() => queryByText(succeededNodeName) == null); - await waitFor(() => expect(getByText(failedNodeName)).toBeInTheDocument()); + await waitFor(() => queryByText(failedNodeName)); + + expect(queryByText(succeededNodeName)).not.toBeInTheDocument(); + expect(getByText(failedNodeName)).toBeInTheDocument(); // Switch to the Graph tab fireEvent.click(statusButton); fireEvent.click(graphTab); - await waitFor(() => queryByText(failedNodeName) == null); + await waitFor(() => queryByText(succeededNodeName)); + + expect(queryByText(succeededNodeName)).toBeInTheDocument(); // Switch back to Nodes Tab and verify filter still applied fireEvent.click(nodesTab); - await waitFor(() => getByText(failedNodeName)); - expect(queryByText(succeededNodeName)).toBeNull(); + await waitFor(() => queryByText(failedNodeName)); + expect(queryByText(succeededNodeName)).not.toBeInTheDocument(); + expect(queryByText(failedNodeName)).toBeInTheDocument(); }); }); diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionTabContent.test.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionTabContent.test.tsx new file mode 100644 index 000000000..36e257776 --- /dev/null +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionTabContent.test.tsx @@ -0,0 +1,92 @@ +import { render, waitFor } from '@testing-library/react'; +import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; +import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; +import { mockWorkflowId } from 'mocks/data/fixtures/types'; +import { insertFixture } from 'mocks/data/insertFixture'; +import { mockServer } from 'mocks/server'; +import * as React from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { createTestQueryClient } from 'test/utils'; +import { ExecutionTabContent } from '../ExecutionTabContent'; +import { tabs } from '../constants'; + +jest.mock('components/Workflow/workflowQueries'); +const { fetchWorkflow } = require('components/Workflow/workflowQueries'); + +jest.mock('components/common/DetailsPanel', () => ({ + DetailsPanel: jest.fn(({ children }) =>
{children}
), +})); + +jest.mock('components/Executions/Tables/NodeExecutionsTable', () => ({ + NodeExecutionsTable: jest.fn(({ children }) => ( +
{children}
+ )), +})); +jest.mock('components/Executions/ExecutionDetails/Timeline/ExecutionTimeline', () => ({ + ExecutionTimeline: jest.fn(({ children }) => ( +
{children}
+ )), +})); +jest.mock('components/Executions/ExecutionDetails/Timeline/ExecutionTimelineFooter', () => ({ + ExecutionTimelineFooter: jest.fn(({ children }) => ( +
{children}
+ )), +})); +jest.mock('components/WorkflowGraph/WorkflowGraph', () => ({ + WorkflowGraph: jest.fn(({ children }) =>
{children}
), +})); + +describe('Executions > ExecutionDetails > ExecutionTabContent', () => { + let queryClient: QueryClient; + let fixture: ReturnType; + + beforeEach(() => { + queryClient = createTestQueryClient(); + fixture = basicPythonWorkflow.generate(); + insertFixture(mockServer, fixture); + fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); + }); + + const renderTabContent = ({ tabType, nodeExecutionsById }) => { + return render( + + + + + + + , + ); + }; + + it('renders NodeExecutionsTable when the Nodes tab is selected', async () => { + const { queryByTestId } = renderTabContent({ + tabType: tabs.nodes.id, + nodeExecutionsById: {}, + }); + + await waitFor(() => queryByTestId('node-executions-table')); + expect(queryByTestId('node-executions-table')).toBeInTheDocument(); + }); + + it('renders WorkflowGraph when the Graph tab is selected', async () => { + const { queryByTestId } = renderTabContent({ + tabType: tabs.graph.id, + nodeExecutionsById: {}, + }); + + await waitFor(() => queryByTestId('workflow-graph')); + expect(queryByTestId('workflow-graph')).toBeInTheDocument(); + }); + + it('renders ExecutionTimeline when the Timeline tab is selected', async () => { + const { queryByTestId } = renderTabContent({ + tabType: tabs.timeline.id, + nodeExecutionsById: {}, + }); + + await waitFor(() => queryByTestId('execution-timeline')); + expect(queryByTestId('execution-timeline')).toBeInTheDocument(); + }); +}); diff --git a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionsTable.tsx b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionsTable.tsx index 8c0cf1268..14cf32998 100644 --- a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionsTable.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionsTable.tsx @@ -16,6 +16,7 @@ import { NodeExecutionRow } from './NodeExecutionRow'; import { NoExecutionsContent } from './NoExecutionsContent'; import { useColumnStyles, useExecutionTableStyles } from './styles'; import { NodeExecutionsByIdContext } from '../contexts'; +import { useNodeExecutionFiltersState } from '../filters/useExecutionFiltersState'; import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; export interface NodeExecutionsTableProps { @@ -23,6 +24,7 @@ export interface NodeExecutionsTableProps { selectedExecution: NodeExecutionIdentifier | null; abortMetadata?: Admin.IAbortMetadata; initialNodes: dNode[]; + filteredNodeExecutions: NodeExecution[]; } const scrollbarPadding = scrollbarSize(); @@ -37,40 +39,44 @@ export const NodeExecutionsTable: React.FC = ({ selectedExecution, abortMetadata, initialNodes, + filteredNodeExecutions, }) => { const [nodeExecutions, setNodeExecutions] = useState([]); const commonStyles = useCommonStyles(); const tableStyles = useExecutionTableStyles(); const nodeExecutionsById = useContext(NodeExecutionsByIdContext); + const filterState = useNodeExecutionFiltersState(); const { compiledWorkflowClosure } = useNodeExecutionContext(); useEffect(() => { if (nodeExecutionsById) { - const executions: NodeExecution[] = []; - initialNodes.map((node) => { - if (nodeExecutionsById[node.scopedId]) executions.push(nodeExecutionsById[node.scopedId]); - else - executions.push({ - closure: { - createdAt: dateToTimestamp(new Date()), - outputUri: '', - phase: NodeExecutionPhase.UNDEFINED, - }, - id: { - executionId: { - domain: node.value?.taskNode?.referenceId?.domain, - name: node.value?.taskNode?.referenceId?.name, - project: node.value?.taskNode?.referenceId?.project, + const executions: NodeExecution[] = [...filteredNodeExecutions]; + if (!filterState.appliedFilters?.length) { + initialNodes.forEach((node) => { + if (!nodeExecutionsById[node.scopedId]) { + executions.push({ + closure: { + createdAt: dateToTimestamp(new Date()), + outputUri: '', + phase: NodeExecutionPhase.UNDEFINED, }, - nodeId: node.id, - }, - inputUri: '', - scopedId: node.scopedId, - }); - }); + id: { + executionId: { + domain: node.value?.taskNode?.referenceId?.domain, + name: node.value?.taskNode?.referenceId?.name, + project: node.value?.taskNode?.referenceId?.project, + }, + nodeId: node.id, + }, + inputUri: '', + scopedId: node.scopedId, + }); + } + }); + } setNodeExecutions(executions); } - }, [nodeExecutionsById, initialNodes]); + }, [nodeExecutionsById, initialNodes, filteredNodeExecutions]); const executionsWithKeys = useMemo( () => diff --git a/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx b/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx index ac6f660ff..23abded88 100644 --- a/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx @@ -1,474 +1,197 @@ -import { - fireEvent, - getAllByRole, - getAllByText, - getByText, - getByTitle, - render, - screen, - waitFor, -} from '@testing-library/react'; -import { cacheStatusMessages } from 'components/Executions/constants'; +import { render, waitFor } from '@testing-library/react'; import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; -import { UNKNOWN_DETAILS } from 'components/Executions/contextProvider/NodeExecutionDetails/types'; import { - ExecutionContext, - ExecutionContextData, + NodeExecutionsByIdContext, NodeExecutionsRequestConfigContext, } from 'components/Executions/contexts'; -import { makeNodeExecutionListQuery } from 'components/Executions/nodeExecutionQueries'; -import { NodeExecutionDisplayType } from 'components/Executions/types'; -import { nodeExecutionIsTerminal } from 'components/Executions/utils'; -import { useConditionalQuery } from 'components/hooks/useConditionalQuery'; -import { cloneDeep } from 'lodash'; import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; -import { dynamicExternalSubWorkflow } from 'mocks/data/fixtures/dynamicExternalSubworkflow'; -import { - dynamicPythonNodeExecutionWorkflow, - dynamicPythonTaskWorkflow, -} from 'mocks/data/fixtures/dynamicPythonWorkflow'; -import { oneFailedTaskWorkflow } from 'mocks/data/fixtures/oneFailedTaskWorkflow'; +import { noExecutionsFoundString } from 'common/constants'; import { mockWorkflowId } from 'mocks/data/fixtures/types'; import { insertFixture } from 'mocks/data/insertFixture'; -import { notFoundError } from 'mocks/errors'; import { mockServer } from 'mocks/server'; -import { FilterOperationName, RequestConfig } from 'models/AdminEntity/types'; -import { nodeExecutionQueryParams } from 'models/Execution/constants'; -import { CatalogCacheStatus, NodeExecutionPhase } from 'models/Execution/enums'; -import { Execution, NodeExecution, TaskNodeMetadata } from 'models/Execution/types'; -import { ResourceType } from 'models/Common/types'; +import { RequestConfig } from 'models/AdminEntity/types'; +import { NodeExecutionPhase } from 'models/Execution/enums'; import * as React from 'react'; -import { QueryClient, QueryClientProvider, useQueryClient } from 'react-query'; -import { makeIdentifier } from 'test/modelUtils'; -import { - createTestQueryClient, - disableQueryLogger, - enableQueryLogger, - findNearestAncestorByRole, -} from 'test/utils'; -import * as moduleApi from 'components/Executions/contextProvider/NodeExecutionDetails/getTaskThroughExecution'; -import { titleStrings } from '../constants'; +import { dateToTimestamp } from 'common/utils'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { createTestQueryClient } from 'test/utils'; +import { NodeExecution } from 'models/Execution/types'; +import { dNode } from 'models/Graph/types'; import { NodeExecutionsTable } from '../NodeExecutionsTable'; jest.mock('components/Workflow/workflowQueries'); const { fetchWorkflow } = require('components/Workflow/workflowQueries'); -describe.skip('NodeExecutionsTable', () => { - let workflowExecution: Execution; +jest.mock('components/Executions/Tables/NodeExecutionRow', () => ({ + NodeExecutionRow: jest.fn(({ children, execution }) => ( +
+
{execution?.id?.nodeId}
+
{execution?.closure?.phase}
+ {children} +
+ )), +})); + +const mockNodes = (n: number): dNode[] => { + const nodes: dNode[] = []; + for (let i = 1; i <= n; i++) { + nodes.push({ + id: `node${i}`, + scopedId: `n${i}`, + type: 4, + name: `Node ${i}`, + nodes: [], + edges: [], + }); + } + return nodes; +}; + +const mockNodeExecutions = (n: number, phases: NodeExecutionPhase[]): NodeExecution[] => { + const nodeExecutions: NodeExecution[] = []; + for (let i = 1; i <= n; i++) { + nodeExecutions.push({ + closure: { + createdAt: dateToTimestamp(new Date()), + outputUri: '', + phase: phases[i - 1], + }, + id: { + executionId: { domain: 'domain', name: 'name', project: 'project' }, + nodeId: `node${i}`, + }, + inputUri: '', + scopedId: `n${i}`, + }); + } + return nodeExecutions; +}; + +const mockExecutionsById = (n: number, phases: NodeExecutionPhase[]) => { + const nodeExecutionsById = {}; + + for (let i = 1; i <= n; i++) { + nodeExecutionsById[`n${i}`] = { + closure: { + createdAt: dateToTimestamp(new Date()), + outputUri: '', + phase: phases[i - 1], + }, + id: { + executionId: { domain: 'domain', name: 'name', project: 'project' }, + nodeId: `node${i}`, + }, + inputUri: '', + scopedId: `n${i}`, + }; + } + return nodeExecutionsById; +}; + +describe('NodeExecutionsTableExecutions > Tables > NodeExecutionsTable', () => { let queryClient: QueryClient; - let executionContext: ExecutionContextData; let requestConfig: RequestConfig; + let fixture: ReturnType; + const initialNodes = mockNodes(2); + const selectedExecution = null; + const setSelectedExecution = jest.fn(); beforeEach(() => { requestConfig = {}; queryClient = createTestQueryClient(); + fixture = basicPythonWorkflow.generate(); + insertFixture(mockServer, fixture); + fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); }); - const shouldUpdateFn = (nodeExecutions: NodeExecution[]) => - nodeExecutions.some((ne) => !nodeExecutionIsTerminal(ne)); - - const selectNode = async (container: HTMLElement, truncatedName: string, nodeId: string) => { - const nodeNameAnchor = await waitFor(() => getByText(container, truncatedName)); - fireEvent.click(nodeNameAnchor); - // Wait for Details Panel to render and then for the nodeId header - const detailsPanel = await waitFor(() => screen.getByTestId('details-panel')); - await waitFor(() => getByText(detailsPanel, nodeId)); - return detailsPanel; - }; - - const expandParentNode = async (rowContainer: HTMLElement) => { - const expander = await waitFor(() => getByTitle(rowContainer, titleStrings.expandRow)); - fireEvent.click(expander); - return await waitFor(() => getAllByRole(rowContainer, 'list')); - }; - - const TestTable = () => { - const query = useConditionalQuery( - { - ...makeNodeExecutionListQuery(useQueryClient(), workflowExecution.id, requestConfig), - // During tests, we only want to wait for the next tick to refresh - refetchInterval: 1, - }, - shouldUpdateFn, - ); - return query.data ? ( - {}} - /> - ) : null; - }; - - const renderTable = () => + const renderTable = ({ + nodeExecutionsById, + initialNodes, + filteredNodeExecutions, + selectedExecution, + setSelectedExecution, + }) => render( - - - - - + + + + + , ); - describe('when rendering the DetailsPanel', () => { - let nodeExecution: NodeExecution; - let fixture: ReturnType; - beforeEach(() => { - fixture = basicPythonWorkflow.generate(); - workflowExecution = fixture.workflowExecutions.top.data; - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - - executionContext = { - execution: workflowExecution, - }; - nodeExecution = fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; - }); - - const updateNodeExecutions = (executions: NodeExecution[]) => { - executions.forEach(mockServer.insertNodeExecution); - mockServer.insertNodeExecutionList(fixture.workflowExecutions.top.data.id, executions); - }; - - it('should render updated state if selected nodeExecution object changes', async () => { - nodeExecution.closure.phase = NodeExecutionPhase.RUNNING; - updateNodeExecutions([nodeExecution]); - const truncatedName = fixture.tasks.python.id.name.split('.').pop() || ''; - // Render table, click first node - const { container } = renderTable(); - const detailsPanel = await selectNode(container, truncatedName, nodeExecution.id.nodeId); - expect(getByText(detailsPanel, 'Running')).toBeInTheDocument(); - - const updatedExecution = cloneDeep(nodeExecution); - updatedExecution.closure.phase = NodeExecutionPhase.FAILED; - updateNodeExecutions([updatedExecution]); - await waitFor(() => expect(getByText(detailsPanel, 'Failed'))); - }); - - describe('with nested children', () => { - let fixture: ReturnType; - beforeEach(() => { - fixture = dynamicPythonNodeExecutionWorkflow.generate(); - workflowExecution = fixture.workflowExecutions.top.data; - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - executionContext = { execution: workflowExecution }; - }); - - it('should correctly render details for nested executions', async () => { - const childNodeExecution = - fixture.workflowExecutions.top.nodeExecutions.dynamicNode.nodeExecutions.firstChild.data; - const { container } = renderTable(); - const dynamicTaskNameEl = await waitFor(() => - getByText(container, fixture.tasks.dynamic.id.name), - ); - const dynamicRowEl = findNearestAncestorByRole(dynamicTaskNameEl, 'listitem'); - const parentNodeEl = await expandParentNode(dynamicRowEl); - const truncatedName = fixture.tasks.python.id.name.split('.').pop() || ''; - await selectNode(parentNodeEl[0], truncatedName, childNodeExecution.id.nodeId); - - // Wait for Details Panel to render and then for the nodeId header - const detailsPanel = await waitFor(() => screen.getByTestId('details-panel')); - await waitFor(() => expect(getByText(detailsPanel, childNodeExecution.id.nodeId))); - expect(getByText(detailsPanel, fixture.tasks.python.id.name)).toBeInTheDocument(); - }); - }); - }); - - describe('for basic executions', () => { - let fixture: ReturnType; - - beforeEach(() => { - fixture = basicPythonWorkflow.generate(); - workflowExecution = fixture.workflowExecutions.top.data; - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - - executionContext = { - execution: workflowExecution, - }; - }); - - const updateNodeExecutions = (executions: NodeExecution[]) => { - executions.forEach(mockServer.insertNodeExecution); - mockServer.insertNodeExecutionList(fixture.workflowExecutions.top.data.id, executions); - }; - - it('renders task name for task nodes', async () => { - const { getByText } = renderTable(); - await waitFor(() => expect(getByText(fixture.tasks.python.id.name)).toBeInTheDocument()); + it('renders empty content when there are no nodes', async () => { + const { queryByText, queryByTestId } = renderTable({ + initialNodes: [], + selectedExecution, + setSelectedExecution, + nodeExecutionsById: {}, + filteredNodeExecutions: [], }); - it('renders NodeExecutions with no associated spec information as Unknown', async () => { - const workflowExecution = fixture.workflowExecutions.top.data; - // For a NodeExecution which has no node in the associated workflow spec and - // no task executions, we don't have a way to identify its type. - // We'll change the python NodeExecution to reference a node id which doesn't exist - // in the spec and remove its TaskExecutions. - const nodeExecution = fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; - nodeExecution.id.nodeId = 'unknownNode'; - nodeExecution.metadata = {}; - mockServer.insertNodeExecution(nodeExecution); - mockServer.insertNodeExecutionList(workflowExecution.id, [nodeExecution]); - mockServer.insertTaskExecutionList(nodeExecution.id, []); - - const { container } = renderTable(); - const pythonNodeNameEl = await waitFor(() => - getAllByText(container, nodeExecution.id.nodeId), - ); - const rowEl = findNearestAncestorByRole(pythonNodeNameEl?.[0], 'listitem'); - await waitFor(() => expect(getByText(rowEl, NodeExecutionDisplayType.Unknown))); - }); + await waitFor(() => queryByText(noExecutionsFoundString)); - describe('for task nodes with cache status', () => { - let taskNodeMetadata: TaskNodeMetadata; - let cachedNodeExecution: NodeExecution; - beforeEach(() => { - const { nodeExecutions } = fixture.workflowExecutions.top; - const { taskExecutions } = nodeExecutions.pythonNode; - cachedNodeExecution = nodeExecutions.pythonNode.data; - taskNodeMetadata = { - cacheStatus: CatalogCacheStatus.CACHE_MISS, - catalogKey: { - datasetId: makeIdentifier({ - resourceType: ResourceType.DATASET, - }), - sourceTaskExecution: { - ...taskExecutions.firstAttempt.data.id, - }, - }, - }; - cachedNodeExecution.closure.taskNodeMetadata = taskNodeMetadata; - }); - - [ - CatalogCacheStatus.CACHE_HIT, - CatalogCacheStatus.CACHE_LOOKUP_FAILURE, - CatalogCacheStatus.CACHE_POPULATED, - CatalogCacheStatus.CACHE_PUT_FAILURE, - CatalogCacheStatus.CACHE_MISS, - CatalogCacheStatus.CACHE_DISABLED, - ].forEach((cacheStatusValue) => - it(`renders correct icon for ${CatalogCacheStatus[cacheStatusValue]}`, async () => { - taskNodeMetadata.cacheStatus = cacheStatusValue; - updateNodeExecutions([cachedNodeExecution]); - const { getByTitle } = renderTable(); - - await waitFor(() => - expect(getByTitle(cacheStatusMessages[cacheStatusValue])).toBeDefined(), - ); - }), - ); - }); + expect(queryByText(noExecutionsFoundString)).toBeInTheDocument(); + expect(queryByTestId('node-execution-row')).not.toBeInTheDocument(); }); - describe('for nodes with children', () => { - describe('with isParentNode flag', () => { - let fixture: ReturnType; - beforeEach(() => { - fixture = dynamicPythonNodeExecutionWorkflow.generate(); - workflowExecution = fixture.workflowExecutions.top.data; - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - executionContext = { execution: workflowExecution }; - }); - - it('correctly renders children', async () => { - const { container } = renderTable(); - const dynamicTaskNameEl = await waitFor(() => - getByText(container, fixture.tasks.dynamic.id.name), - ); - const dynamicRowEl = findNearestAncestorByRole(dynamicTaskNameEl, 'listitem'); - const childContainerList = await expandParentNode(dynamicRowEl); - await waitFor(() => expect(getByText(childContainerList[0], fixture.tasks.python.id.name))); - }); - - it('correctly renders groups', async () => { - const { nodeExecutions } = fixture.workflowExecutions.top; - // We returned two task execution attempts, each with children - const { container } = renderTable(); - const nodeNameEl = await waitFor(() => - getByText(container, nodeExecutions.dynamicNode.data.id.nodeId), - ); - const rowEl = findNearestAncestorByRole(nodeNameEl, 'listitem'); - const childGroups = await expandParentNode(rowEl); - expect(childGroups).toHaveLength(2); - }); - - describe('with initial failure to fetch children', () => { - // Disable react-query logger output to avoid a console.error - // when the request fails. - beforeEach(() => { - disableQueryLogger(); - }); - afterEach(() => { - enableQueryLogger(); - }); - it('renders error icon with retry', async () => { - const { - data: { id: workflowExecutionId }, - nodeExecutions, - } = fixture.workflowExecutions.top; - const parentNodeExecution = nodeExecutions.dynamicNode.data; - // Simulate an error when attempting to list children of first NE. - mockServer.insertNodeExecutionList( - workflowExecutionId, - notFoundError(parentNodeExecution.id.nodeId), - { - [nodeExecutionQueryParams.parentNodeId]: parentNodeExecution.id.nodeId, - }, - ); - - const { container, getByTitle } = renderTable(); - // We expect to find an error icon in place of the child expander - const errorIconButton = await waitFor(() => - getByTitle(titleStrings.childGroupFetchFailed), - ); - // restore proper handler for node execution children - insertFixture(mockServer, fixture); - // click error icon - await fireEvent.click(errorIconButton); - - // wait for expander and open it to verify children loaded correctly - const nodeNameEl = await waitFor(() => - getByText(container, nodeExecutions.dynamicNode.data.id.nodeId), - ); - const rowEl = findNearestAncestorByRole(nodeNameEl, 'listitem'); - const childGroups = await expandParentNode(rowEl); - expect(childGroups.length).toBeGreaterThan(0); - }); - }); - }); - - describe('without isParentNode flag, using taskNodeMetadata', () => { - let fixture: ReturnType; - beforeEach(() => { - fixture = dynamicPythonTaskWorkflow.generate(); - workflowExecution = fixture.workflowExecutions.top.data; - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - executionContext = { - execution: workflowExecution, - }; - }); - - it('correctly renders children', async () => { - // The dynamic task node should have a single child node - // which runs the basic python task. Expand it and then - // look for the python task name to verify it was rendered. - const { container } = renderTable(); - const dynamicTaskNameEl = await waitFor(() => - getByText(container, fixture.tasks.dynamic.id.name), - ); - const dynamicRowEl = findNearestAncestorByRole(dynamicTaskNameEl, 'listitem'); - const childContainerList = await expandParentNode(dynamicRowEl); - await waitFor(() => expect(getByText(childContainerList[0], fixture.tasks.python.id.name))); - }); - - it('correctly renders groups', async () => { - // We returned two task execution attempts, each with children - const { container } = renderTable(); - const nodeNameEl = await waitFor(() => - getByText( - container, - fixture.workflowExecutions.top.nodeExecutions.dynamicNode.data.id.nodeId, - ), - ); - const rowEl = findNearestAncestorByRole(nodeNameEl, 'listitem'); - const childGroups = await expandParentNode(rowEl); - expect(childGroups).toHaveLength(2); - }); + it('renders NodeExecutionRows with proper nodeExecutions', async () => { + const phases = [NodeExecutionPhase.FAILED, NodeExecutionPhase.SUCCEEDED]; + const nodeExecutionsById = mockExecutionsById(2, phases); + const filteredNodeExecutions = mockNodeExecutions(2, phases); + + const { queryAllByTestId } = renderTable({ + initialNodes, + selectedExecution, + setSelectedExecution, + nodeExecutionsById, + filteredNodeExecutions, }); - describe('without isParentNode flag, using workflowNodeMetadata', () => { - let fixture: ReturnType; - let mockGetTaskThroughExecution: any; - - beforeEach(() => { - fixture = dynamicExternalSubWorkflow.generate(); - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - workflowExecution = fixture.workflowExecutions.top.data; - executionContext = { - execution: workflowExecution, - }; - - mockGetTaskThroughExecution = jest.spyOn(moduleApi, 'getTaskThroughExecution'); - mockGetTaskThroughExecution.mockImplementation(() => { - return Promise.resolve({ - ...UNKNOWN_DETAILS, - displayName: fixture.workflows.sub.id.name, - }); - }); - }); - - afterEach(() => { - mockGetTaskThroughExecution.mockReset(); - }); - - it('correctly renders children', async () => { - const { container } = renderTable(); - const dynamicTaskNameEl = await waitFor(() => - getByText(container, fixture.tasks.generateSubWorkflow.id.name), - ); - const dynamicRowEl = findNearestAncestorByRole(dynamicTaskNameEl, 'listitem'); - const childContainerList = await expandParentNode(dynamicRowEl); - await waitFor(() => - expect(getByText(childContainerList[0], fixture.workflows.sub.id.name)), - ); - }); - - it('correctly renders groups', async () => { - const parentNodeId = - fixture.workflowExecutions.top.nodeExecutions.dynamicWorkflowGenerator.data.metadata - ?.specNodeId || 'not found'; - // We returned a single WF execution child, so there should only - // be one child group - const { container } = renderTable(); - const nodeNameEl = await waitFor(() => getByText(container, parentNodeId)); - const rowEl = findNearestAncestorByRole(nodeNameEl, 'listitem'); - const childGroups = await expandParentNode(rowEl); - expect(childGroups).toHaveLength(1); - }); - }); + await waitFor(() => queryAllByTestId('node-execution-row')); + + expect(queryAllByTestId('node-execution-row')).toHaveLength(initialNodes.length); + const ids = queryAllByTestId('node-execution-col-id'); + expect(ids).toHaveLength(initialNodes.length); + const renderedPhases = queryAllByTestId('node-execution-col-phase'); + expect(renderedPhases).toHaveLength(initialNodes.length); + for (const i in initialNodes) { + expect(ids[i]).toHaveTextContent(initialNodes[i].id); + expect(renderedPhases[i]).toHaveTextContent(phases[i].toString()); + } }); - describe('with a request filter', () => { - let fixture: ReturnType; - - beforeEach(() => { - fixture = oneFailedTaskWorkflow.generate(); - workflowExecution = fixture.workflowExecutions.top.data; - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - // Adding a request filter to only show failed NodeExecutions - requestConfig = { - filter: [ - { - key: 'phase', - operation: FilterOperationName.EQ, - value: NodeExecutionPhase[NodeExecutionPhase.FAILED], - }, - ], - }; - const nodeExecutions = fixture.workflowExecutions.top.nodeExecutions; - mockServer.insertNodeExecutionList(workflowExecution.id, [nodeExecutions.failedNode.data], { - filters: 'eq(phase,FAILED)', - }); - executionContext = { - execution: workflowExecution, - }; + it('renders future nodes with UNDEFINED phase', async () => { + const phases = [NodeExecutionPhase.SUCCEEDED, NodeExecutionPhase.UNDEFINED]; + const nodeExecutionsById = mockExecutionsById(1, phases); + const filteredNodeExecutions = mockNodeExecutions(1, phases); + + const { queryAllByTestId } = renderTable({ + initialNodes, + selectedExecution, + setSelectedExecution, + nodeExecutionsById, + filteredNodeExecutions, }); - it('requests child node executions using configuration from context', async () => { - const { getByText, queryByText } = renderTable(); - const { nodeExecutions } = fixture.workflowExecutions.top; - - await waitFor(() => expect(getByText(nodeExecutions.failedNode.data.id.nodeId))); - - expect(queryByText(nodeExecutions.pythonNode.data.id.nodeId)).toBeNull(); - }); + await waitFor(() => queryAllByTestId('node-execution-row')); + + expect(queryAllByTestId('node-execution-row')).toHaveLength(initialNodes.length); + const ids = queryAllByTestId('node-execution-col-id'); + expect(ids).toHaveLength(initialNodes.length); + const renderedPhases = queryAllByTestId('node-execution-col-phase'); + expect(renderedPhases).toHaveLength(initialNodes.length); + for (const i in initialNodes) { + expect(ids[i]).toHaveTextContent(initialNodes[i].id); + expect(renderedPhases[i]).toHaveTextContent(phases[i].toString()); + } }); });