mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
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:
commit
7ca35634a7
@ -182,9 +182,13 @@ function NotificationList({
|
||||
key: 'name__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Description`),
|
||||
key: 'description__icontains',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Type`),
|
||||
key: 'or__type',
|
||||
key: 'or__notification_type',
|
||||
options: [
|
||||
['email', i18n._(t`Email`)],
|
||||
['grafana', i18n._(t`Grafana`)],
|
||||
@ -212,6 +216,10 @@ function NotificationList({
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Type`),
|
||||
key: 'notification_type',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
|
||||
@ -160,7 +160,7 @@ function Search({
|
||||
const searchOptions = columns
|
||||
.filter(({ key }) => key !== searchKey)
|
||||
.map(({ key, name }) => (
|
||||
<SelectOption key={key} value={name}>
|
||||
<SelectOption key={key} value={name} id={`select-option-${key}`}>
|
||||
{name}
|
||||
</SelectOption>
|
||||
));
|
||||
@ -177,6 +177,7 @@ function Search({
|
||||
onSelect={handleDropdownSelect}
|
||||
selections={searchColumnName}
|
||||
isOpen={isSearchDropdownOpen}
|
||||
ouiaId="simple-key-select"
|
||||
>
|
||||
{searchOptions}
|
||||
</Select>
|
||||
@ -217,9 +218,14 @@ function Search({
|
||||
})}
|
||||
isOpen={isFilterDropdownOpen}
|
||||
placeholderText={`Filter By ${name}`}
|
||||
ouiaId={`filter-by-${key}`}
|
||||
>
|
||||
{options.map(([optionKey, optionLabel]) => (
|
||||
<SelectOption key={optionKey} value={optionKey}>
|
||||
<SelectOption
|
||||
key={optionKey}
|
||||
value={optionKey}
|
||||
inputId={`select-option-${optionKey}`}
|
||||
>
|
||||
{optionLabel}
|
||||
</SelectOption>
|
||||
))}
|
||||
@ -234,6 +240,7 @@ function Search({
|
||||
selections={chipsByKey[key].chips[0]?.label}
|
||||
isOpen={isFilterDropdownOpen}
|
||||
placeholderText={`Filter By ${name}`}
|
||||
ouiaId={`filter-by-${key}`}
|
||||
>
|
||||
<SelectOption key="true" value="true">
|
||||
{booleanLabels.true || i18n._(t`Yes`)}
|
||||
|
||||
@ -110,7 +110,27 @@ function NotificationTemplatesList({ i18n }) {
|
||||
},
|
||||
{
|
||||
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={[
|
||||
|
||||
@ -1,11 +1,22 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
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 { Card, PageSection } from '@patternfly/react-core';
|
||||
import { useConfig } from '../../contexts/Config';
|
||||
import useRequest from '../../util/useRequest';
|
||||
import RoutedTabs from '../../components/RoutedTabs';
|
||||
import ContentError from '../../components/ContentError';
|
||||
import ContentLoading from '../../components/ContentLoading';
|
||||
import NotificationList from '../../components/NotificationList';
|
||||
import { ResourceAccessList } from '../../components/ResourceAccessList';
|
||||
import { Schedules } from '../../components/Schedule';
|
||||
@ -14,48 +25,18 @@ import ProjectEdit from './ProjectEdit';
|
||||
import ProjectJobTemplatesList from './ProjectJobTemplatesList';
|
||||
import { OrganizationsAPI, ProjectsAPI } from '../../api';
|
||||
|
||||
class Project extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
function Project({ i18n, setBreadcrumb }) {
|
||||
const { me = {} } = useConfig();
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
|
||||
this.state = {
|
||||
project: null,
|
||||
hasContentLoading: true,
|
||||
contentError: null,
|
||||
isInitialized: false,
|
||||
isNotifAdmin: false,
|
||||
};
|
||||
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 {
|
||||
request: fetchProjectAndRoles,
|
||||
result: { project, isNotifAdmin },
|
||||
isLoading: hasContentLoading,
|
||||
error: contentError,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const [{ data }, notifAdminRes] = await Promise.all([
|
||||
ProjectsAPI.readDetail(id),
|
||||
OrganizationsAPI.read({
|
||||
@ -63,188 +44,155 @@ class Project extends Component {
|
||||
role_level: 'notification_admin_role',
|
||||
}),
|
||||
]);
|
||||
setBreadcrumb(data);
|
||||
this.setState({
|
||||
return {
|
||||
project: data,
|
||||
isNotifAdmin: notifAdminRes.data.results.length > 0,
|
||||
});
|
||||
} catch (err) {
|
||||
this.setState({ contentError: err });
|
||||
} finally {
|
||||
this.setState({ hasContentLoading: false });
|
||||
};
|
||||
}, [id]),
|
||||
{
|
||||
project: null,
|
||||
notifAdminRes: null,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async loadProject() {
|
||||
const { match, setBreadcrumb } = this.props;
|
||||
const id = parseInt(match.params.id, 10);
|
||||
useEffect(() => {
|
||||
fetchProjectAndRoles();
|
||||
}, [fetchProjectAndRoles, location.pathname]);
|
||||
|
||||
this.setState({ contentError: null, hasContentLoading: true });
|
||||
try {
|
||||
const { data } = await ProjectsAPI.readDetail(id);
|
||||
setBreadcrumb(data);
|
||||
this.setState({ project: data });
|
||||
} catch (err) {
|
||||
this.setState({ contentError: err });
|
||||
} finally {
|
||||
this.setState({ hasContentLoading: false });
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setBreadcrumb(project);
|
||||
}
|
||||
}
|
||||
}, [project, setBreadcrumb]);
|
||||
|
||||
createSchedule(data) {
|
||||
const { project } = this.state;
|
||||
function createSchedule(data) {
|
||||
return ProjectsAPI.createSchedule(project.id, data);
|
||||
}
|
||||
|
||||
loadScheduleOptions() {
|
||||
const { project } = this.state;
|
||||
function loadScheduleOptions() {
|
||||
return ProjectsAPI.readScheduleOptions(project.id);
|
||||
}
|
||||
|
||||
loadSchedules(params) {
|
||||
const { project } = this.state;
|
||||
function loadSchedules(params) {
|
||||
return ProjectsAPI.readSchedules(project.id, params);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { location, match, me, i18n, setBreadcrumb } = this.props;
|
||||
|
||||
const {
|
||||
project,
|
||||
contentError,
|
||||
hasContentLoading,
|
||||
isInitialized,
|
||||
isNotifAdmin,
|
||||
} = this.state;
|
||||
const canSeeNotificationsTab = me.is_system_auditor || isNotifAdmin;
|
||||
const canToggleNotifications = isNotifAdmin;
|
||||
|
||||
const tabsArray = [
|
||||
{
|
||||
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`,
|
||||
});
|
||||
}
|
||||
const canSeeNotificationsTab = me.is_system_auditor || isNotifAdmin;
|
||||
const canToggleNotifications = isNotifAdmin;
|
||||
const tabsArray = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{i18n._(t`Back to Projects`)}
|
||||
</>
|
||||
),
|
||||
link: `/projects`,
|
||||
id: 99,
|
||||
},
|
||||
{ name: i18n._(t`Details`), link: `/projects/${id}/details` },
|
||||
{ name: i18n._(t`Access`), link: `/projects/${id}/access` },
|
||||
];
|
||||
|
||||
if (canSeeNotificationsTab) {
|
||||
tabsArray.push({
|
||||
name: i18n._(t`Job Templates`),
|
||||
link: `${match.url}/job_templates`,
|
||||
name: i18n._(t`Notifications`),
|
||||
link: `/projects/${id}/notifications`,
|
||||
});
|
||||
}
|
||||
|
||||
if (project?.scm_type) {
|
||||
tabsArray.push({
|
||||
name: i18n._(t`Schedules`),
|
||||
link: `${match.url}/schedules`,
|
||||
});
|
||||
}
|
||||
tabsArray.push({
|
||||
name: i18n._(t`Job Templates`),
|
||||
link: `/projects/${id}/job_templates`,
|
||||
});
|
||||
|
||||
tabsArray.forEach((tab, n) => {
|
||||
tab.id = n;
|
||||
if (project?.scm_type) {
|
||||
tabsArray.push({
|
||||
name: i18n._(t`Schedules`),
|
||||
link: `/projects/${id}/schedules`,
|
||||
});
|
||||
}
|
||||
|
||||
let showCardHeader = true;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
tabsArray.forEach((tab, n) => {
|
||||
tab.id = n;
|
||||
});
|
||||
|
||||
if (contentError) {
|
||||
return (
|
||||
<PageSection>
|
||||
<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>
|
||||
<Redirect from="/projects/:id" to="/projects/:id/details" exact />
|
||||
{project && (
|
||||
<Route path="/projects/:id/edit">
|
||||
<ProjectEdit project={project} />
|
||||
</Route>
|
||||
)}
|
||||
{project && (
|
||||
<Route path="/projects/:id/details">
|
||||
<ProjectDetail project={project} />
|
||||
</Route>
|
||||
)}
|
||||
{project && (
|
||||
<Route path="/projects/:id/access">
|
||||
<ResourceAccessList resource={project} apiModel={ProjectsAPI} />
|
||||
</Route>
|
||||
)}
|
||||
<Route path="/projects/:id/edit">
|
||||
<ProjectEdit project={project} />
|
||||
</Route>
|
||||
<Route path="/projects/:id/details">
|
||||
<ProjectDetail project={project} />
|
||||
</Route>
|
||||
<Route path="/projects/:id/access">
|
||||
<ResourceAccessList resource={project} apiModel={ProjectsAPI} />
|
||||
</Route>
|
||||
{canSeeNotificationsTab && (
|
||||
<Route path="/projects/:id/notifications">
|
||||
<NotificationList
|
||||
id={Number(match.params.id)}
|
||||
id={Number(id)}
|
||||
canToggleNotifications={canToggleNotifications}
|
||||
apiModel={ProjectsAPI}
|
||||
/>
|
||||
</Route>
|
||||
)}
|
||||
<Route path="/projects/:id/job_templates">
|
||||
<ProjectJobTemplatesList id={Number(match.params.id)} />
|
||||
<ProjectJobTemplatesList />
|
||||
</Route>
|
||||
{project?.scm_type && project.scm_type !== '' && (
|
||||
<Route path="/projects/:id/schedules">
|
||||
<Schedules
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
unifiedJobTemplate={project}
|
||||
createSchedule={this.createSchedule}
|
||||
loadSchedules={this.loadSchedules}
|
||||
loadScheduleOptions={this.loadScheduleOptions}
|
||||
createSchedule={createSchedule}
|
||||
loadSchedules={loadSchedules}
|
||||
loadScheduleOptions={loadScheduleOptions}
|
||||
/>
|
||||
</Route>
|
||||
)}
|
||||
<Route key="not-found" path="*">
|
||||
{!hasContentLoading && (
|
||||
<ContentError isNotFound>
|
||||
{match.params.id && (
|
||||
<Link to={`/projects/${match.params.id}/details`}>
|
||||
{i18n._(t`View Project Details`)}
|
||||
</Link>
|
||||
)}
|
||||
</ContentError>
|
||||
)}
|
||||
<ContentError isNotFound>
|
||||
{id && (
|
||||
<Link to={`/projects/${id}/details`}>
|
||||
{i18n._(t`View Project Details`)}
|
||||
</Link>
|
||||
)}
|
||||
</ContentError>
|
||||
</Route>
|
||||
,
|
||||
</Switch>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(withRouter(Project));
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { OrganizationsAPI, ProjectsAPI } from '../../api';
|
||||
import {
|
||||
@ -28,29 +29,34 @@ async function getOrganizations() {
|
||||
}
|
||||
|
||||
describe('<Project />', () => {
|
||||
test('initially renders succesfully', () => {
|
||||
let wrapper;
|
||||
|
||||
test('initially renders successfully', async () => {
|
||||
ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails });
|
||||
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 });
|
||||
OrganizationsAPI.read.mockImplementation(getOrganizations);
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<Project setBreadcrumb={() => {}} me={mockMe} />
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Project setBreadcrumb={() => {}} me={mockMe} />
|
||||
);
|
||||
});
|
||||
const tabs = await waitForElement(
|
||||
wrapper,
|
||||
'.pf-c-tabs__item',
|
||||
el => el.length === 6
|
||||
);
|
||||
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 });
|
||||
OrganizationsAPI.read.mockResolvedValue({
|
||||
count: 0,
|
||||
@ -58,20 +64,20 @@ describe('<Project />', () => {
|
||||
previous: null,
|
||||
data: { results: [] },
|
||||
});
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<Project setBreadcrumb={() => {}} me={mockMe} />
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Project setBreadcrumb={() => {}} me={mockMe} />
|
||||
);
|
||||
});
|
||||
const tabs = await waitForElement(
|
||||
wrapper,
|
||||
'.pf-c-tabs__item',
|
||||
el => el.length === 5
|
||||
);
|
||||
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 });
|
||||
OrganizationsAPI.read.mockResolvedValue({
|
||||
count: 0,
|
||||
@ -80,19 +86,21 @@ describe('<Project />', () => {
|
||||
data: { results: [] },
|
||||
});
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<Project setBreadcrumb={() => {}} me={mockMe} />
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Project setBreadcrumb={() => {}} me={mockMe} />
|
||||
);
|
||||
});
|
||||
|
||||
const tabs = await waitForElement(
|
||||
wrapper,
|
||||
'.pf-c-tabs__item',
|
||||
el => el.length === 5
|
||||
);
|
||||
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: '' });
|
||||
ProjectsAPI.readDetail.mockResolvedValue({ data: manualDetails });
|
||||
OrganizationsAPI.read.mockResolvedValue({
|
||||
@ -102,40 +110,44 @@ describe('<Project />', () => {
|
||||
data: { results: [] },
|
||||
});
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<Project setBreadcrumb={() => {}} me={mockMe} />
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Project setBreadcrumb={() => {}} me={mockMe} />
|
||||
);
|
||||
});
|
||||
|
||||
const tabs = await waitForElement(
|
||||
wrapper,
|
||||
'.pf-c-tabs__item',
|
||||
el => el.length === 4
|
||||
);
|
||||
tabs.forEach(tab => expect(tab.text()).not.toEqual('Schedules'));
|
||||
done();
|
||||
});
|
||||
|
||||
test('should show content error when user attempts to navigate to erroneous route', async () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/projects/1/foobar'],
|
||||
});
|
||||
const wrapper = mountWithContexts(
|
||||
<Project setBreadcrumb={() => {}} me={mockMe} />,
|
||||
{
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
route: {
|
||||
location: history.location,
|
||||
match: {
|
||||
params: { id: 1 },
|
||||
url: '/projects/1/foobar',
|
||||
path: '/project/1/foobar',
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Project setBreadcrumb={() => {}} me={mockMe} />,
|
||||
{
|
||||
context: {
|
||||
router: {
|
||||
history,
|
||||
route: {
|
||||
location: history.location,
|
||||
match: {
|
||||
params: { id: 1 },
|
||||
url: '/projects/1/foobar',
|
||||
path: '/project/1/foobar',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import { Config } from '../../contexts/Config';
|
||||
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
|
||||
|
||||
import ProjectsList from './ProjectList/ProjectList';
|
||||
import ProjectAdd from './ProjectAdd/ProjectAdd';
|
||||
import Project from './Project';
|
||||
|
||||
class Projects extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
function Projects({ i18n }) {
|
||||
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
||||
'/projects': i18n._(t`Projects`),
|
||||
'/projects/add': i18n._(t`Create New Project`),
|
||||
});
|
||||
|
||||
const { i18n } = props;
|
||||
|
||||
this.state = {
|
||||
breadcrumbConfig: {
|
||||
const buildBreadcrumbConfig = useCallback(
|
||||
(project, nested) => {
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
const projectSchedulesPath = `/projects/${project.id}/schedules`;
|
||||
setBreadcrumbConfig({
|
||||
'/projects': i18n._(t`Projects`),
|
||||
'/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) => {
|
||||
const { i18n } = this.props;
|
||||
[`${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`),
|
||||
});
|
||||
},
|
||||
[i18n]
|
||||
);
|
||||
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectSchedulesPath = `/projects/${project.id}/schedules`;
|
||||
|
||||
const breadcrumbConfig = {
|
||||
'/projects': i18n._(t`Projects`),
|
||||
'/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`),
|
||||
|
||||
[`${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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||
<Switch>
|
||||
<Route path="/projects/add">
|
||||
<ProjectAdd />
|
||||
</Route>
|
||||
<Route path="/projects/:id">
|
||||
<Project setBreadcrumb={buildBreadcrumbConfig} />
|
||||
</Route>
|
||||
<Route path="/projects">
|
||||
<ProjectsList />
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { Projects as _Projects };
|
||||
|
||||
@ -1,70 +1,52 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { number } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import { ProjectsAPI } from '../../../api';
|
||||
|
||||
class ProjectSyncButton extends React.Component {
|
||||
static propTypes = {
|
||||
projectId: number.isRequired,
|
||||
};
|
||||
|
||||
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) {
|
||||
function ProjectSyncButton({ i18n, children, projectId }) {
|
||||
const { request: handleSync, error: syncError } = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await ProjectsAPI.readSync(projectId);
|
||||
if (data.can_update) {
|
||||
await ProjectsAPI.sync(projectId);
|
||||
} else {
|
||||
this.setState({
|
||||
syncError: i18n._(
|
||||
throw new Error(
|
||||
i18n._(
|
||||
t`You don't have the necessary permissions to sync this project.`
|
||||
),
|
||||
});
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.setState({ syncError: err });
|
||||
}
|
||||
}
|
||||
}, [i18n, projectId]),
|
||||
null
|
||||
);
|
||||
|
||||
render() {
|
||||
const { syncError } = this.state;
|
||||
const { i18n, children } = this.props;
|
||||
return (
|
||||
<Fragment>
|
||||
{children(this.handleSync)}
|
||||
{syncError && (
|
||||
<AlertModal
|
||||
isOpen={syncError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={this.handleSyncErrorClose}
|
||||
>
|
||||
{i18n._(t`Failed to sync job.`)}
|
||||
<ErrorDetail error={syncError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
const { error, dismissError } = useDismissableError(syncError);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children(handleSync)}
|
||||
{error && (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={dismissError}
|
||||
>
|
||||
{i18n._(t`Failed to sync project.`)}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ProjectSyncButton.propTypes = {
|
||||
projectId: number.isRequired,
|
||||
};
|
||||
|
||||
export default withI18n()(ProjectSyncButton);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { sleep } from '../../../../testUtils/testUtils';
|
||||
|
||||
@ -8,6 +9,7 @@ import { ProjectsAPI } from '../../../api';
|
||||
jest.mock('../../../api');
|
||||
|
||||
describe('ProjectSyncButton', () => {
|
||||
let wrapper;
|
||||
ProjectsAPI.readSync.mockResolvedValue({
|
||||
data: {
|
||||
can_update: true,
|
||||
@ -18,29 +20,34 @@ describe('ProjectSyncButton', () => {
|
||||
<button type="submit" onClick={() => handleSync()} />
|
||||
);
|
||||
|
||||
test('renders the expected content', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<ProjectSyncButton projectId={1}>{children}</ProjectSyncButton>
|
||||
);
|
||||
test('renders the expected content', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectSyncButton projectId={1}>{children}</ProjectSyncButton>
|
||||
);
|
||||
});
|
||||
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({
|
||||
data: {
|
||||
id: 9000,
|
||||
},
|
||||
});
|
||||
const wrapper = mountWithContexts(
|
||||
<ProjectSyncButton projectId={1}>{children}</ProjectSyncButton>
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectSyncButton projectId={1}>{children}</ProjectSyncButton>
|
||||
);
|
||||
});
|
||||
const button = wrapper.find('button');
|
||||
button.prop('onClick')();
|
||||
await act(async () => {
|
||||
button.prop('onClick')();
|
||||
});
|
||||
expect(ProjectsAPI.readSync).toHaveBeenCalledWith(1);
|
||||
await sleep(0);
|
||||
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(
|
||||
new Error({
|
||||
response: {
|
||||
@ -53,18 +60,23 @@ describe('ProjectSyncButton', () => {
|
||||
},
|
||||
})
|
||||
);
|
||||
const wrapper = mountWithContexts(
|
||||
<ProjectSyncButton projectId={1}>{children}</ProjectSyncButton>
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ProjectSyncButton projectId={1}>{children}</ProjectSyncButton>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('Modal').length).toBe(0);
|
||||
wrapper.find('button').prop('onClick')();
|
||||
await act(async () => {
|
||||
wrapper.find('button').prop('onClick')();
|
||||
});
|
||||
await sleep(0);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Modal').length).toBe(1);
|
||||
wrapper.find('ModalBoxCloseButton').simulate('click');
|
||||
await act(async () => {
|
||||
wrapper.find('ModalBoxCloseButton').simulate('click');
|
||||
});
|
||||
await sleep(0);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Modal').length).toBe(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user