{
+ 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)}
+
+ ) : (
+
+ )
+ }
+ alwaysVisible
+ />
diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/useWsProject.js b/awx/ui_next/src/screens/Project/ProjectDetail/useWsProject.js
index 6c3e582c4b..e9a9954c28 100644
--- a/awx/ui_next/src/screens/Project/ProjectDetail/useWsProject.js
+++ b/awx/ui_next/src/screens/Project/ProjectDetail/useWsProject.js
@@ -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: {
diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/useWsProject.test.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/useWsProject.test.jsx
index 293f6ef986..21bcabe1e7 100644
--- a/awx/ui_next/src/screens/Project/ProjectDetail/useWsProject.test.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectDetail/useWsProject.test.jsx
@@ -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 ;
}
@@ -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();
});
diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx
index 66b9dff1f9..6ea8bfca9e 100644
--- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx
@@ -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 (
<>
@@ -207,13 +246,14 @@ function ProjectList() {
)}
renderRow={(project, index) => (
row.id === project.id)}
onSelect={() => handleSelect(project)}
rowIndex={index}
+ onRefreshRow={projectId => fetchUpdatedProject(projectId)}
/>
)}
emptyStateControls={
@@ -224,16 +264,30 @@ function ProjectList() {
/>
-
- {t`Failed to delete one or more projects.`}
-
-
+ {deletionError && (
+
+ {t`Failed to delete one or more projects.`}
+
+
+ )}
+ {projectError && (
+
+ {t`Failed to fetch the updated project data.`}
+
+
+ )}
>
);
}
diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
index 7fcf5ec5c9..e45bd4529c 100644
--- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
@@ -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 ? (
+ {
+ 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)}
+
+ ) : (
+
+ );
+ }
+
+ if (
+ isJobRunning(project.summary_fields.current_job.status) &&
+ !project.scm_revision
+ ) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+ };
+
const labelId = `check-action-${project.id}`;
const missingExecutionEnvironment =
@@ -153,21 +216,7 @@ function ProjectListItem({
|
{project.scm_type === '' ? t`Manual` : toTitleCase(project.scm_type)}
|
-
- {project.scm_revision.substring(0, 7)}
- {!project.scm_revision && (
-
- )}
-
- |
+ {renderRevision()} |
{['running', 'pending', 'waiting'].includes(job?.status) ? (
', () => {
});
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(
@@ -285,7 +285,7 @@ describe('', () => {
);
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(
@@ -314,10 +314,82 @@ describe('', () => {
);
- 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(
+
+
+ {}}
+ 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,
+ },
+ },
+ }}
+ />
+
+
+ );
+ 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(
+
+
+ {}}
+ 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,
+ },
+ },
+ }}
+ />
+
+
+ );
+ 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(
diff --git a/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js
index de863a7ee7..9fcebf8f97 100644
--- a/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js
+++ b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js
@@ -33,6 +33,11 @@ export default function useWsProjects(initialProjects) {
},
},
};
+
+ if (lastMessage.finished) {
+ updatedProject.scm_revision = null;
+ }
+
setProjects([
...projects.slice(0, index),
updatedProject,