diff --git a/awx/ui/src/components/WorkflowOutputNavigation/WorkflowOutputNavigation.js b/awx/ui/src/components/WorkflowOutputNavigation/WorkflowOutputNavigation.js
index 55d29e40..61d12374 100644
--- a/awx/ui/src/components/WorkflowOutputNavigation/WorkflowOutputNavigation.js
+++ b/awx/ui/src/components/WorkflowOutputNavigation/WorkflowOutputNavigation.js
@@ -56,12 +56,16 @@ function WorkflowOutputNavigation({ relatedJobs, parentRef }) {
);
// 1-based, and 0 when the job on screen is not one of the workflow's nodes
- const currentPosition =
+ const viewedPosition =
jobNodes.findIndex(({ job: jobId }) => `${jobId}` === id) + 1;
- // named so the extracted message reads {currentPosition}/{total} rather than
- // leaving translators with a positional {0}
const total = jobNodes.length;
+ // the parameter and total are named so the extracted message reads
+ // {currentPosition}/{total} rather than leaving translators with a
+ // positional {0}
+ const positionLabel = (currentPosition) =>
+ t`Workflow Job ${currentPosition}/${total}`;
+
const statusLabels = {
Failed: t`Failed`,
Successful: t`Successful`,
@@ -71,10 +75,21 @@ function WorkflowOutputNavigation({ relatedJobs, parentRef }) {
setFilterBy((current) => (current === value ? undefined : value));
};
- const nodeLabel = (node) =>
- stringIsUUID(node.identifier)
- ? node.summary_fields.job.name
- : node.identifier;
+ const nodeLabel = (node) => {
+ if (stringIsUUID(node.identifier)) {
+ return node.summary_fields.job.name;
+ }
+ if (node.identifier) {
+ return node.identifier;
+ }
+ // Sliced-job and federated-inventory workflows create their nodes directly
+ // rather than copying them from a template node, so identifier is blank,
+ // and every slice's job carries the same name as the template. Label these
+ // by position, in the words the toggle uses for the job on screen.
+ return positionLabel(
+ jobNodes.findIndex((candidate) => candidate.id === node.id) + 1
+ );
+ };
// Derived rather than held in state: the previous version seeded a useState
// from the first render's list, so after navigating within the workflow the
@@ -143,8 +158,8 @@ function WorkflowOutputNavigation({ relatedJobs, parentRef }) {
)}
{!filterBy &&
- (currentPosition > 0
- ? t`Workflow Job ${currentPosition}/${total}`
+ (viewedPosition > 0
+ ? positionLabel(viewedPosition)
: t`Workflow Jobs (${total})`)}
)}
diff --git a/awx/ui/src/components/WorkflowOutputNavigation/WorkflowOutputNavigation.test.js b/awx/ui/src/components/WorkflowOutputNavigation/WorkflowOutputNavigation.test.js
index 4ea48e0b..edd2b595 100644
--- a/awx/ui/src/components/WorkflowOutputNavigation/WorkflowOutputNavigation.test.js
+++ b/awx/ui/src/components/WorkflowOutputNavigation/WorkflowOutputNavigation.test.js
@@ -51,7 +51,29 @@ const relatedJobs = [
},
];
-function renderAt(jobId) {
+// sliced jobs (and federated inventories) build their workflow nodes directly
+// rather than from a template node: identifier comes back blank, and every
+// slice's job wears the template's name
+const slicedJobs = [
+ {
+ id: 30,
+ job: 301,
+ identifier: '',
+ summary_fields: {
+ job: { id: 301, name: 'Sliced JT', type: 'job', status: 'successful' },
+ },
+ },
+ {
+ id: 31,
+ job: 302,
+ identifier: '',
+ summary_fields: {
+ job: { id: 302, name: 'Sliced JT', type: 'job', status: 'failed' },
+ },
+ },
+];
+
+function renderAt(jobId, jobs = relatedJobs) {
const history = createMemoryHistory({
initialEntries: [`/jobs/playbook/${jobId}/output`],
});
@@ -61,7 +83,7 @@ function renderAt(jobId) {
+
}
/>
,
@@ -243,4 +265,38 @@ describe('', () => {
// no route for that type, so it stays put rather than going to /jobs/undefined/199
expect(history.location.pathname).toBe('/jobs/playbook/101/output');
});
+
+ test('labels nodes with a blank identifier by their position', async () => {
+ const { user } = renderAt(301, slicedJobs);
+ await user.click(screen.getByRole('button'));
+ // the entry for the job on screen reads the same as the toggle, so it
+ // appears twice; the other slice appears once, in the menu
+ await waitFor(() =>
+ expect(screen.getAllByText('Workflow Job 1/2')).toHaveLength(2)
+ );
+ expect(screen.getByText('Workflow Job 2/2')).toBeInTheDocument();
+ });
+
+ test('navigates to a slice picked by its position', async () => {
+ const { user, history } = renderAt(301, slicedJobs);
+ await user.click(screen.getByRole('button'));
+ await waitFor(() => screen.getByText('Workflow Job 2/2'));
+ await user.click(screen.getByText('Workflow Job 2/2'));
+ await waitFor(() =>
+ expect(history.location.pathname).toBe('/jobs/playbook/302/output')
+ );
+ });
+
+ test('keeps positional labels stable under a status filter', async () => {
+ const { user } = renderAt(301, slicedJobs);
+ await user.click(screen.getByRole('button'));
+ await user.click(screen.getByRole('option', { name: /Failed/ }));
+ // only the failed slice survives, still wearing its original position
+ await waitFor(() =>
+ expect(screen.getByText('Workflow Job 2/2')).toBeInTheDocument()
+ );
+ expect(
+ screen.queryByRole('option', { name: 'Workflow Job 1/2' })
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/awx/ui/src/screens/Job/Job.js b/awx/ui/src/screens/Job/Job.js
index 5ee2fd09..b01a980c 100644
--- a/awx/ui/src/screens/Job/Job.js
+++ b/awx/ui/src/screens/Job/Job.js
@@ -65,7 +65,10 @@ function Job({ setBreadcrumb }) {
const {
data: { results },
} = await getJobModel('workflow_job').readNodes(
- jobDetailData.summary_fields.source_workflow_job.id
+ jobDetailData.summary_fields.source_workflow_job.id,
+ // without this the API returns its default page of 25, which
+ // truncates the workflow navigation menu; 200 is MAX_PAGE_SIZE
+ { page_size: 200 }
);
relatedJobData = results;
}
diff --git a/awx/ui/src/screens/Job/Job.test.js b/awx/ui/src/screens/Job/Job.test.js
index 2f668274..95e75f85 100644
--- a/awx/ui/src/screens/Job/Job.test.js
+++ b/awx/ui/src/screens/Job/Job.test.js
@@ -1,5 +1,6 @@
import React from 'react';
-import { waitForElementToBeRemoved } from '@testing-library/react';
+import { waitFor, waitForElementToBeRemoved } from '@testing-library/react';
+import { ProjectUpdatesAPI, WorkflowJobsAPI } from 'api';
import { renderWithContexts } from '../../../testUtils/rtlContexts';
import Job from './Job';
@@ -25,4 +26,27 @@ describe('', () => {
container.querySelector('[role="progressbar"]')
);
});
+
+ test('requests a full page of workflow nodes for the navigation menu', async () => {
+ ProjectUpdatesAPI.readDetail.mockResolvedValue({
+ data: {
+ id: 1,
+ type: 'project_update',
+ related: { source_workflow_job: '/api/v2/workflow_jobs/99/' },
+ summary_fields: { source_workflow_job: { id: 99 } },
+ },
+ });
+ ProjectUpdatesAPI.readEventOptions.mockResolvedValue({ data: {} });
+ WorkflowJobsAPI.readNodes.mockResolvedValue({ data: { results: [] } });
+
+ renderWithContexts( {}} />);
+
+ // the API's default page is 25 nodes, which truncates the menu for large
+ // (e.g. heavily sliced) workflows; the fetch must ask for MAX_PAGE_SIZE
+ await waitFor(() =>
+ expect(WorkflowJobsAPI.readNodes).toHaveBeenCalledWith(99, {
+ page_size: 200,
+ })
+ );
+ });
});