Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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
);
};
Comment thread
cigamit marked this conversation as resolved.

// 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
Expand Down Expand Up @@ -143,8 +158,8 @@ function WorkflowOutputNavigation({ relatedJobs, parentRef }) {
</ChipGroup>
)}
{!filterBy &&
(currentPosition > 0
? t`Workflow Job ${currentPosition}/${total}`
(viewedPosition > 0
? positionLabel(viewedPosition)
: t`Workflow Jobs (${total})`)}
</WorkflowMenuToggle>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`],
});
Expand All @@ -61,7 +83,7 @@ function renderAt(jobId) {
<Route
path="/jobs/:typeSegment/:id/output"
element={
<WorkflowOutputNavigation relatedJobs={relatedJobs} parentRef={ref} />
<WorkflowOutputNavigation relatedJobs={jobs} parentRef={ref} />
}
/>
</Routes>,
Expand Down Expand Up @@ -243,4 +265,38 @@ describe('<WorkflowOutputNavigation />', () => {
// 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();
});
});
5 changes: 4 additions & 1 deletion awx/ui/src/screens/Job/Job.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
26 changes: 25 additions & 1 deletion awx/ui/src/screens/Job/Job.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,4 +26,27 @@ describe('<Job />', () => {
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(<Job setBreadcrumb={() => {}} />);

// 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,
})
);
});
});
Loading