@@ -19,7 +19,7 @@ | |||
*/ | |||
import { getJSON, post, postJSON } from '../helpers/request'; | |||
import throwGlobalError from '../app/utils/throwGlobalError'; | |||
import { Webhook } from '../app/types'; | |||
import { Webhook, WebhookDelivery, Paging } from '../app/types'; | |||
export function createWebhook(data: { | |||
name: string; | |||
@@ -48,3 +48,22 @@ export function updateWebhook(data: { | |||
}): Promise<void | Response> { | |||
return post('/api/webhooks/update', data).catch(throwGlobalError); | |||
} | |||
export function searchDeliveries(data: { | |||
ceTaskId?: string; | |||
componentKey?: string; | |||
webhook?: string; | |||
p?: number; | |||
ps?: number; | |||
}): Promise<{ | |||
deliveries: WebhookDelivery[]; | |||
paging: Paging; | |||
}> { | |||
return getJSON('/api/webhooks/deliveries', data).catch(throwGlobalError); | |||
} | |||
export function getDelivery(data: { | |||
deliveryId: string; | |||
}): Promise<{ delivery: WebhookDelivery & { payload: string } }> { | |||
return getJSON('/api/webhooks/delivery', data).catch(throwGlobalError); | |||
} |
@@ -42,7 +42,8 @@ const SETTINGS_URLS = [ | |||
'/project/history', | |||
'background_tasks', | |||
'/project/key', | |||
'/project/deletion' | |||
'/project/deletion', | |||
'/project/webhooks' | |||
]; | |||
interface Props { |
@@ -36,7 +36,8 @@ const ADMIN_PATHS = [ | |||
'delete', | |||
'permissions', | |||
'permission_templates', | |||
'projects_management' | |||
'projects_management', | |||
'webhooks' | |||
]; | |||
export default function OrganizationNavigationAdministration({ location, organization }: Props) { |
@@ -35,37 +35,37 @@ export default function OrganizationNavigationMenu({ location, organization }: P | |||
return ( | |||
<NavBarTabs className="navbar-context-tabs"> | |||
<li> | |||
<Link to={`/organizations/${organization.key}/projects`} activeClassName="active"> | |||
<Link activeClassName="active" to={`/organizations/${organization.key}/projects`}> | |||
{translate('projects.page')} | |||
</Link> | |||
</li> | |||
<li> | |||
<Link | |||
activeClassName="active" | |||
to={{ | |||
pathname: `/organizations/${organization.key}/issues`, | |||
query: { resolved: 'false' } | |||
}} | |||
activeClassName="active"> | |||
}}> | |||
{translate('issues.page')} | |||
</Link> | |||
</li> | |||
<li> | |||
<Link to={`/organizations/${organization.key}/quality_profiles`} activeClassName="active"> | |||
<Link activeClassName="active" to={`/organizations/${organization.key}/quality_profiles`}> | |||
{translate('quality_profiles.page')} | |||
</Link> | |||
</li> | |||
<li> | |||
<Link to={`/organizations/${organization.key}/rules`} activeClassName="active"> | |||
<Link activeClassName="active" to={`/organizations/${organization.key}/rules`}> | |||
{translate('coding_rules.page')} | |||
</Link> | |||
</li> | |||
<li> | |||
<Link to={getQualityGatesUrl(organization.key)} activeClassName="active"> | |||
<Link activeClassName="active" to={getQualityGatesUrl(organization.key)}> | |||
{translate('quality_gates.page')} | |||
</Link> | |||
</li> | |||
<li> | |||
<Link to={`/organizations/${organization.key}/members`} activeClassName="active"> | |||
<Link activeClassName="active" to={`/organizations/${organization.key}/members`}> | |||
{translate('organization.members.page')} | |||
</Link> | |||
</li> |
@@ -94,7 +94,10 @@ export default class App extends React.PureComponent<Props, State> { | |||
if (this.mounted) { | |||
this.setState(({ webhooks }) => ({ | |||
webhooks: webhooks.map( | |||
webhook => (webhook.key === data.webhook ? { ...webhook, ...data } : webhook) | |||
webhook => | |||
webhook.key === data.webhook | |||
? { ...webhook, name: data.name, url: data.url } | |||
: webhook | |||
) | |||
})); | |||
} | |||
@@ -105,27 +108,29 @@ export default class App extends React.PureComponent<Props, State> { | |||
const { loading, webhooks } = this.state; | |||
return ( | |||
<div className="page page-limited"> | |||
<> | |||
<Helmet title={translate('webhooks.page')} /> | |||
<PageHeader loading={loading}> | |||
<PageActions | |||
loading={loading} | |||
onCreate={this.handleCreate} | |||
webhooksCount={webhooks.length} | |||
/> | |||
</PageHeader> | |||
{!loading && ( | |||
<div className="boxed-group boxed-group-inner"> | |||
<WebhooksList | |||
onDelete={this.handleDelete} | |||
onUpdate={this.handleUpdate} | |||
webhooks={webhooks} | |||
<div className="page page-limited"> | |||
<PageHeader loading={loading}> | |||
<PageActions | |||
loading={loading} | |||
onCreate={this.handleCreate} | |||
webhooksCount={webhooks.length} | |||
/> | |||
</div> | |||
)} | |||
</div> | |||
</PageHeader> | |||
{!loading && ( | |||
<div className="boxed-group boxed-group-inner"> | |||
<WebhooksList | |||
onDelete={this.handleDelete} | |||
onUpdate={this.handleUpdate} | |||
webhooks={webhooks} | |||
/> | |||
</div> | |||
)} | |||
</div> | |||
</> | |||
); | |||
} | |||
} |
@@ -22,6 +22,7 @@ import { FormikProps } from 'formik'; | |||
import ValidationModal from '../../../components/controls/ValidationModal'; | |||
import InputValidationField from '../../../components/controls/InputValidationField'; | |||
import { Webhook } from '../../../app/types'; | |||
import { isUrl } from '../../../helpers/urls'; | |||
import { translate } from '../../../helpers/l10n'; | |||
interface Props { | |||
@@ -52,8 +53,8 @@ export default class CreateWebhookForm extends React.PureComponent<Props> { | |||
errors.url = translate('webhooks.url.required'); | |||
} else if (!url.startsWith('http://') && !url.startsWith('https://')) { | |||
errors.url = translate('webhooks.url.bad_protocol'); | |||
} else if (url.indexOf(':', 6) > 0 && url.indexOf('@') <= 0) { | |||
errors.url = translate('webhooks.url.bad_auth'); | |||
} else if (!isUrl(url)) { | |||
errors.url = translate('webhooks.url.bad_format'); | |||
} | |||
return errors; | |||
}; | |||
@@ -106,8 +107,8 @@ export default class CreateWebhookForm extends React.PureComponent<Props> { | |||
/> | |||
<InputValidationField | |||
description={translate('webhooks.url.description')} | |||
disabled={isSubmitting} | |||
dirty={dirty} | |||
disabled={isSubmitting} | |||
error={errors.url} | |||
id="webhook-url" | |||
label={ |
@@ -0,0 +1,120 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2018 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import DeliveryItem from './DeliveryItem'; | |||
import DeferredSpinner from '../../../components/common/DeferredSpinner'; | |||
import ListFooter from '../../../components/controls/ListFooter'; | |||
import Modal from '../../../components/controls/Modal'; | |||
import { Webhook, WebhookDelivery, Paging } from '../../../app/types'; | |||
import { translateWithParameters, translate } from '../../../helpers/l10n'; | |||
import { searchDeliveries } from '../../../api/webhooks'; | |||
interface Props { | |||
onClose: () => void; | |||
webhook: Webhook; | |||
} | |||
interface State { | |||
deliveries: WebhookDelivery[]; | |||
loading: boolean; | |||
paging?: Paging; | |||
} | |||
const PAGE_SIZE = 10; | |||
export default class DeliveriesForm extends React.PureComponent<Props, State> { | |||
mounted: boolean = false; | |||
state: State = { deliveries: [], loading: true }; | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.fetchDeliveries(); | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
fetchDeliveries = ({ webhook } = this.props) => { | |||
searchDeliveries({ webhook: webhook.key, ps: PAGE_SIZE }).then(({ deliveries, paging }) => { | |||
if (this.mounted) { | |||
this.setState({ deliveries, loading: false, paging }); | |||
} | |||
}, this.stopLoading); | |||
}; | |||
fetchMoreDeliveries = ({ webhook } = this.props) => { | |||
const { paging } = this.state; | |||
if (paging) { | |||
this.setState({ loading: true }); | |||
searchDeliveries({ webhook: webhook.key, p: paging.pageIndex + 1, ps: PAGE_SIZE }).then( | |||
({ deliveries, paging }) => { | |||
if (this.mounted) { | |||
this.setState((state: State) => ({ | |||
deliveries: [...state.deliveries, ...deliveries], | |||
loading: false, | |||
paging | |||
})); | |||
} | |||
}, | |||
this.stopLoading | |||
); | |||
} | |||
}; | |||
stopLoading = () => { | |||
if (this.mounted) { | |||
this.setState({ loading: false }); | |||
} | |||
}; | |||
render() { | |||
const { webhook } = this.props; | |||
const { deliveries, loading, paging } = this.state; | |||
const header = translateWithParameters('webhooks.deliveries_for_x', webhook.name); | |||
return ( | |||
<Modal contentLabel={header} onRequestClose={this.props.onClose}> | |||
<header className="modal-head"> | |||
<h2>{header}</h2> | |||
</header> | |||
<div className="modal-body modal-container"> | |||
{deliveries.map(delivery => <DeliveryItem delivery={delivery} key={delivery.id} />)} | |||
<div className="text-center"> | |||
<DeferredSpinner loading={loading} /> | |||
</div> | |||
{paging !== undefined && ( | |||
<ListFooter | |||
count={deliveries.length} | |||
loadMore={this.fetchMoreDeliveries} | |||
ready={!loading} | |||
total={paging.total} | |||
/> | |||
)} | |||
</div> | |||
<footer className="modal-foot"> | |||
<button className="button-link js-modal-close" onClick={this.props.onClose} type="button"> | |||
{translate('close')} | |||
</button> | |||
</footer> | |||
</Modal> | |||
); | |||
} | |||
} |
@@ -0,0 +1,119 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2018 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import CheckIcon from '../../../components/icons-components/CheckIcon'; | |||
import ClearIcon from '../../../components/icons-components/ClearIcon'; | |||
import DateTimeFormatter from '../../../components/intl/DateTimeFormatter'; | |||
import BoxedGroupAccordion from '../../../components/controls/BoxedGroupAccordion'; | |||
import CodeSnippet from '../../../components/common/CodeSnippet'; | |||
import DeferredSpinner from '../../../components/common/DeferredSpinner'; | |||
import { getDelivery } from '../../../api/webhooks'; | |||
import { formatMeasure } from '../../../helpers/measures'; | |||
import { translateWithParameters, translate } from '../../../helpers/l10n'; | |||
import { WebhookDelivery } from '../../../app/types'; | |||
interface Props { | |||
delivery: WebhookDelivery; | |||
} | |||
interface State { | |||
loading: boolean; | |||
open: boolean; | |||
payload?: string; | |||
} | |||
export default class DeliveryItem extends React.PureComponent<Props, State> { | |||
mounted: boolean = false; | |||
state: State = { loading: false, open: false }; | |||
componentDidMount() { | |||
this.mounted = true; | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
fetchPayload = ({ delivery } = this.props) => { | |||
this.setState({ loading: true }); | |||
return getDelivery({ deliveryId: delivery.id }).then( | |||
({ delivery }) => { | |||
if (this.mounted) { | |||
this.setState({ payload: delivery.payload, loading: false }); | |||
} | |||
}, | |||
() => { | |||
if (this.mounted) { | |||
this.setState({ loading: false }); | |||
} | |||
} | |||
); | |||
}; | |||
formatPayload = (payload: string) => { | |||
try { | |||
return JSON.stringify(JSON.parse(payload), undefined, 2); | |||
} catch (error) { | |||
return payload; | |||
} | |||
}; | |||
handleClick = () => { | |||
if (!this.state.payload) { | |||
this.fetchPayload(); | |||
} | |||
this.setState(({ open }) => ({ open: !open })); | |||
}; | |||
render() { | |||
const { delivery } = this.props; | |||
const { loading, open, payload } = this.state; | |||
return ( | |||
<BoxedGroupAccordion | |||
onClick={this.handleClick} | |||
open={open} | |||
renderHeader={() => | |||
delivery.success ? ( | |||
<AlertSuccessIcon className="pull-right js-success" /> | |||
) : ( | |||
<AlertErrorIcon className="pull-right js-error" /> | |||
) | |||
} | |||
title={<DateTimeFormatter date={delivery.at} />}> | |||
<div className="big-spacer-left"> | |||
<p className="spacer-bottom"> | |||
{translateWithParameters('webhooks.delivery.response_x', delivery.httpStatus)} | |||
</p> | |||
<p className="spacer-bottom"> | |||
{translateWithParameters( | |||
'webhooks.delivery.duration_x', | |||
formatMeasure(delivery.durationMs, 'MILLISEC') | |||
)} | |||
</p> | |||
<p className="spacer-bottom">{translate('webhooks.delivery.payload')}</p> | |||
<DeferredSpinner className="spacer-left spacer-top" loading={loading}> | |||
{payload && <CodeSnippet noCopy={true} snippet={this.formatPayload(payload)} />} | |||
</DeferredSpinner> | |||
</div> | |||
</BoxedGroupAccordion> | |||
); | |||
} | |||
} |
@@ -62,14 +62,16 @@ export default class PageActions extends React.PureComponent<Props, State> { | |||
<Tooltip | |||
overlay={translateWithParameters('webhooks.maximum_reached', WEBHOOKS_LIMIT)} | |||
placement="left"> | |||
<button className="js-webhook-create disabled">{translate('create')}</button> | |||
<button className="js-webhook-create disabled" type="button"> | |||
{translate('create')} | |||
</button> | |||
</Tooltip> | |||
); | |||
} | |||
return ( | |||
<> | |||
<button className="js-webhook-create" onClick={this.handleCreateOpen}> | |||
<button className="js-webhook-create" onClick={this.handleCreateOpen} type="button"> | |||
{translate('create')} | |||
</button> | |||
{this.state.openCreate && ( |
@@ -19,6 +19,7 @@ | |||
*/ | |||
import * as React from 'react'; | |||
import CreateWebhookForm from './CreateWebhookForm'; | |||
import DeliveriesForm from './DeliveriesForm'; | |||
import ActionsDropdown, { | |||
ActionsDropdownItem, | |||
ActionsDropdownDivider | |||
@@ -34,12 +35,13 @@ interface Props { | |||
} | |||
interface State { | |||
deliveries: boolean; | |||
updating: boolean; | |||
} | |||
export default class WebhookActions extends React.PureComponent<Props, State> { | |||
mounted: boolean = false; | |||
state: State = { updating: false }; | |||
state: State = { deliveries: false, updating: false }; | |||
componentDidMount() { | |||
this.mounted = true; | |||
@@ -53,6 +55,14 @@ export default class WebhookActions extends React.PureComponent<Props, State> { | |||
return this.props.onDelete(this.props.webhook.key); | |||
}; | |||
handleDeliveriesClick = () => { | |||
this.setState({ deliveries: true }); | |||
}; | |||
handleDeliveriesStop = () => { | |||
this.setState({ deliveries: false }); | |||
}; | |||
handleUpdate = (data: { name: string; url: string }) => { | |||
return this.props.onUpdate({ ...data, webhook: this.props.webhook.key }); | |||
}; | |||
@@ -67,13 +77,18 @@ export default class WebhookActions extends React.PureComponent<Props, State> { | |||
render() { | |||
const { webhook } = this.props; | |||
// TODO Disable "Show deliveries" if there is no lastDelivery | |||
return ( | |||
<> | |||
<ActionsDropdown className="big-spacer-left"> | |||
<ActionsDropdownItem className="js-webhook-update" onClick={this.handleUpdateClick}> | |||
{translate('update_verb')} | |||
</ActionsDropdownItem> | |||
<ActionsDropdownItem | |||
className="js-webhook-deliveries" | |||
onClick={this.handleDeliveriesClick}> | |||
{translate('webhooks.deliveries.show')} | |||
</ActionsDropdownItem> | |||
<ActionsDropdownDivider /> | |||
<ConfirmButton | |||
confirmButtonText={translate('delete')} | |||
@@ -91,7 +106,9 @@ export default class WebhookActions extends React.PureComponent<Props, State> { | |||
)} | |||
</ConfirmButton> | |||
</ActionsDropdown> | |||
{this.state.deliveries && ( | |||
<DeliveriesForm onClose={this.handleDeliveriesStop} webhook={webhook} /> | |||
)} | |||
{this.state.updating && ( | |||
<CreateWebhookForm | |||
onClose={this.handleUpdatingStop} |
@@ -18,6 +18,7 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { sortBy } from 'lodash'; | |||
import WebhookItem from './WebhookItem'; | |||
import { Webhook } from '../../../app/types'; | |||
import { translate } from '../../../helpers/l10n'; | |||
@@ -48,7 +49,7 @@ export default class WebhooksList extends React.PureComponent<Props> { | |||
<table className="data zebra"> | |||
{this.renderHeader()} | |||
<tbody> | |||
{webhooks.map(webhook => ( | |||
{sortBy(webhooks, webhook => webhook.name.toLowerCase()).map(webhook => ( | |||
<WebhookItem | |||
key={webhook.key} | |||
onDelete={this.props.onDelete} |
@@ -20,18 +20,38 @@ | |||
import * as React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import App from '../App'; | |||
import { searchWebhooks } from '../../../../api/webhooks'; | |||
import { | |||
createWebhook, | |||
deleteWebhook, | |||
searchWebhooks, | |||
updateWebhook | |||
} from '../../../../api/webhooks'; | |||
import { Visibility } from '../../../../app/types'; | |||
jest.mock('../../../../api/webhooks', () => ({ | |||
searchWebhooks: jest.fn(() => Promise.resolve({ webhooks: [] })) | |||
createWebhook: jest.fn(() => | |||
Promise.resolve({ webhook: { key: '3', name: 'baz', url: 'http://baz' } }) | |||
), | |||
deleteWebhook: jest.fn(() => Promise.resolve()), | |||
searchWebhooks: jest.fn(() => | |||
Promise.resolve({ | |||
webhooks: [ | |||
{ key: '1', name: 'foo', url: 'http://foo' }, | |||
{ key: '2', name: 'bar', url: 'http://bar' } | |||
] | |||
}) | |||
), | |||
updateWebhook: jest.fn(() => Promise.resolve()) | |||
})); | |||
const organization = { key: 'foo', name: 'Foo', projectVisibility: Visibility.Private }; | |||
const component = { key: 'bar', organization: 'foo', qualifier: 'TRK' }; | |||
beforeEach(() => { | |||
(createWebhook as jest.Mock<any>).mockClear(); | |||
(deleteWebhook as jest.Mock<any>).mockClear(); | |||
(searchWebhooks as jest.Mock<any>).mockClear(); | |||
(updateWebhook as jest.Mock<any>).mockClear(); | |||
}); | |||
it('should be in loading status', () => { | |||
@@ -58,21 +78,21 @@ describe('should correctly fetch webhooks when', () => { | |||
}); | |||
it('on project scope', async () => { | |||
shallow(<App organization={undefined} component={component} />); | |||
shallow(<App component={component} organization={undefined} />); | |||
await new Promise(setImmediate); | |||
expect(searchWebhooks).toHaveBeenCalledWith({ project: component.key }); | |||
}); | |||
it('on organization scope', async () => { | |||
shallow(<App organization={organization} component={undefined} />); | |||
shallow(<App component={undefined} organization={organization} />); | |||
await new Promise(setImmediate); | |||
expect(searchWebhooks).toHaveBeenCalledWith({ organization: organization.key }); | |||
}); | |||
it('on project scope within an organization', async () => { | |||
shallow(<App organization={organization} component={component} />); | |||
shallow(<App component={component} organization={organization} />); | |||
await new Promise(setImmediate); | |||
expect(searchWebhooks).toHaveBeenCalledWith({ | |||
@@ -81,3 +101,46 @@ describe('should correctly fetch webhooks when', () => { | |||
}); | |||
}); | |||
}); | |||
it('should correctly handle webhook creation', async () => { | |||
const webhook = { name: 'baz', url: 'http://baz' }; | |||
const wrapper = shallow(<App organization={organization} />); | |||
(wrapper.instance() as App).handleCreate({ ...webhook }); | |||
expect(createWebhook).lastCalledWith({ | |||
...webhook, | |||
organization: organization.key, | |||
project: undefined | |||
}); | |||
await new Promise(setImmediate); | |||
wrapper.update(); | |||
expect(wrapper.state('webhooks')).toEqual([ | |||
{ key: '1', name: 'foo', url: 'http://foo' }, | |||
{ key: '2', name: 'bar', url: 'http://bar' }, | |||
{ key: '3', name: 'baz', url: 'http://baz' } | |||
]); | |||
}); | |||
it('should correctly handle webhook deletion', async () => { | |||
const wrapper = shallow(<App organization={undefined} />); | |||
(wrapper.instance() as App).handleDelete('2'); | |||
expect(deleteWebhook).lastCalledWith({ webhook: '2' }); | |||
await new Promise(setImmediate); | |||
wrapper.update(); | |||
expect(wrapper.state('webhooks')).toEqual([{ key: '1', name: 'foo', url: 'http://foo' }]); | |||
}); | |||
it('should correctly handle webhook update', async () => { | |||
const newValues = { webhook: '1', name: 'Cfoo', url: 'http://cfoo' }; | |||
const wrapper = shallow(<App organization={undefined} />); | |||
(wrapper.instance() as App).handleUpdate(newValues); | |||
expect(updateWebhook).lastCalledWith(newValues); | |||
await new Promise(setImmediate); | |||
wrapper.update(); | |||
expect(wrapper.state('webhooks')).toEqual([ | |||
{ key: '1', name: 'Cfoo', url: 'http://cfoo' }, | |||
{ key: '2', name: 'bar', url: 'http://bar' } | |||
]); | |||
}); |
@@ -0,0 +1,74 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2018 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import DeliveriesForm from '../DeliveriesForm'; | |||
import { searchDeliveries } from '../../../../api/webhooks'; | |||
jest.mock('../../../../api/webhooks', () => ({ | |||
searchDeliveries: jest.fn(() => | |||
Promise.resolve({ | |||
deliveries: [ | |||
{ | |||
at: '12.02.2018', | |||
durationMs: 20, | |||
httpStatus: 200, | |||
id: '2', | |||
success: true | |||
}, | |||
{ | |||
at: '11.02.2018', | |||
durationMs: 122, | |||
httpStatus: 500, | |||
id: '1', | |||
success: false | |||
} | |||
], | |||
paging: { | |||
pageIndex: 1, | |||
pageSize: 10, | |||
total: 15 | |||
} | |||
}) | |||
) | |||
})); | |||
const webhook = { key: '1', name: 'foo', url: 'http://foo.bar' }; | |||
beforeEach(() => { | |||
(searchDeliveries as jest.Mock<any>).mockClear(); | |||
}); | |||
it('should render correctly', async () => { | |||
const wrapper = getWrapper(); | |||
expect(wrapper).toMatchSnapshot(); | |||
await new Promise(setImmediate); | |||
expect(searchDeliveries as jest.Mock<any>).lastCalledWith({ webhook: webhook.key, ps: 10 }); | |||
wrapper.update(); | |||
expect(wrapper).toMatchSnapshot(); | |||
wrapper.find('ListFooter').prop<Function>('loadMore')(); | |||
expect(searchDeliveries).lastCalledWith({ webhook: webhook.key, p: 2, ps: 10 }); | |||
}); | |||
function getWrapper(props = {}) { | |||
return shallow(<DeliveriesForm onClose={jest.fn()} webhook={webhook} {...props} />); | |||
} |
@@ -0,0 +1,58 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2018 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import DeliveryItem from '../DeliveryItem'; | |||
import { getDelivery } from '../../../../api/webhooks'; | |||
jest.mock('../../../../api/webhooks', () => ({ | |||
getDelivery: jest.fn(() => | |||
Promise.resolve({ | |||
delivery: { payload: '{ "success": true }' } | |||
}) | |||
) | |||
})); | |||
const delivery = { | |||
at: '12.02.2018', | |||
durationMs: 20, | |||
httpStatus: 200, | |||
id: '2', | |||
success: true | |||
}; | |||
beforeEach(() => { | |||
(getDelivery as jest.Mock<any>).mockClear(); | |||
}); | |||
it('should render correctly', async () => { | |||
const wrapper = getWrapper(); | |||
expect(wrapper).toMatchSnapshot(); | |||
wrapper.find('BoxedGroupAccordion').prop<Function>('onClick')(); | |||
await new Promise(setImmediate); | |||
expect(getDelivery).lastCalledWith({ deliveryId: delivery.id }); | |||
wrapper.update(); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); | |||
function getWrapper(props = {}) { | |||
return shallow(<DeliveryItem delivery={delivery} {...props} />); | |||
} |
@@ -54,6 +54,12 @@ it('should display the delete webhook form', () => { | |||
expect(onDelete).lastCalledWith(webhook.key); | |||
}); | |||
it('should display the deliveries form', () => { | |||
const wrapper = getWrapper(); | |||
click(wrapper.find('.js-webhook-deliveries')); | |||
expect(wrapper.find('DeliveriesForm').exists()).toBeTruthy(); | |||
}); | |||
function getWrapper(props = {}) { | |||
return shallow( | |||
<WebhookActions |
@@ -1,52 +1,69 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should be in loading status 1`] = ` | |||
<div | |||
className="page page-limited" | |||
> | |||
<React.Fragment> | |||
<HelmetWrapper | |||
defer={true} | |||
encodeSpecialCharacters={true} | |||
title="webhooks.page" | |||
/> | |||
<PageHeader | |||
loading={true} | |||
<div | |||
className="page page-limited" | |||
> | |||
<PageActions | |||
<PageHeader | |||
loading={true} | |||
onCreate={[Function]} | |||
webhooksCount={0} | |||
/> | |||
</PageHeader> | |||
</div> | |||
> | |||
<PageActions | |||
loading={true} | |||
onCreate={[Function]} | |||
webhooksCount={0} | |||
/> | |||
</PageHeader> | |||
</div> | |||
</React.Fragment> | |||
`; | |||
exports[`should fetch webhooks and display them 1`] = ` | |||
<div | |||
className="page page-limited" | |||
> | |||
<React.Fragment> | |||
<HelmetWrapper | |||
defer={true} | |||
encodeSpecialCharacters={true} | |||
title="webhooks.page" | |||
/> | |||
<PageHeader | |||
loading={false} | |||
> | |||
<PageActions | |||
loading={false} | |||
onCreate={[Function]} | |||
webhooksCount={0} | |||
/> | |||
</PageHeader> | |||
<div | |||
className="boxed-group boxed-group-inner" | |||
className="page page-limited" | |||
> | |||
<WebhooksList | |||
onDelete={[Function]} | |||
onUpdate={[Function]} | |||
webhooks={Array []} | |||
/> | |||
<PageHeader | |||
loading={false} | |||
> | |||
<PageActions | |||
loading={false} | |||
onCreate={[Function]} | |||
webhooksCount={2} | |||
/> | |||
</PageHeader> | |||
<div | |||
className="boxed-group boxed-group-inner" | |||
> | |||
<WebhooksList | |||
onDelete={[Function]} | |||
onUpdate={[Function]} | |||
webhooks={ | |||
Array [ | |||
Object { | |||
"key": "1", | |||
"name": "foo", | |||
"url": "http://foo", | |||
}, | |||
Object { | |||
"key": "2", | |||
"name": "bar", | |||
"url": "http://bar", | |||
}, | |||
] | |||
} | |||
/> | |||
</div> | |||
</div> | |||
</div> | |||
</React.Fragment> | |||
`; |
@@ -0,0 +1,107 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly 1`] = ` | |||
<Modal | |||
contentLabel="webhooks.deliveries_for_x.foo" | |||
onRequestClose={[MockFunction]} | |||
> | |||
<header | |||
className="modal-head" | |||
> | |||
<h2> | |||
webhooks.deliveries_for_x.foo | |||
</h2> | |||
</header> | |||
<div | |||
className="modal-body modal-container" | |||
> | |||
<div | |||
className="text-center" | |||
> | |||
<DeferredSpinner | |||
loading={true} | |||
timeout={100} | |||
/> | |||
</div> | |||
</div> | |||
<footer | |||
className="modal-foot" | |||
> | |||
<button | |||
className="button-link js-modal-close" | |||
onClick={[MockFunction]} | |||
type="button" | |||
> | |||
close | |||
</button> | |||
</footer> | |||
</Modal> | |||
`; | |||
exports[`should render correctly 2`] = ` | |||
<Modal | |||
contentLabel="webhooks.deliveries_for_x.foo" | |||
onRequestClose={[MockFunction]} | |||
> | |||
<header | |||
className="modal-head" | |||
> | |||
<h2> | |||
webhooks.deliveries_for_x.foo | |||
</h2> | |||
</header> | |||
<div | |||
className="modal-body modal-container" | |||
> | |||
<DeliveryItem | |||
delivery={ | |||
Object { | |||
"at": "12.02.2018", | |||
"durationMs": 20, | |||
"httpStatus": 200, | |||
"id": "2", | |||
"success": true, | |||
} | |||
} | |||
key="2" | |||
/> | |||
<DeliveryItem | |||
delivery={ | |||
Object { | |||
"at": "11.02.2018", | |||
"durationMs": 122, | |||
"httpStatus": 500, | |||
"id": "1", | |||
"success": false, | |||
} | |||
} | |||
key="1" | |||
/> | |||
<div | |||
className="text-center" | |||
> | |||
<DeferredSpinner | |||
loading={false} | |||
timeout={100} | |||
/> | |||
</div> | |||
<ListFooter | |||
count={2} | |||
loadMore={[Function]} | |||
ready={true} | |||
total={15} | |||
/> | |||
</div> | |||
<footer | |||
className="modal-foot" | |||
> | |||
<button | |||
className="button-link js-modal-close" | |||
onClick={[MockFunction]} | |||
type="button" | |||
> | |||
close | |||
</button> | |||
</footer> | |||
</Modal> | |||
`; |
@@ -0,0 +1,84 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly 1`] = ` | |||
<BoxedGroupAccordion | |||
onClick={[Function]} | |||
open={false} | |||
renderHeader={[Function]} | |||
title={ | |||
<DateTimeFormatter | |||
date="12.02.2018" | |||
/> | |||
} | |||
> | |||
<div | |||
className="big-spacer-left" | |||
> | |||
<p | |||
className="spacer-bottom" | |||
> | |||
webhooks.delivery.response_x.200 | |||
</p> | |||
<p | |||
className="spacer-bottom" | |||
> | |||
webhooks.delivery.duration_x.20ms | |||
</p> | |||
<p | |||
className="spacer-bottom" | |||
> | |||
webhooks.delivery.payload | |||
</p> | |||
<DeferredSpinner | |||
className="spacer-left spacer-top" | |||
loading={false} | |||
timeout={100} | |||
/> | |||
</div> | |||
</BoxedGroupAccordion> | |||
`; | |||
exports[`should render correctly 2`] = ` | |||
<BoxedGroupAccordion | |||
onClick={[Function]} | |||
open={true} | |||
renderHeader={[Function]} | |||
title={ | |||
<DateTimeFormatter | |||
date="12.02.2018" | |||
/> | |||
} | |||
> | |||
<div | |||
className="big-spacer-left" | |||
> | |||
<p | |||
className="spacer-bottom" | |||
> | |||
webhooks.delivery.response_x.200 | |||
</p> | |||
<p | |||
className="spacer-bottom" | |||
> | |||
webhooks.delivery.duration_x.20ms | |||
</p> | |||
<p | |||
className="spacer-bottom" | |||
> | |||
webhooks.delivery.payload | |||
</p> | |||
<DeferredSpinner | |||
className="spacer-left spacer-top" | |||
loading={false} | |||
timeout={100} | |||
> | |||
<CodeSnippet | |||
noCopy={true} | |||
snippet="{ | |||
\\"success\\": true | |||
}" | |||
/> | |||
</DeferredSpinner> | |||
</div> | |||
</BoxedGroupAccordion> | |||
`; |
@@ -10,6 +10,7 @@ exports[`should not allow to create a new webhook 1`] = ` | |||
> | |||
<button | |||
className="js-webhook-create disabled" | |||
type="button" | |||
> | |||
create | |||
</button> | |||
@@ -25,6 +26,7 @@ exports[`should render correctly 1`] = ` | |||
<button | |||
className="js-webhook-create" | |||
onClick={[Function]} | |||
type="button" | |||
> | |||
create | |||
</button> |
@@ -11,6 +11,12 @@ exports[`should render correctly 1`] = ` | |||
> | |||
update_verb | |||
</ActionsDropdownItem> | |||
<ActionsDropdownItem | |||
className="js-webhook-deliveries" | |||
onClick={[Function]} | |||
> | |||
webhooks.deliveries.show | |||
</ActionsDropdownItem> | |||
<ActionsDropdownDivider /> | |||
<ConfirmButton | |||
confirmButtonText="delete" |
@@ -23,26 +23,26 @@ exports[`should correctly render the webhooks 1`] = ` | |||
</thead> | |||
<tbody> | |||
<WebhookItem | |||
key="1" | |||
key="2" | |||
onDelete={[MockFunction]} | |||
onUpdate={[MockFunction]} | |||
webhook={ | |||
Object { | |||
"key": "1", | |||
"name": "my webhook", | |||
"url": "http://webhook.target", | |||
"key": "2", | |||
"name": "jenkins webhook", | |||
"url": "http://jenkins.target", | |||
} | |||
} | |||
/> | |||
<WebhookItem | |||
key="2" | |||
key="1" | |||
onDelete={[MockFunction]} | |||
onUpdate={[MockFunction]} | |||
webhook={ | |||
Object { | |||
"key": "2", | |||
"name": "jenkins webhook", | |||
"url": "http://jenkins.target", | |||
"key": "1", | |||
"name": "my webhook", | |||
"url": "http://webhook.target", | |||
} | |||
} | |||
/> |
@@ -23,7 +23,8 @@ import { | |||
getPathUrlAsString, | |||
getProjectUrl, | |||
getQualityGatesUrl, | |||
getQualityGateUrl | |||
getQualityGateUrl, | |||
isUrl | |||
} from '../urls'; | |||
const SIMPLE_COMPONENT_KEY = 'sonarqube'; | |||
@@ -113,3 +114,55 @@ describe('#getQualityGate(s)Url', () => { | |||
}); | |||
}); | |||
}); | |||
describe('#isUrl', () => { | |||
it('should be valid', () => { | |||
expect(isUrl('https://localhost')).toBeTruthy(); | |||
expect(isUrl('https://localhost/')).toBeTruthy(); | |||
expect(isUrl('https://localhost:9000')).toBeTruthy(); | |||
expect(isUrl('https://localhost:9000/')).toBeTruthy(); | |||
expect(isUrl('https://foo:bar@localhost:9000')).toBeTruthy(); | |||
expect(isUrl('https://foo@localhost')).toBeTruthy(); | |||
expect(isUrl('http://foo.com/blah_blah')).toBeTruthy(); | |||
expect(isUrl('http://foo.com/blah_blah/')).toBeTruthy(); | |||
expect(isUrl('http://www.example.com/wpstyle/?p=364')).toBeTruthy(); | |||
expect(isUrl('https://www.example.com/foo/?bar=baz&inga=42&quux')).toBeTruthy(); | |||
expect(isUrl('http://userid@example.com')).toBeTruthy(); | |||
expect(isUrl('http://userid@example.com/')).toBeTruthy(); | |||
expect(isUrl('http://userid:password@example.com:8080')).toBeTruthy(); | |||
expect(isUrl('http://userid:password@example.com:8080/')).toBeTruthy(); | |||
expect(isUrl('http://userid@example.com:8080')).toBeTruthy(); | |||
expect(isUrl('http://userid@example.com:8080/')).toBeTruthy(); | |||
expect(isUrl('http://userid:password@example.com')).toBeTruthy(); | |||
expect(isUrl('http://userid:password@example.com/')).toBeTruthy(); | |||
expect(isUrl('http://142.42.1.1/')).toBeTruthy(); | |||
expect(isUrl('http://142.42.1.1:8080/')).toBeTruthy(); | |||
expect(isUrl('http://foo.com/blah_(wikipedia)#cite-1')).toBeTruthy(); | |||
expect(isUrl('http://foo.com/blah_(wikipedia)_blah#cite-1')).toBeTruthy(); | |||
expect(isUrl('http://foo.com/(something)?after=parens')).toBeTruthy(); | |||
expect(isUrl('http://code.google.com/events/#&product=browser')).toBeTruthy(); | |||
expect(isUrl('http://j.mp')).toBeTruthy(); | |||
expect(isUrl('http://foo.bar/?q=Test%20URL-encoded%20stuff')).toBeTruthy(); | |||
expect(isUrl('http://1337.net')).toBeTruthy(); | |||
expect(isUrl('http://a.b-c.de')).toBeTruthy(); | |||
expect(isUrl('http://223.255.255.254')).toBeTruthy(); | |||
expect(isUrl('https://foo_bar.example.com/')).toBeTruthy(); | |||
}); | |||
it('should not be valid', () => { | |||
expect(isUrl('http://')).toBeFalsy(); | |||
expect(isUrl('http://?')).toBeFalsy(); | |||
expect(isUrl('http://??')).toBeFalsy(); | |||
expect(isUrl('http://??/')).toBeFalsy(); | |||
expect(isUrl('http://#')).toBeFalsy(); | |||
expect(isUrl('http://##')).toBeFalsy(); | |||
expect(isUrl('http://##/')).toBeFalsy(); | |||
expect(isUrl('//')).toBeFalsy(); | |||
expect(isUrl('//a')).toBeFalsy(); | |||
expect(isUrl('///a')).toBeFalsy(); | |||
expect(isUrl('///')).toBeFalsy(); | |||
expect(isUrl('foo.com')).toBeFalsy(); | |||
expect(isUrl('http:// shouldfail.com')).toBeFalsy(); | |||
expect(isUrl(':// should fail')).toBeFalsy(); | |||
}); | |||
}); |
@@ -191,3 +191,17 @@ export function getHomePageUrl(homepage: HomePage) { | |||
// should never happen, but just in case... | |||
return '/projects'; | |||
} | |||
export function isUrl(url: string) { | |||
if (!URL) { | |||
const elem = document.createElement('a'); | |||
elem.href = url; | |||
return !!(elem.host && elem.hostname && elem.protocol); | |||
} | |||
try { | |||
const parsedUrl = new URL(url); | |||
return url.includes(parsedUrl.host); | |||
} catch (error) { | |||
return false; | |||
} | |||
} |
@@ -2818,6 +2818,11 @@ webhooks.create=Create Webhook | |||
webhooks.delete=Delete Webhook | |||
webhooks.delete.confirm=Are you sure you want to delete the webhook "{0}"? | |||
webhooks.description=Webhooks are used to notify external services when a project analysis is done. An HTTP POST request including a JSON payload is sent to each of the provided URLs. Learn more in the {url}. | |||
webhooks.deliveries.show=Show recent deliveries | |||
webhooks.deliveries_for_x=Recent deliveries for {0} | |||
webhooks.delivery.duration_x=Duration: {0} | |||
webhooks.delivery.payload=Payload: | |||
webhooks.delivery.response_x=Response: {0} | |||
webhooks.documentation_link=Webhooks documentation | |||
webhooks.maximum_reached=You reached your maximum number of {0} webhooks. You can still update or delete an existing one. | |||
webhooks.name=Name | |||
@@ -2825,7 +2830,7 @@ webhooks.name.required=Name is required. | |||
webhooks.no_result=No webhook defined. | |||
webhooks.update=Update Webhook | |||
webhooks.url=URL | |||
webhooks.url.bad_auth=Bad format of URL authentication. | |||
webhooks.url.bad_format=Bad format of URL. | |||
webhooks.url.bad_protocol=URL must start with "http://" or "https://". | |||
webhooks.url.description=Server endpoint that will receive the webhook payload, for example: "http://my_server/foo". If HTTP Basic authentication is used, HTTPS is recommended to avoid man in the middle attacks. Example: "https://myLogin:myPassword@my_server/foo" | |||
webhooks.url.required=URL is required. |