mirror of
https://github.com/ansible/awx.git
synced 2026-02-19 12:10:06 -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:
@@ -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}
|
||||||
|
|||||||
@@ -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`)}
|
||||||
|
|||||||
@@ -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={[
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user