Adds sync button to project details page

This commit is contained in:
Alex Corey
2021-01-05 09:50:49 -05:00
parent 7e74f823f4
commit d1fcb96ee2
5 changed files with 71 additions and 60 deletions

View File

@@ -19,6 +19,7 @@ import CredentialChip from '../../../components/CredentialChip';
import { ProjectsAPI } from '../../../api'; import { ProjectsAPI } from '../../../api';
import { toTitleCase } from '../../../util/strings'; import { toTitleCase } from '../../../util/strings';
import useRequest, { useDismissableError } from '../../../util/useRequest'; import useRequest, { useDismissableError } from '../../../util/useRequest';
import ProjectSyncButton from '../shared/ProjectSyncButton';
function ProjectDetail({ project, i18n }) { function ProjectDetail({ project, i18n }) {
const { const {
@@ -148,27 +149,28 @@ function ProjectDetail({ project, i18n }) {
/> />
</DetailList> </DetailList>
<CardActionsRow> <CardActionsRow>
{summary_fields.user_capabilities && {summary_fields.user_capabilities?.edit && (
summary_fields.user_capabilities.edit && ( <Button
<Button aria-label={i18n._(t`edit`)}
aria-label={i18n._(t`edit`)} component={Link}
component={Link} to={`/projects/${id}/edit`}
to={`/projects/${id}/edit`} >
> {i18n._(t`Edit`)}
{i18n._(t`Edit`)} </Button>
</Button> )}
)} {summary_fields.user_capabilities?.start && (
{summary_fields.user_capabilities && <ProjectSyncButton projectId={project.id} />
summary_fields.user_capabilities.delete && ( )}
<DeleteButton {summary_fields.user_capabilities?.delete && (
name={name} <DeleteButton
modalTitle={i18n._(t`Delete Project`)} name={name}
onConfirm={deleteProject} modalTitle={i18n._(t`Delete Project`)}
isDisabled={isLoading} onConfirm={deleteProject}
> isDisabled={isLoading}
{i18n._(t`Delete`)} >
</DeleteButton> {i18n._(t`Delete`)}
)} </DeleteButton>
)}
</CardActionsRow> </CardActionsRow>
{/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */} {/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */}
{error && ( {error && (

View File

@@ -9,7 +9,12 @@ import { ProjectsAPI } from '../../../api';
import ProjectDetail from './ProjectDetail'; import ProjectDetail from './ProjectDetail';
jest.mock('../../../api'); jest.mock('../../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useRouteMatch: () => ({
url: '/projects/1/details',
}),
}));
describe('<ProjectDetail />', () => { describe('<ProjectDetail />', () => {
const mockProject = { const mockProject = {
id: 1, id: 1,
@@ -139,13 +144,19 @@ describe('<ProjectDetail />', () => {
); );
}); });
test('should show edit button for users with edit permission', async () => { test('should show edit and sync button for users with edit permission', async () => {
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />); const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
const editButton = await waitForElement( const editButton = await waitForElement(
wrapper, wrapper,
'ProjectDetail Button[aria-label="edit"]' 'ProjectDetail Button[aria-label="edit"]'
); );
const syncButton = await waitForElement(
wrapper,
'ProjectDetail Button[aria-label="Sync Project"]'
);
expect(editButton.text()).toEqual('Edit'); expect(editButton.text()).toEqual('Edit');
expect(syncButton.text()).toEqual('Sync');
expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`); expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`);
}); });
@@ -166,6 +177,9 @@ describe('<ProjectDetail />', () => {
expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe( expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe(
0 0
); );
expect(wrapper.find('ProjectDetail Button[aria-label="sync"]').length).toBe(
0
);
}); });
test('edit button should navigate to project edit', () => { test('edit button should navigate to project edit', () => {
@@ -180,6 +194,17 @@ describe('<ProjectDetail />', () => {
expect(history.location.pathname).toEqual('/projects/1/edit'); expect(history.location.pathname).toEqual('/projects/1/edit');
}); });
test('sync button should call api to syn project', async () => {
ProjectsAPI.readSync.mockResolvedValue({ data: { can_update: true } });
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
await act(() =>
wrapper
.find('ProjectDetail Button[aria-label="Sync Project"]')
.prop('onClick')(1)
);
expect(ProjectsAPI.sync).toHaveBeenCalledTimes(1);
});
test('expected api calls are made for delete', async () => { test('expected api calls are made for delete', async () => {
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />); const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]'); await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]');

View File

@@ -14,7 +14,7 @@ import {
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { PencilAltIcon, SyncIcon } from '@patternfly/react-icons'; import { PencilAltIcon } from '@patternfly/react-icons';
import styled from 'styled-components'; import styled from 'styled-components';
import { formatDateString, timeOfDay } from '../../../util/dates'; import { formatDateString, timeOfDay } from '../../../util/dates';
import { ProjectsAPI } from '../../../api'; import { ProjectsAPI } from '../../../api';
@@ -153,23 +153,10 @@ function ProjectListItem({
aria-labelledby={labelId} aria-labelledby={labelId}
id={labelId} id={labelId}
> >
{project.summary_fields.user_capabilities.start ? ( {project.summary_fields.user_capabilities.start && (
<Tooltip content={i18n._(t`Sync Project`)} position="top"> <Tooltip content={i18n._(t`Sync Project`)} position="top">
<ProjectSyncButton projectId={project.id}> <ProjectSyncButton projectId={project.id} />
{handleSync => (
<Button
isDisabled={isDisabled}
aria-label={i18n._(t`Sync Project`)}
variant="plain"
onClick={handleSync}
>
<SyncIcon />
</Button>
)}
</ProjectSyncButton>
</Tooltip> </Tooltip>
) : (
''
)} )}
{project.summary_fields.user_capabilities.edit ? ( {project.summary_fields.user_capabilities.edit ? (
<Tooltip content={i18n._(t`Edit Project`)} position="top"> <Tooltip content={i18n._(t`Edit Project`)} position="top">

View File

@@ -1,4 +1,8 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useRouteMatch } from 'react-router-dom';
import { Button } from '@patternfly/react-core';
import { SyncIcon } from '@patternfly/react-icons';
import { number } from 'prop-types'; import { number } from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -8,28 +12,27 @@ import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import { ProjectsAPI } from '../../../api'; import { ProjectsAPI } from '../../../api';
function ProjectSyncButton({ i18n, children, projectId }) { function ProjectSyncButton({ i18n, projectId }) {
const match = useRouteMatch();
const { request: handleSync, error: syncError } = useRequest( const { request: handleSync, error: syncError } = useRequest(
useCallback(async () => { useCallback(async () => {
const { data } = await ProjectsAPI.readSync(projectId); await ProjectsAPI.sync(projectId);
if (data.can_update) { }, [projectId]),
await ProjectsAPI.sync(projectId);
} else {
throw new Error(
i18n._(
t`You don't have the necessary permissions to sync this project.`
)
);
}
}, [i18n, projectId]),
null null
); );
const { error, dismissError } = useDismissableError(syncError); const { error, dismissError } = useDismissableError(syncError);
const isDetailsView = match.url.endsWith('/details');
return ( return (
<> <>
{children(handleSync)} <Button
aria-label={i18n._(t`Sync Project`)}
variant={isDetailsView ? 'secondary' : 'plain'}
onClick={handleSync}
>
{match.url.endsWith('/details') ? i18n._(t`Sync`) : <SyncIcon />}
</Button>
{error && ( {error && (
<AlertModal <AlertModal
isOpen={error} isOpen={error}

View File

@@ -10,11 +10,6 @@ jest.mock('../../../api');
describe('ProjectSyncButton', () => { describe('ProjectSyncButton', () => {
let wrapper; let wrapper;
ProjectsAPI.readSync.mockResolvedValue({
data: {
can_update: true,
},
});
const children = handleSync => ( const children = handleSync => (
<button type="submit" onClick={() => handleSync()} /> <button type="submit" onClick={() => handleSync()} />
@@ -43,8 +38,7 @@ describe('ProjectSyncButton', () => {
await act(async () => { await act(async () => {
button.prop('onClick')(); button.prop('onClick')();
}); });
expect(ProjectsAPI.readSync).toHaveBeenCalledWith(1);
await sleep(0);
expect(ProjectsAPI.sync).toHaveBeenCalledWith(1); expect(ProjectsAPI.sync).toHaveBeenCalledWith(1);
}); });
test('displays error modal after unsuccessful sync', async () => { test('displays error modal after unsuccessful sync', async () => {