Merge pull request #8633 from marshmalien/refactor-project-functional

Fix notification list toolbar filter keys and convert Project/* to functional components

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-12-01 13:52:22 +00:00
committed by GitHub
8 changed files with 323 additions and 360 deletions

View File

@@ -182,9 +182,13 @@ function NotificationList({
key: 'name__icontains', key: 'name__icontains',
isDefault: true, isDefault: true,
}, },
{
name: i18n._(t`Description`),
key: 'description__icontains',
},
{ {
name: i18n._(t`Type`), name: i18n._(t`Type`),
key: 'or__type', key: 'or__notification_type',
options: [ options: [
['email', i18n._(t`Email`)], ['email', i18n._(t`Email`)],
['grafana', i18n._(t`Grafana`)], ['grafana', i18n._(t`Grafana`)],
@@ -212,6 +216,10 @@ function NotificationList({
name: i18n._(t`Name`), name: i18n._(t`Name`),
key: 'name', key: 'name',
}, },
{
name: i18n._(t`Type`),
key: 'notification_type',
},
]} ]}
toolbarSearchableKeys={searchableKeys} toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys} toolbarRelatedSearchableKeys={relatedSearchableKeys}

View File

@@ -160,7 +160,7 @@ function Search({
const searchOptions = columns const searchOptions = columns
.filter(({ key }) => key !== searchKey) .filter(({ key }) => key !== searchKey)
.map(({ key, name }) => ( .map(({ key, name }) => (
<SelectOption key={key} value={name}> <SelectOption key={key} value={name} id={`select-option-${key}`}>
{name} {name}
</SelectOption> </SelectOption>
)); ));
@@ -177,6 +177,7 @@ function Search({
onSelect={handleDropdownSelect} onSelect={handleDropdownSelect}
selections={searchColumnName} selections={searchColumnName}
isOpen={isSearchDropdownOpen} isOpen={isSearchDropdownOpen}
ouiaId="simple-key-select"
> >
{searchOptions} {searchOptions}
</Select> </Select>
@@ -217,9 +218,14 @@ function Search({
})} })}
isOpen={isFilterDropdownOpen} isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`} placeholderText={`Filter By ${name}`}
ouiaId={`filter-by-${key}`}
> >
{options.map(([optionKey, optionLabel]) => ( {options.map(([optionKey, optionLabel]) => (
<SelectOption key={optionKey} value={optionKey}> <SelectOption
key={optionKey}
value={optionKey}
inputId={`select-option-${optionKey}`}
>
{optionLabel} {optionLabel}
</SelectOption> </SelectOption>
))} ))}
@@ -234,6 +240,7 @@ function Search({
selections={chipsByKey[key].chips[0]?.label} selections={chipsByKey[key].chips[0]?.label}
isOpen={isFilterDropdownOpen} isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`} placeholderText={`Filter By ${name}`}
ouiaId={`filter-by-${key}`}
> >
<SelectOption key="true" value="true"> <SelectOption key="true" value="true">
{booleanLabels.true || i18n._(t`Yes`)} {booleanLabels.true || i18n._(t`Yes`)}

View File

@@ -110,7 +110,27 @@ function NotificationTemplatesList({ i18n }) {
}, },
{ {
name: i18n._(t`Type`), name: i18n._(t`Type`),
key: 'notification_type', key: 'or__notification_type',
options: [
['email', i18n._(t`Email`)],
['grafana', i18n._(t`Grafana`)],
['hipchat', i18n._(t`Hipchat`)],
['irc', i18n._(t`IRC`)],
['mattermost', i18n._(t`Mattermost`)],
['pagerduty', i18n._(t`Pagerduty`)],
['rocketchat', i18n._(t`Rocket.Chat`)],
['slack', i18n._(t`Slack`)],
['twilio', i18n._(t`Twilio`)],
['webhook', i18n._(t`Webhook`)],
],
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username__icontains',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username__icontains',
}, },
]} ]}
toolbarSortColumns={[ toolbarSortColumns={[

View File

@@ -1,11 +1,22 @@
import React, { Component } from 'react'; import React, { useCallback, useEffect } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Switch, Route, withRouter, Redirect, Link } from 'react-router-dom'; import {
Switch,
Route,
withRouter,
Redirect,
Link,
useParams,
useLocation,
} from 'react-router-dom';
import { CaretLeftIcon } from '@patternfly/react-icons'; import { CaretLeftIcon } from '@patternfly/react-icons';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
import { useConfig } from '../../contexts/Config';
import useRequest from '../../util/useRequest';
import RoutedTabs from '../../components/RoutedTabs'; import RoutedTabs from '../../components/RoutedTabs';
import ContentError from '../../components/ContentError'; import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import NotificationList from '../../components/NotificationList'; import NotificationList from '../../components/NotificationList';
import { ResourceAccessList } from '../../components/ResourceAccessList'; import { ResourceAccessList } from '../../components/ResourceAccessList';
import { Schedules } from '../../components/Schedule'; import { Schedules } from '../../components/Schedule';
@@ -14,48 +25,18 @@ import ProjectEdit from './ProjectEdit';
import ProjectJobTemplatesList from './ProjectJobTemplatesList'; import ProjectJobTemplatesList from './ProjectJobTemplatesList';
import { OrganizationsAPI, ProjectsAPI } from '../../api'; import { OrganizationsAPI, ProjectsAPI } from '../../api';
class Project extends Component { function Project({ i18n, setBreadcrumb }) {
constructor(props) { const { me = {} } = useConfig();
super(props); const { id } = useParams();
const location = useLocation();
this.state = { const {
project: null, request: fetchProjectAndRoles,
hasContentLoading: true, result: { project, isNotifAdmin },
contentError: null, isLoading: hasContentLoading,
isInitialized: false, error: contentError,
isNotifAdmin: false, } = useRequest(
}; useCallback(async () => {
this.createSchedule = this.createSchedule.bind(this);
this.loadProject = this.loadProject.bind(this);
this.loadProjectAndRoles = this.loadProjectAndRoles.bind(this);
this.loadSchedules = this.loadSchedules.bind(this);
this.loadScheduleOptions = this.loadScheduleOptions.bind(this);
}
async componentDidMount() {
await this.loadProjectAndRoles();
this.setState({ isInitialized: true });
}
async componentDidUpdate(prevProps) {
const { location, match } = this.props;
const url = `/projects/${match.params.id}/`;
if (
prevProps.location.pathname.startsWith(url) &&
prevProps.location !== location &&
location.pathname === `${url}details`
) {
await this.loadProject();
}
}
async loadProjectAndRoles() {
const { match, setBreadcrumb } = this.props;
const id = parseInt(match.params.id, 10);
this.setState({ contentError: null, hasContentLoading: true });
try {
const [{ data }, notifAdminRes] = await Promise.all([ const [{ data }, notifAdminRes] = await Promise.all([
ProjectsAPI.readDetail(id), ProjectsAPI.readDetail(id),
OrganizationsAPI.read({ OrganizationsAPI.read({
@@ -63,188 +44,155 @@ class Project extends Component {
role_level: 'notification_admin_role', role_level: 'notification_admin_role',
}), }),
]); ]);
setBreadcrumb(data); return {
this.setState({
project: data, project: data,
isNotifAdmin: notifAdminRes.data.results.length > 0, isNotifAdmin: notifAdminRes.data.results.length > 0,
}); };
} catch (err) { }, [id]),
this.setState({ contentError: err }); {
} finally { project: null,
this.setState({ hasContentLoading: false }); notifAdminRes: null,
} }
} );
async loadProject() { useEffect(() => {
const { match, setBreadcrumb } = this.props; fetchProjectAndRoles();
const id = parseInt(match.params.id, 10); }, [fetchProjectAndRoles, location.pathname]);
this.setState({ contentError: null, hasContentLoading: true }); useEffect(() => {
try { if (project) {
const { data } = await ProjectsAPI.readDetail(id); setBreadcrumb(project);
setBreadcrumb(data);
this.setState({ project: data });
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
} }
} }, [project, setBreadcrumb]);
createSchedule(data) { function createSchedule(data) {
const { project } = this.state;
return ProjectsAPI.createSchedule(project.id, data); return ProjectsAPI.createSchedule(project.id, data);
} }
loadScheduleOptions() { function loadScheduleOptions() {
const { project } = this.state;
return ProjectsAPI.readScheduleOptions(project.id); return ProjectsAPI.readScheduleOptions(project.id);
} }
loadSchedules(params) { function loadSchedules(params) {
const { project } = this.state;
return ProjectsAPI.readSchedules(project.id, params); return ProjectsAPI.readSchedules(project.id, params);
} }
render() { const canSeeNotificationsTab = me.is_system_auditor || isNotifAdmin;
const { location, match, me, i18n, setBreadcrumb } = this.props; const canToggleNotifications = isNotifAdmin;
const tabsArray = [
const { {
project, name: (
contentError, <>
hasContentLoading, <CaretLeftIcon />
isInitialized, {i18n._(t`Back to Projects`)}
isNotifAdmin, </>
} = this.state; ),
const canSeeNotificationsTab = me.is_system_auditor || isNotifAdmin; link: `/projects`,
const canToggleNotifications = isNotifAdmin; id: 99,
},
const tabsArray = [ { name: i18n._(t`Details`), link: `/projects/${id}/details` },
{ { name: i18n._(t`Access`), link: `/projects/${id}/access` },
name: ( ];
<>
<CaretLeftIcon />
{i18n._(t`Back to Projects`)}
</>
),
link: `/projects`,
id: 99,
},
{ name: i18n._(t`Details`), link: `${match.url}/details` },
{ name: i18n._(t`Access`), link: `${match.url}/access` },
];
if (canSeeNotificationsTab) {
tabsArray.push({
name: i18n._(t`Notifications`),
link: `${match.url}/notifications`,
});
}
if (canSeeNotificationsTab) {
tabsArray.push({ tabsArray.push({
name: i18n._(t`Job Templates`), name: i18n._(t`Notifications`),
link: `${match.url}/job_templates`, link: `/projects/${id}/notifications`,
}); });
}
if (project?.scm_type) { tabsArray.push({
tabsArray.push({ name: i18n._(t`Job Templates`),
name: i18n._(t`Schedules`), link: `/projects/${id}/job_templates`,
link: `${match.url}/schedules`, });
});
}
tabsArray.forEach((tab, n) => { if (project?.scm_type) {
tab.id = n; tabsArray.push({
name: i18n._(t`Schedules`),
link: `/projects/${id}/schedules`,
}); });
}
let showCardHeader = true; tabsArray.forEach((tab, n) => {
tab.id = n;
if ( });
!isInitialized ||
location.pathname.endsWith('edit') ||
location.pathname.includes('schedules/')
) {
showCardHeader = false;
}
if (!hasContentLoading && contentError) {
return (
<PageSection>
<Card>
<ContentError error={contentError}>
{contentError.response.status === 404 && (
<span>
{i18n._(t`Project not found.`)}{' '}
<Link to="/projects">{i18n._(t`View all Projects.`)}</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
if (contentError) {
return ( return (
<PageSection> <PageSection>
<Card> <Card>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />} <ContentError error={contentError}>
{contentError.response.status === 404 && (
<span>
{i18n._(t`Project not found.`)}{' '}
<Link to="/projects">{i18n._(t`View all Projects.`)}</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
let showCardHeader = true;
if (['edit', 'schedules/'].some(name => location.pathname.includes(name))) {
showCardHeader = false;
}
return (
<PageSection>
<Card>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
{hasContentLoading && <ContentLoading />}
{!hasContentLoading && project && (
<Switch> <Switch>
<Redirect from="/projects/:id" to="/projects/:id/details" exact /> <Redirect from="/projects/:id" to="/projects/:id/details" exact />
{project && ( <Route path="/projects/:id/edit">
<Route path="/projects/:id/edit"> <ProjectEdit project={project} />
<ProjectEdit project={project} /> </Route>
</Route> <Route path="/projects/:id/details">
)} <ProjectDetail project={project} />
{project && ( </Route>
<Route path="/projects/:id/details"> <Route path="/projects/:id/access">
<ProjectDetail project={project} /> <ResourceAccessList resource={project} apiModel={ProjectsAPI} />
</Route> </Route>
)}
{project && (
<Route path="/projects/:id/access">
<ResourceAccessList resource={project} apiModel={ProjectsAPI} />
</Route>
)}
{canSeeNotificationsTab && ( {canSeeNotificationsTab && (
<Route path="/projects/:id/notifications"> <Route path="/projects/:id/notifications">
<NotificationList <NotificationList
id={Number(match.params.id)} id={Number(id)}
canToggleNotifications={canToggleNotifications} canToggleNotifications={canToggleNotifications}
apiModel={ProjectsAPI} apiModel={ProjectsAPI}
/> />
</Route> </Route>
)} )}
<Route path="/projects/:id/job_templates"> <Route path="/projects/:id/job_templates">
<ProjectJobTemplatesList id={Number(match.params.id)} /> <ProjectJobTemplatesList />
</Route> </Route>
{project?.scm_type && project.scm_type !== '' && ( {project?.scm_type && project.scm_type !== '' && (
<Route path="/projects/:id/schedules"> <Route path="/projects/:id/schedules">
<Schedules <Schedules
setBreadcrumb={setBreadcrumb} setBreadcrumb={setBreadcrumb}
unifiedJobTemplate={project} unifiedJobTemplate={project}
createSchedule={this.createSchedule} createSchedule={createSchedule}
loadSchedules={this.loadSchedules} loadSchedules={loadSchedules}
loadScheduleOptions={this.loadScheduleOptions} loadScheduleOptions={loadScheduleOptions}
/> />
</Route> </Route>
)} )}
<Route key="not-found" path="*"> <Route key="not-found" path="*">
{!hasContentLoading && ( <ContentError isNotFound>
<ContentError isNotFound> {id && (
{match.params.id && ( <Link to={`/projects/${id}/details`}>
<Link to={`/projects/${match.params.id}/details`}> {i18n._(t`View Project Details`)}
{i18n._(t`View Project Details`)} </Link>
</Link> )}
)} </ContentError>
</ContentError>
)}
</Route> </Route>
,
</Switch> </Switch>
</Card> )}
</PageSection> </Card>
); </PageSection>
} );
} }
export default withI18n()(withRouter(Project)); export default withI18n()(withRouter(Project));

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { OrganizationsAPI, ProjectsAPI } from '../../api'; import { OrganizationsAPI, ProjectsAPI } from '../../api';
import { import {
@@ -28,29 +29,34 @@ async function getOrganizations() {
} }
describe('<Project />', () => { describe('<Project />', () => {
test('initially renders succesfully', () => { let wrapper;
test('initially renders successfully', async () => {
ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails }); ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails });
OrganizationsAPI.read.mockImplementation(getOrganizations); OrganizationsAPI.read.mockImplementation(getOrganizations);
mountWithContexts(<Project setBreadcrumb={() => {}} me={mockMe} />); await act(async () => {
mountWithContexts(<Project setBreadcrumb={() => {}} me={mockMe} />);
});
}); });
test('notifications tab shown for admins', async done => { test('notifications tab shown for admins', async () => {
ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails }); ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails });
OrganizationsAPI.read.mockImplementation(getOrganizations); OrganizationsAPI.read.mockImplementation(getOrganizations);
const wrapper = mountWithContexts( await act(async () => {
<Project setBreadcrumb={() => {}} me={mockMe} /> wrapper = mountWithContexts(
); <Project setBreadcrumb={() => {}} me={mockMe} />
);
});
const tabs = await waitForElement( const tabs = await waitForElement(
wrapper, wrapper,
'.pf-c-tabs__item', '.pf-c-tabs__item',
el => el.length === 6 el => el.length === 6
); );
expect(tabs.at(3).text()).toEqual('Notifications'); expect(tabs.at(3).text()).toEqual('Notifications');
done();
}); });
test('notifications tab hidden with reduced permissions', async done => { test('notifications tab hidden with reduced permissions', async () => {
ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails }); ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails });
OrganizationsAPI.read.mockResolvedValue({ OrganizationsAPI.read.mockResolvedValue({
count: 0, count: 0,
@@ -58,20 +64,20 @@ describe('<Project />', () => {
previous: null, previous: null,
data: { results: [] }, data: { results: [] },
}); });
await act(async () => {
const wrapper = mountWithContexts( wrapper = mountWithContexts(
<Project setBreadcrumb={() => {}} me={mockMe} /> <Project setBreadcrumb={() => {}} me={mockMe} />
); );
});
const tabs = await waitForElement( const tabs = await waitForElement(
wrapper, wrapper,
'.pf-c-tabs__item', '.pf-c-tabs__item',
el => el.length === 5 el => el.length === 5
); );
tabs.forEach(tab => expect(tab.text()).not.toEqual('Notifications')); tabs.forEach(tab => expect(tab.text()).not.toEqual('Notifications'));
done();
}); });
test('schedules tab shown for scm based projects.', async done => { test('schedules tab shown for scm based projects.', async () => {
ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails }); ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails });
OrganizationsAPI.read.mockResolvedValue({ OrganizationsAPI.read.mockResolvedValue({
count: 0, count: 0,
@@ -80,19 +86,21 @@ describe('<Project />', () => {
data: { results: [] }, data: { results: [] },
}); });
const wrapper = mountWithContexts( await act(async () => {
<Project setBreadcrumb={() => {}} me={mockMe} /> wrapper = mountWithContexts(
); <Project setBreadcrumb={() => {}} me={mockMe} />
);
});
const tabs = await waitForElement( const tabs = await waitForElement(
wrapper, wrapper,
'.pf-c-tabs__item', '.pf-c-tabs__item',
el => el.length === 5 el => el.length === 5
); );
expect(tabs.at(4).text()).toEqual('Schedules'); expect(tabs.at(4).text()).toEqual('Schedules');
done();
}); });
test('schedules tab hidden for manual projects.', async done => { test('schedules tab hidden for manual projects.', async () => {
const manualDetails = Object.assign(mockDetails, { scm_type: '' }); const manualDetails = Object.assign(mockDetails, { scm_type: '' });
ProjectsAPI.readDetail.mockResolvedValue({ data: manualDetails }); ProjectsAPI.readDetail.mockResolvedValue({ data: manualDetails });
OrganizationsAPI.read.mockResolvedValue({ OrganizationsAPI.read.mockResolvedValue({
@@ -102,40 +110,44 @@ describe('<Project />', () => {
data: { results: [] }, data: { results: [] },
}); });
const wrapper = mountWithContexts( await act(async () => {
<Project setBreadcrumb={() => {}} me={mockMe} /> wrapper = mountWithContexts(
); <Project setBreadcrumb={() => {}} me={mockMe} />
);
});
const tabs = await waitForElement( const tabs = await waitForElement(
wrapper, wrapper,
'.pf-c-tabs__item', '.pf-c-tabs__item',
el => el.length === 4 el => el.length === 4
); );
tabs.forEach(tab => expect(tab.text()).not.toEqual('Schedules')); tabs.forEach(tab => expect(tab.text()).not.toEqual('Schedules'));
done();
}); });
test('should show content error when user attempts to navigate to erroneous route', async () => { test('should show content error when user attempts to navigate to erroneous route', async () => {
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: ['/projects/1/foobar'], initialEntries: ['/projects/1/foobar'],
}); });
const wrapper = mountWithContexts( await act(async () => {
<Project setBreadcrumb={() => {}} me={mockMe} />, wrapper = mountWithContexts(
{ <Project setBreadcrumb={() => {}} me={mockMe} />,
context: { {
router: { context: {
history, router: {
route: { history,
location: history.location, route: {
match: { location: history.location,
params: { id: 1 }, match: {
url: '/projects/1/foobar', params: { id: 1 },
path: '/project/1/foobar', url: '/projects/1/foobar',
path: '/project/1/foobar',
},
}, },
}, },
}, },
}, }
} );
); });
await waitForElement(wrapper, 'ContentError', el => el.length === 1); await waitForElement(wrapper, 'ContentError', el => el.length === 1);
}); });
}); });

View File

@@ -1,90 +1,64 @@
import React, { Component, Fragment } from 'react'; import React, { useState, useCallback } from 'react';
import { Route, withRouter, Switch } from 'react-router-dom'; import { Route, withRouter, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
import ProjectsList from './ProjectList/ProjectList'; import ProjectsList from './ProjectList/ProjectList';
import ProjectAdd from './ProjectAdd/ProjectAdd'; import ProjectAdd from './ProjectAdd/ProjectAdd';
import Project from './Project'; import Project from './Project';
class Projects extends Component { function Projects({ i18n }) {
constructor(props) { const [breadcrumbConfig, setBreadcrumbConfig] = useState({
super(props); '/projects': i18n._(t`Projects`),
'/projects/add': i18n._(t`Create New Project`),
});
const { i18n } = props; const buildBreadcrumbConfig = useCallback(
(project, nested) => {
this.state = { if (!project) {
breadcrumbConfig: { return;
}
const projectSchedulesPath = `/projects/${project.id}/schedules`;
setBreadcrumbConfig({
'/projects': i18n._(t`Projects`), '/projects': i18n._(t`Projects`),
'/projects/add': i18n._(t`Create New Project`), '/projects/add': i18n._(t`Create New Project`),
}, [`/projects/${project.id}`]: `${project.name}`,
}; [`/projects/${project.id}/edit`]: i18n._(t`Edit Details`),
} [`/projects/${project.id}/details`]: i18n._(t`Details`),
[`/projects/${project.id}/access`]: i18n._(t`Access`),
[`/projects/${project.id}/notifications`]: i18n._(t`Notifications`),
[`/projects/${project.id}/job_templates`]: i18n._(t`Job Templates`),
setBreadcrumbConfig = (project, nested) => { [`${projectSchedulesPath}`]: i18n._(t`Schedules`),
const { i18n } = this.props; [`${projectSchedulesPath}/add`]: i18n._(t`Create New Schedule`),
[`${projectSchedulesPath}/${nested?.id}`]: `${nested?.name}`,
[`${projectSchedulesPath}/${nested?.id}/details`]: i18n._(
t`Schedule Details`
),
[`${projectSchedulesPath}/${nested?.id}/edit`]: i18n._(t`Edit Details`),
});
},
[i18n]
);
if (!project) { return (
return; <>
} <Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch>
const projectSchedulesPath = `/projects/${project.id}/schedules`; <Route path="/projects/add">
<ProjectAdd />
const breadcrumbConfig = { </Route>
'/projects': i18n._(t`Projects`), <Route path="/projects/:id">
'/projects/add': i18n._(t`Create New Project`), <Project setBreadcrumb={buildBreadcrumbConfig} />
[`/projects/${project.id}`]: `${project.name}`, </Route>
[`/projects/${project.id}/edit`]: i18n._(t`Edit Details`), <Route path="/projects">
[`/projects/${project.id}/details`]: i18n._(t`Details`), <ProjectsList />
[`/projects/${project.id}/access`]: i18n._(t`Access`), </Route>
[`/projects/${project.id}/notifications`]: i18n._(t`Notifications`), </Switch>
[`/projects/${project.id}/job_templates`]: i18n._(t`Job Templates`), </>
);
[`${projectSchedulesPath}`]: i18n._(t`Schedules`),
[`${projectSchedulesPath}/add`]: i18n._(t`Create New Schedule`),
[`${projectSchedulesPath}/${nested?.id}`]: `${nested?.name}`,
[`${projectSchedulesPath}/${nested?.id}/details`]: i18n._(
t`Schedule Details`
),
[`${projectSchedulesPath}/${nested?.id}/edit`]: i18n._(t`Edit Details`),
};
this.setState({ breadcrumbConfig });
};
render() {
const { match, history, location } = this.props;
const { breadcrumbConfig } = this.state;
return (
<Fragment>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path={`${match.path}/add`}>
<ProjectAdd />
</Route>
<Route path={`${match.path}/:id`}>
<Config>
{({ me }) => (
<Project
history={history}
location={location}
setBreadcrumb={this.setBreadcrumbConfig}
me={me || {}}
/>
)}
</Config>
</Route>
<Route path={`${match.path}`}>
<ProjectsList />
</Route>
</Switch>
</Fragment>
);
}
} }
export { Projects as _Projects }; export { Projects as _Projects };

View File

@@ -1,70 +1,52 @@
import React, { Fragment } from 'react'; import React, { useCallback } from 'react';
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';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import { ProjectsAPI } from '../../../api'; import { ProjectsAPI } from '../../../api';
class ProjectSyncButton extends React.Component { function ProjectSyncButton({ i18n, children, projectId }) {
static propTypes = { const { request: handleSync, error: syncError } = useRequest(
projectId: number.isRequired, useCallback(async () => {
}; const { data } = await ProjectsAPI.readSync(projectId);
if (data.can_update) {
constructor(props) {
super(props);
this.state = {
syncError: null,
};
this.handleSync = this.handleSync.bind(this);
this.handleSyncErrorClose = this.handleSyncErrorClose.bind(this);
}
handleSyncErrorClose() {
this.setState({ syncError: null });
}
async handleSync() {
const { i18n, projectId } = this.props;
try {
const { data: syncConfig } = await ProjectsAPI.readSync(projectId);
if (syncConfig.can_update) {
await ProjectsAPI.sync(projectId); await ProjectsAPI.sync(projectId);
} else { } else {
this.setState({ throw new Error(
syncError: i18n._( i18n._(
t`You don't have the necessary permissions to sync this project.` t`You don't have the necessary permissions to sync this project.`
), )
}); );
} }
} catch (err) { }, [i18n, projectId]),
this.setState({ syncError: err }); null
} );
}
render() { const { error, dismissError } = useDismissableError(syncError);
const { syncError } = this.state;
const { i18n, children } = this.props; return (
return ( <>
<Fragment> {children(handleSync)}
{children(this.handleSync)} {error && (
{syncError && ( <AlertModal
<AlertModal isOpen={error}
isOpen={syncError} variant="error"
variant="error" title={i18n._(t`Error!`)}
title={i18n._(t`Error!`)} onClose={dismissError}
onClose={this.handleSyncErrorClose} >
> {i18n._(t`Failed to sync project.`)}
{i18n._(t`Failed to sync job.`)} <ErrorDetail error={error} />
<ErrorDetail error={syncError} /> </AlertModal>
</AlertModal> )}
)} </>
</Fragment> );
);
}
} }
ProjectSyncButton.propTypes = {
projectId: number.isRequired,
};
export default withI18n()(ProjectSyncButton); export default withI18n()(ProjectSyncButton);

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { sleep } from '../../../../testUtils/testUtils'; import { sleep } from '../../../../testUtils/testUtils';
@@ -8,6 +9,7 @@ import { ProjectsAPI } from '../../../api';
jest.mock('../../../api'); jest.mock('../../../api');
describe('ProjectSyncButton', () => { describe('ProjectSyncButton', () => {
let wrapper;
ProjectsAPI.readSync.mockResolvedValue({ ProjectsAPI.readSync.mockResolvedValue({
data: { data: {
can_update: true, can_update: true,
@@ -18,29 +20,34 @@ describe('ProjectSyncButton', () => {
<button type="submit" onClick={() => handleSync()} /> <button type="submit" onClick={() => handleSync()} />
); );
test('renders the expected content', () => { test('renders the expected content', async () => {
const wrapper = mountWithContexts( await act(async () => {
<ProjectSyncButton projectId={1}>{children}</ProjectSyncButton> wrapper = mountWithContexts(
); <ProjectSyncButton projectId={1}>{children}</ProjectSyncButton>
);
});
expect(wrapper).toHaveLength(1); expect(wrapper).toHaveLength(1);
}); });
test('correct api calls are made on sync', async done => { test('correct api calls are made on sync', async () => {
ProjectsAPI.sync.mockResolvedValue({ ProjectsAPI.sync.mockResolvedValue({
data: { data: {
id: 9000, id: 9000,
}, },
}); });
const wrapper = mountWithContexts( await act(async () => {
<ProjectSyncButton projectId={1}>{children}</ProjectSyncButton> wrapper = mountWithContexts(
); <ProjectSyncButton projectId={1}>{children}</ProjectSyncButton>
);
});
const button = wrapper.find('button'); const button = wrapper.find('button');
button.prop('onClick')(); await act(async () => {
button.prop('onClick')();
});
expect(ProjectsAPI.readSync).toHaveBeenCalledWith(1); expect(ProjectsAPI.readSync).toHaveBeenCalledWith(1);
await sleep(0); await sleep(0);
expect(ProjectsAPI.sync).toHaveBeenCalledWith(1); expect(ProjectsAPI.sync).toHaveBeenCalledWith(1);
done();
}); });
test('displays error modal after unsuccessful sync', async done => { test('displays error modal after unsuccessful sync', async () => {
ProjectsAPI.sync.mockRejectedValue( ProjectsAPI.sync.mockRejectedValue(
new Error({ new Error({
response: { response: {
@@ -53,18 +60,23 @@ describe('ProjectSyncButton', () => {
}, },
}) })
); );
const wrapper = mountWithContexts( await act(async () => {
<ProjectSyncButton projectId={1}>{children}</ProjectSyncButton> wrapper = mountWithContexts(
); <ProjectSyncButton projectId={1}>{children}</ProjectSyncButton>
);
});
expect(wrapper.find('Modal').length).toBe(0); expect(wrapper.find('Modal').length).toBe(0);
wrapper.find('button').prop('onClick')(); await act(async () => {
wrapper.find('button').prop('onClick')();
});
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
expect(wrapper.find('Modal').length).toBe(1); expect(wrapper.find('Modal').length).toBe(1);
wrapper.find('ModalBoxCloseButton').simulate('click'); await act(async () => {
wrapper.find('ModalBoxCloseButton').simulate('click');
});
await sleep(0); await sleep(0);
wrapper.update(); wrapper.update();
expect(wrapper.find('Modal').length).toBe(0); expect(wrapper.find('Modal').length).toBe(0);
done();
}); });
}); });