diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx index 0d33d9dfef..7e7c9a07be 100644 --- a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx +++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx @@ -1,8 +1,8 @@ -import React, { useCallback } from 'react'; +import React, { Fragment, useCallback } from 'react'; import { Link, useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Button, List, ListItem } from '@patternfly/react-core'; +import { Button, List, ListItem, Tooltip } from '@patternfly/react-core'; import { Project } from '../../../types'; import { Config } from '../../../contexts/Config'; @@ -22,6 +22,9 @@ import { toTitleCase } from '../../../util/strings'; import useRequest, { useDismissableError } from '../../../util/useRequest'; import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails'; import ProjectSyncButton from '../shared/ProjectSyncButton'; +import StatusLabel from '../../../components/StatusLabel'; +import { formatDateString } from '../../../util/dates'; +import useWsProject from './useWsProject'; function ProjectDetail({ project, i18n }) { const { @@ -43,7 +46,7 @@ function ProjectDetail({ project, i18n }) { scm_update_cache_timeout, scm_url, summary_fields, - } = project; + } = useWsProject(project); const history = useHistory(); const { request: deleteProject, isLoading, error: deleteError } = useRequest( @@ -84,9 +87,52 @@ function ProjectDetail({ project, i18n }) { ); } + const generateLastJobTooltip = job => { + return ( + +
{i18n._(t`MOST RECENT SYNC`)}
+
+ {i18n._(t`JOB ID:`)} {job.id} +
+
+ {i18n._(t`STATUS:`)} {job.status.toUpperCase()} +
+ {job.finished && ( +
+ {i18n._(t`FINISHED:`)} {formatDateString(job.finished)} +
+ )} +
+ ); + }; + + let job = null; + + if (summary_fields?.current_job) { + job = summary_fields.current_job; + } else if (summary_fields?.last_job) { + job = summary_fields.last_job; + } + return ( + + + + + + ) + } + /> )} {summary_fields.user_capabilities?.start && ( - + )} {summary_fields.user_capabilities?.delete && ( { + setProject(initialProject); + }, [initialProject]); + + useEffect( + () => { + if ( + !project || + !lastMessage?.unified_job_id || + lastMessage.type !== 'project_update' + ) { + return; + } + + const updatedProject = { + ...project, + summary_fields: { + ...project.summary_fields, + current_job: { + id: lastMessage.unified_job_id, + status: lastMessage.status, + finished: lastMessage.finished, + }, + }, + }; + setProject(updatedProject); + }, + [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return project; +} diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/useWsProject.test.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/useWsProject.test.jsx new file mode 100644 index 0000000000..293f6ef986 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectDetail/useWsProject.test.jsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import useWsProject from './useWsProject'; + +function TestInner() { + return
; +} +function Test({ project }) { + const synced = useWsProject(project); + return ; +} + +describe('useWsProject', () => { + let debug; + let wrapper; + beforeEach(() => { + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; + }); + + afterEach(() => { + global.console.debug = debug; + }); + + test('should return project detail', async () => { + const project = { id: 1 }; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + expect(wrapper.find('TestInner').prop('project')).toEqual(project); + WS.clean(); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('ws://localhost/websocket/'); + + const project = { id: 1 }; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + WS.clean(); + }); + + test('should update project status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('ws://localhost/websocket/'); + + const project = { + id: 1, + summary_fields: { + last_job: { + id: 1, + status: 'successful', + finished: '2020-07-02T16:25:31.839071Z', + }, + }, + }; + + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + expect( + wrapper.find('TestInner').prop('project').summary_fields.current_job + ).toBeUndefined(); + expect( + wrapper.find('TestInner').prop('project').summary_fields.last_job.status + ).toEqual('successful'); + + await act(async () => { + mockServer.send( + JSON.stringify({ + group_name: 'jobs', + project_id: 1, + status: 'running', + type: 'project_update', + unified_job_id: 2, + unified_job_template_id: 1, + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('project').summary_fields.current_job + ).toEqual({ + id: 2, + status: 'running', + finished: undefined, + }); + + await act(async () => { + mockServer.send( + JSON.stringify({ + group_name: 'jobs', + project_id: 1, + status: 'successful', + type: 'project_update', + unified_job_id: 2, + unified_job_template_id: 1, + finished: '2020-07-02T16:28:31.839071Z', + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('project').summary_fields.current_job + ).toEqual({ + id: 2, + status: 'successful', + finished: '2020-07-02T16:28:31.839071Z', + }); + WS.clean(); + }); +}); diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx index ebdb83e36c..08058020f5 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx @@ -93,6 +93,14 @@ function ProjectListItem({ const missingExecutionEnvironment = project.custom_virtualenv && !project.default_environment; + let job = null; + + if (project.summary_fields?.current_job) { + job = project.summary_fields.current_job; + } else if (project.summary_fields?.last_job) { + job = project.summary_fields.last_job; + } + return ( <> @@ -132,14 +140,14 @@ function ProjectListItem({ )} - {project.summary_fields.last_job && ( + {job && ( - - + + )} @@ -169,7 +177,10 @@ function ProjectListItem({ visible={project.summary_fields.user_capabilities.start} tooltip={i18n._(t`Sync Project`)} > - + ', () => { description: '', name: 'Mock org', }, + last_job: { + id: 9000, + status: 'successful', + }, user_capabilities: { start: true, }, diff --git a/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js index 38303c9ed3..de863a7ee7 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js +++ b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js @@ -26,7 +26,7 @@ export default function useWsProjects(initialProjects) { ...project, summary_fields: { ...project.summary_fields, - last_job: { + current_job: { id: lastMessage.unified_job_id, status: lastMessage.status, finished: lastMessage.finished, diff --git a/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.test.jsx index 0a41c39689..426de68429 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.test.jsx @@ -64,7 +64,7 @@ describe('useWsProjects', () => { { id: 1, summary_fields: { - last_job: { + current_job: { id: 1, status: 'running', finished: null, @@ -87,7 +87,7 @@ describe('useWsProjects', () => { }) ); expect( - wrapper.find('TestInner').prop('projects')[0].summary_fields.last_job + wrapper.find('TestInner').prop('projects')[0].summary_fields.current_job .status ).toEqual('running'); await act(async () => { @@ -104,7 +104,7 @@ describe('useWsProjects', () => { wrapper.update(); expect( - wrapper.find('TestInner').prop('projects')[0].summary_fields.last_job + wrapper.find('TestInner').prop('projects')[0].summary_fields.current_job ).toEqual({ id: 12, status: 'successful', diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx index 2d316d5fa7..e8569620b6 100644 --- a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx +++ b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; import { useRouteMatch } from 'react-router-dom'; -import { Button } from '@patternfly/react-core'; +import { Button, Tooltip } from '@patternfly/react-core'; import { SyncIcon } from '@patternfly/react-icons'; import { number } from 'prop-types'; @@ -12,7 +12,7 @@ import AlertModal from '../../../components/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail'; import { ProjectsAPI } from '../../../api'; -function ProjectSyncButton({ i18n, projectId }) { +function ProjectSyncButton({ i18n, projectId, lastJobStatus = null }) { const match = useRouteMatch(); const { request: handleSync, error: syncError } = useRequest( @@ -24,16 +24,39 @@ function ProjectSyncButton({ i18n, projectId }) { const { error, dismissError } = useDismissableError(syncError); const isDetailsView = match.url.endsWith('/details'); + const isDisabled = ['pending', 'waiting', 'running'].includes(lastJobStatus); + return ( <> - + {isDisabled ? ( + +
+ +
+
+ ) : ( + + )} {error && ( { expect(ProjectsAPI.sync).toHaveBeenCalledWith(1); }); + test('disable button and set onClick to undefined on sync', async () => { + await act(async () => { + wrapper = mountWithContexts( + + {children} + + ); + }); + + expect(wrapper.find('Button').prop('isDisabled')).toBe(true); + expect(wrapper.find('Button').prop('onClick')).toBe(undefined); + }); + test('should render tooltip on sync', async () => { + await act(async () => { + wrapper = mountWithContexts( + + {children} + + ); + }); + + expect(wrapper.find('Tooltip')).toHaveLength(1); + expect(wrapper.find('Tooltip').prop('content')).toEqual( + 'This project is currently on sync and cannot be clicked until sync process completed' + ); + }); test('displays error modal after unsuccessful sync', async () => { ProjectsAPI.sync.mockRejectedValue( new Error({