mirror of
https://github.com/ansible/awx.git
synced 2026-01-16 20:30:46 -03:30
Adds ability to get latest revision from project list when a project is done syncing. Automatically refresh project data on project detail when it's done syncing.
This commit is contained in:
parent
d3b20e6585
commit
66f140bb70
@ -1,8 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { CopyIcon } from '@patternfly/react-icons';
|
||||
import useRequest, { useDismissableError } from '../../util/useRequest';
|
||||
@ -16,7 +14,6 @@ function CopyButton({
|
||||
onCopyStart,
|
||||
onCopyFinish,
|
||||
errorMessage,
|
||||
|
||||
ouiaId,
|
||||
}) {
|
||||
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
|
||||
@ -44,16 +41,18 @@ function CopyButton({
|
||||
>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
<AlertModal
|
||||
aria-label={t`Copy Error`}
|
||||
isOpen={error}
|
||||
variant="error"
|
||||
title={t`Error!`}
|
||||
onClose={dismissError}
|
||||
>
|
||||
{errorMessage}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
{error && (
|
||||
<AlertModal
|
||||
aria-label={t`Copy Error`}
|
||||
isOpen={error}
|
||||
variant="error"
|
||||
title={t`Error!`}
|
||||
onClose={dismissError}
|
||||
>
|
||||
{errorMessage}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
import React, { Fragment, useCallback } from 'react';
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button, List, ListItem, Tooltip } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
Button,
|
||||
ClipboardCopy,
|
||||
List,
|
||||
ListItem,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { Project } from '../../../types';
|
||||
import { Config } from '../../../contexts/Config';
|
||||
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||
import DeleteButton from '../../../components/DeleteButton';
|
||||
@ -27,6 +32,10 @@ import StatusLabel from '../../../components/StatusLabel';
|
||||
import { formatDateString } from '../../../util/dates';
|
||||
import useWsProject from './useWsProject';
|
||||
|
||||
const Label = styled.span`
|
||||
color: var(--pf-global--disabled-color--100);
|
||||
`;
|
||||
|
||||
function ProjectDetail({ project }) {
|
||||
const {
|
||||
allow_override,
|
||||
@ -42,6 +51,7 @@ function ProjectDetail({ project }) {
|
||||
scm_delete_on_update,
|
||||
scm_track_submodules,
|
||||
scm_refspec,
|
||||
scm_revision,
|
||||
scm_type,
|
||||
scm_update_on_launch,
|
||||
scm_update_cache_timeout,
|
||||
@ -146,6 +156,36 @@ function ProjectDetail({ project }) {
|
||||
label={t`Source Control Type`}
|
||||
value={scm_type === '' ? t`Manual` : toTitleCase(project.scm_type)}
|
||||
/>
|
||||
<Detail
|
||||
label={t`Source Control Revision`}
|
||||
value={
|
||||
scm_revision ? (
|
||||
<ClipboardCopy
|
||||
variant="inline-compact"
|
||||
clickTip={t`Successfully copied to clipboard!`}
|
||||
hoverTip={t`Copy full revision to clipboard.`}
|
||||
onCopy={event => {
|
||||
const clipboard = event.currentTarget.parentElement;
|
||||
const el = document.createElement('textarea');
|
||||
el.value = scm_revision.toString();
|
||||
clipboard.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
clipboard.removeChild(el);
|
||||
}}
|
||||
>
|
||||
{scm_revision.substring(0, 7)}
|
||||
</ClipboardCopy>
|
||||
) : (
|
||||
<Label
|
||||
aria-label={t`The project must be synced before a revision is available.`}
|
||||
>
|
||||
{t`Sync for revision`}
|
||||
</Label>
|
||||
)
|
||||
}
|
||||
alwaysVisible
|
||||
/>
|
||||
<Detail label={t`Source Control URL`} value={scm_url} />
|
||||
<Detail label={t`Source Control Branch`} value={scm_branch} />
|
||||
<Detail label={t`Source Control Refspec`} value={scm_refspec} />
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import useWebsocket from '../../../util/useWebsocket';
|
||||
import { ProjectsAPI } from '../../../api';
|
||||
|
||||
export default function useWsProjects(initialProject) {
|
||||
const [project, setProject] = useState(initialProject);
|
||||
@ -8,6 +9,11 @@ export default function useWsProjects(initialProject) {
|
||||
control: ['limit_reached_1'],
|
||||
});
|
||||
|
||||
const refreshProject = async () => {
|
||||
const { data } = await ProjectsAPI.readDetail(project.id);
|
||||
setProject(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setProject(initialProject);
|
||||
}, [initialProject]);
|
||||
@ -22,6 +28,11 @@ export default function useWsProjects(initialProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastMessage.finished) {
|
||||
refreshProject();
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...project,
|
||||
summary_fields: {
|
||||
|
||||
@ -2,8 +2,11 @@ import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import WS from 'jest-websocket-mock';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { ProjectsAPI } from '../../../api';
|
||||
import useWsProject from './useWsProject';
|
||||
|
||||
jest.mock('../../../api/models/Projects');
|
||||
|
||||
function TestInner() {
|
||||
return <div />;
|
||||
}
|
||||
@ -15,13 +18,30 @@ function Test({ project }) {
|
||||
describe('useWsProject', () => {
|
||||
let debug;
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
debug = global.console.debug; // eslint-disable-line prefer-destructuring
|
||||
global.console.debug = () => {};
|
||||
ProjectsAPI.readDetail.mockResolvedValue({
|
||||
data: {
|
||||
id: 1,
|
||||
summary_fields: {
|
||||
last_job: {
|
||||
id: 19,
|
||||
name: 'Test Project',
|
||||
description: '',
|
||||
finished: '2021-06-01T18:43:53.332201Z',
|
||||
status: 'successful',
|
||||
failed: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.console.debug = debug;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should return project detail', async () => {
|
||||
@ -127,14 +147,20 @@ describe('useWsProject', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(ProjectsAPI.readDetail).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
wrapper.find('TestInner').prop('project').summary_fields.current_job
|
||||
wrapper.find('TestInner').prop('project').summary_fields.last_job
|
||||
).toEqual({
|
||||
id: 2,
|
||||
id: 19,
|
||||
name: 'Test Project',
|
||||
description: '',
|
||||
finished: '2021-06-01T18:43:53.332201Z',
|
||||
status: 'successful',
|
||||
finished: '2020-07-02T16:28:31.839071Z',
|
||||
failed: false,
|
||||
});
|
||||
WS.clean();
|
||||
});
|
||||
|
||||
@ -2,9 +2,11 @@ import React, { useEffect, useCallback } from 'react';
|
||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
||||
import { t, Plural } from '@lingui/macro';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
|
||||
import { ProjectsAPI } from '../../../api';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import useRequest, {
|
||||
useDeleteItems,
|
||||
useDismissableError,
|
||||
} from '../../../util/useRequest';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import DataListToolbar from '../../../components/DataListToolbar';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
@ -33,6 +35,21 @@ function ProjectList() {
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
|
||||
const {
|
||||
request: fetchUpdatedProject,
|
||||
error: fetchUpdatedProjectError,
|
||||
result: updatedProject,
|
||||
} = useRequest(
|
||||
useCallback(async projectId => {
|
||||
if (!projectId) {
|
||||
return {};
|
||||
}
|
||||
const { data } = await ProjectsAPI.readDetail(projectId);
|
||||
return data;
|
||||
}, []),
|
||||
null
|
||||
);
|
||||
|
||||
const {
|
||||
result: {
|
||||
results,
|
||||
@ -43,7 +60,8 @@ function ProjectList() {
|
||||
},
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchProjects,
|
||||
request: fetchUpdatedProjects,
|
||||
setValue: setProjects,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
@ -73,8 +91,8 @@ function ProjectList() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, [fetchProjects]);
|
||||
fetchUpdatedProjects();
|
||||
}, [fetchUpdatedProjects]);
|
||||
|
||||
const projects = useWsProjects(results);
|
||||
|
||||
@ -99,7 +117,7 @@ function ProjectList() {
|
||||
{
|
||||
qsConfig: QS_CONFIG,
|
||||
allItemsSelected: isAllSelected,
|
||||
fetchItems: fetchProjects,
|
||||
fetchItems: fetchUpdatedProjects,
|
||||
}
|
||||
);
|
||||
|
||||
@ -115,6 +133,27 @@ function ProjectList() {
|
||||
selected[0]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedProject) {
|
||||
const updatedProjects = projects.map(project =>
|
||||
project.id === updatedProject.id ? updatedProject : project
|
||||
);
|
||||
setProjects({
|
||||
results: updatedProjects,
|
||||
itemCount,
|
||||
actions,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
});
|
||||
}
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [updatedProject]);
|
||||
|
||||
const {
|
||||
error: projectError,
|
||||
dismissError: dismissProjectError,
|
||||
} = useDismissableError(fetchUpdatedProjectError);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageSection>
|
||||
@ -207,13 +246,14 @@ function ProjectList() {
|
||||
)}
|
||||
renderRow={(project, index) => (
|
||||
<ProjectListItem
|
||||
fetchProjects={fetchProjects}
|
||||
fetchUpdatedProjects={fetchUpdatedProjects}
|
||||
key={project.id}
|
||||
project={project}
|
||||
detailUrl={`${match.url}/${project.id}`}
|
||||
isSelected={selected.some(row => row.id === project.id)}
|
||||
onSelect={() => handleSelect(project)}
|
||||
rowIndex={index}
|
||||
onRefreshRow={projectId => fetchUpdatedProject(projectId)}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={
|
||||
@ -224,16 +264,30 @@ function ProjectList() {
|
||||
/>
|
||||
</Card>
|
||||
</PageSection>
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
aria-label={t`Deletion Error`}
|
||||
title={t`Error!`}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{t`Failed to delete one or more projects.`}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
{deletionError && (
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
aria-label={t`Deletion Error`}
|
||||
title={t`Error!`}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{t`Failed to delete one or more projects.`}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
{projectError && (
|
||||
<AlertModal
|
||||
isOpen={projectError}
|
||||
variant="error"
|
||||
aria-label={t`Error fetching updated project`}
|
||||
title={t`Error!`}
|
||||
onClose={dismissProjectError}
|
||||
>
|
||||
{t`Failed to fetch the updated project data.`}
|
||||
<ErrorDetail error={projectError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,20 +1,19 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { Fragment, useState, useCallback } from 'react';
|
||||
import { string, bool, func } from 'prop-types';
|
||||
|
||||
import { Button, Tooltip } from '@patternfly/react-core';
|
||||
import { Button, ClipboardCopy, Tooltip } from '@patternfly/react-core';
|
||||
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
PencilAltIcon,
|
||||
ExclamationTriangleIcon as PFExclamationTriangleIcon,
|
||||
UndoIcon,
|
||||
} from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||
import { formatDateString, timeOfDay } from '../../../util/dates';
|
||||
import { ProjectsAPI } from '../../../api';
|
||||
import ClipboardCopyButton from '../../../components/ClipboardCopyButton';
|
||||
import {
|
||||
DetailList,
|
||||
Detail,
|
||||
@ -23,6 +22,7 @@ import {
|
||||
import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail';
|
||||
import StatusLabel from '../../../components/StatusLabel';
|
||||
import { toTitleCase } from '../../../util/strings';
|
||||
import { isJobRunning } from '../../../util/jobs';
|
||||
import CopyButton from '../../../components/CopyButton';
|
||||
import ProjectSyncButton from '../shared/ProjectSyncButton';
|
||||
import { Project } from '../../../types';
|
||||
@ -44,6 +44,7 @@ function ProjectListItem({
|
||||
detailUrl,
|
||||
fetchProjects,
|
||||
rowIndex,
|
||||
onRefreshRow,
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isDisabled, setIsDisabled] = useState(false);
|
||||
@ -88,6 +89,68 @@ function ProjectListItem({
|
||||
setIsDisabled(false);
|
||||
}, []);
|
||||
|
||||
const renderRevision = () => {
|
||||
if (!project.summary_fields?.current_job || project.scm_revision) {
|
||||
return project.scm_revision ? (
|
||||
<ClipboardCopy
|
||||
dataCy={`project-copy-revision-${project.id}`}
|
||||
variant="inline-compact"
|
||||
clickTip={t`Successfully copied to clipboard!`}
|
||||
hoverTip={t`Copy full revision to clipboard.`}
|
||||
onCopy={event => {
|
||||
const clipboard = event.currentTarget.parentElement;
|
||||
const el = document.createElement('textarea');
|
||||
el.value = project.scm_revision.toString();
|
||||
clipboard.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
clipboard.removeChild(el);
|
||||
}}
|
||||
>
|
||||
{project.scm_revision.substring(0, 7)}
|
||||
</ClipboardCopy>
|
||||
) : (
|
||||
<Label
|
||||
aria-label={t`The project must be synced before a revision is available.`}
|
||||
>
|
||||
{t`Sync for revision`}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isJobRunning(project.summary_fields.current_job.status) &&
|
||||
!project.scm_revision
|
||||
) {
|
||||
return (
|
||||
<Label
|
||||
aria-label={t`The project is currently syncing and the revision will be available after the sync is complete.`}
|
||||
>
|
||||
{t`Syncing`}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label
|
||||
aria-label={t`The project revision is currently out of date. Please refresh to fetch the most recent revision.`}
|
||||
>
|
||||
{t`Refresh for revision`}
|
||||
</Label>
|
||||
<Tooltip content={t`Refresh project revision`}>
|
||||
<Button
|
||||
ouiaId={`project-refresh-revision-${project.id}`}
|
||||
variant="plain"
|
||||
onClick={() => onRefreshRow(project.id)}
|
||||
>
|
||||
<UndoIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const labelId = `check-action-${project.id}`;
|
||||
|
||||
const missingExecutionEnvironment =
|
||||
@ -153,21 +216,7 @@ function ProjectListItem({
|
||||
<Td dataLabel={t`Type`}>
|
||||
{project.scm_type === '' ? t`Manual` : toTitleCase(project.scm_type)}
|
||||
</Td>
|
||||
<Td dataLabel={t`Revision`}>
|
||||
{project.scm_revision.substring(0, 7)}
|
||||
{!project.scm_revision && (
|
||||
<Label aria-label={t`copy to clipboard disabled`}>
|
||||
{t`Sync for revision`}
|
||||
</Label>
|
||||
)}
|
||||
<ClipboardCopyButton
|
||||
isDisabled={!project.scm_revision}
|
||||
stringToCopy={project.scm_revision}
|
||||
copyTip={t`Copy full revision to clipboard.`}
|
||||
copiedSuccessTip={t`Successfully copied to clipboard!`}
|
||||
ouiaId="copy-revision-button"
|
||||
/>
|
||||
</Td>
|
||||
<Td dataLabel={t`Revision`}>{renderRevision()}</Td>
|
||||
<ActionsTd dataLabel={t`Actions`}>
|
||||
{['running', 'pending', 'waiting'].includes(job?.status) ? (
|
||||
<ActionItem
|
||||
|
||||
@ -215,7 +215,7 @@ describe('<ProjectsListItem />', () => {
|
||||
});
|
||||
|
||||
test('should render proper alert modal on copy error', async () => {
|
||||
ProjectsAPI.copy.mockRejectedValue(new Error());
|
||||
ProjectsAPI.copy.mockRejectedValue(new Error('This is an error'));
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
@ -285,7 +285,7 @@ describe('<ProjectsListItem />', () => {
|
||||
);
|
||||
expect(wrapper.find('CopyButton').length).toBe(0);
|
||||
});
|
||||
test('should render disabled copy to clipboard button', () => {
|
||||
test('should render proper revision text when project has not been synced', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
@ -314,10 +314,82 @@ describe('<ProjectsListItem />', () => {
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(
|
||||
wrapper.find('span[aria-label="copy to clipboard disabled"]').text()
|
||||
).toBe('Sync for revision');
|
||||
expect(wrapper.find('ClipboardCopyButton').prop('isDisabled')).toBe(true);
|
||||
expect(wrapper.find('ClipboardCopy').length).toBe(0);
|
||||
expect(wrapper.find('td[data-label="Revision"]').text()).toBe(
|
||||
'Sync for revision'
|
||||
);
|
||||
});
|
||||
test('should render the clipboard copy with the right text when scm revision available', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<ProjectsListItem
|
||||
isSelected={false}
|
||||
detailUrl="/project/1"
|
||||
onSelect={() => {}}
|
||||
project={{
|
||||
id: 1,
|
||||
name: 'Project 1',
|
||||
url: '/api/v2/projects/1',
|
||||
type: 'project',
|
||||
scm_type: 'git',
|
||||
scm_revision: 'osofej904r09a9sf0udfsajogsdfbh4e23489adf',
|
||||
summary_fields: {
|
||||
last_job: {
|
||||
id: 9000,
|
||||
status: 'successful',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('ClipboardCopy').length).toBe(1);
|
||||
expect(wrapper.find('ClipboardCopy').text()).toBe('osofej9');
|
||||
});
|
||||
test('should indicate that the revision needs to be refreshed when project sync is done', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<ProjectsListItem
|
||||
isSelected={false}
|
||||
detailUrl="/project/1"
|
||||
onSelect={() => {}}
|
||||
project={{
|
||||
id: 1,
|
||||
name: 'Project 1',
|
||||
url: '/api/v2/projects/1',
|
||||
type: 'project',
|
||||
scm_type: 'git',
|
||||
scm_revision: null,
|
||||
summary_fields: {
|
||||
current_job: {
|
||||
id: 9001,
|
||||
status: 'successful',
|
||||
finished: '2021-06-01T18:43:53.332201Z',
|
||||
},
|
||||
last_job: {
|
||||
id: 9000,
|
||||
status: 'successful',
|
||||
},
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('ClipboardCopy').length).toBe(0);
|
||||
expect(wrapper.find('td[data-label="Revision"]').text()).toBe(
|
||||
'Refresh for revision'
|
||||
);
|
||||
expect(wrapper.find('UndoIcon').length).toBe(1);
|
||||
});
|
||||
test('should render expected details in expanded section', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
|
||||
@ -33,6 +33,11 @@ export default function useWsProjects(initialProjects) {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (lastMessage.finished) {
|
||||
updatedProject.scm_revision = null;
|
||||
}
|
||||
|
||||
setProjects([
|
||||
...projects.slice(0, index),
|
||||
updatedProject,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user