diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2018-02-09 15:44:52 +0100 |
---|---|---|
committer | Guillaume Jambet <guillaume.jambet@gmail.com> | 2018-03-01 15:21:05 +0100 |
commit | 4bf292bac1d4dae9b8338c464e88e9dac6ca4b03 (patch) | |
tree | cd3b55c1a527196333e8cf7e9d6bf207679a0c18 /server/sonar-web/src | |
parent | 91fe807305e89ecd9df3b6f4f221540fd451659e (diff) | |
download | sonarqube-4bf292bac1d4dae9b8338c464e88e9dac6ca4b03.tar.gz sonarqube-4bf292bac1d4dae9b8338c464e88e9dac6ca4b03.zip |
SONAR-10347 Add ability to browse webhook deliveries payloads
Diffstat (limited to 'server/sonar-web/src')
23 files changed, 852 insertions, 82 deletions
diff --git a/server/sonar-web/src/main/js/api/webhooks.ts b/server/sonar-web/src/main/js/api/webhooks.ts index f23cb2f079b..a51a01c0948 100644 --- a/server/sonar-web/src/main/js/api/webhooks.ts +++ b/server/sonar-web/src/main/js/api/webhooks.ts @@ -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); +} diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx index bb56f7751a8..7771dce09bb 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx @@ -42,7 +42,8 @@ const SETTINGS_URLS = [ '/project/history', 'background_tasks', '/project/key', - '/project/deletion' + '/project/deletion', + '/project/webhooks' ]; interface Props { diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx index cc2b9631ee8..338494e7604 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx @@ -36,7 +36,8 @@ const ADMIN_PATHS = [ 'delete', 'permissions', 'permission_templates', - 'projects_management' + 'projects_management', + 'webhooks' ]; export default function OrganizationNavigationAdministration({ location, organization }: Props) { diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx index 2c7b4f8b863..bbce6164647 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx @@ -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> diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/App.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/App.tsx index d48a3690400..68a8f9d42bd 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/App.tsx +++ b/server/sonar-web/src/main/js/apps/webhooks/components/App.tsx @@ -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> + </> ); } } diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx index 91a12c2ba7c..6a76095990f 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx +++ b/server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx @@ -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={ diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/DeliveriesForm.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/DeliveriesForm.tsx new file mode 100644 index 00000000000..33c0439d38e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/webhooks/components/DeliveriesForm.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/DeliveryItem.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/DeliveryItem.tsx new file mode 100644 index 00000000000..61cf8bbbcd0 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/webhooks/components/DeliveryItem.tsx @@ -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> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/PageActions.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/PageActions.tsx index 92df63c688b..3968f8bdfbe 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/PageActions.tsx +++ b/server/sonar-web/src/main/js/apps/webhooks/components/PageActions.tsx @@ -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 && ( diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx index db7c401f832..42351e01a1f 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx +++ b/server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx @@ -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} diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/WebhooksList.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/WebhooksList.tsx index 6bfaea6527b..57f33ffe151 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/WebhooksList.tsx +++ b/server/sonar-web/src/main/js/apps/webhooks/components/WebhooksList.tsx @@ -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} diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/App-test.tsx index 085def84f20..62d45726d81 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/App-test.tsx +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/App-test.tsx @@ -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' } + ]); +}); diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveriesForm-test.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveriesForm-test.tsx new file mode 100644 index 00000000000..cf7cc332f8f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveriesForm-test.tsx @@ -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} />); +} diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveryItem-test.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveryItem-test.tsx new file mode 100644 index 00000000000..585df65cab5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveryItem-test.tsx @@ -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} />); +} diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookActions-test.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookActions-test.tsx index 51aa702e132..b4331ce8ce8 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookActions-test.tsx +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookActions-test.tsx @@ -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 diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/App-test.tsx.snap b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/App-test.tsx.snap index 3490f184a15..43c7785c6e0 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/App-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/App-test.tsx.snap @@ -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> `; diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveriesForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveriesForm-test.tsx.snap new file mode 100644 index 00000000000..42cff706441 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveriesForm-test.tsx.snap @@ -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> +`; diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveryItem-test.tsx.snap b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveryItem-test.tsx.snap new file mode 100644 index 00000000000..350ab5714c6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveryItem-test.tsx.snap @@ -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> +`; diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/PageActions-test.tsx.snap b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/PageActions-test.tsx.snap index abbb9ca3fb9..7ba2be6a55f 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/PageActions-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/PageActions-test.tsx.snap @@ -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> diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhookActions-test.tsx.snap b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhookActions-test.tsx.snap index a82a5f3ed35..e3d35952ba5 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhookActions-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhookActions-test.tsx.snap @@ -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" diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhooksList-test.tsx.snap b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhooksList-test.tsx.snap index ddf82de6e99..ff8401ea461 100644 --- a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhooksList-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhooksList-test.tsx.snap @@ -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", } } /> diff --git a/server/sonar-web/src/main/js/helpers/__tests__/urls-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/urls-test.ts index 5b457e11305..ecd54ab1adb 100644 --- a/server/sonar-web/src/main/js/helpers/__tests__/urls-test.ts +++ b/server/sonar-web/src/main/js/helpers/__tests__/urls-test.ts @@ -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(); + }); +}); diff --git a/server/sonar-web/src/main/js/helpers/urls.ts b/server/sonar-web/src/main/js/helpers/urls.ts index 732c0b6a6be..2bde3a1184e 100644 --- a/server/sonar-web/src/main/js/helpers/urls.ts +++ b/server/sonar-web/src/main/js/helpers/urls.ts @@ -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; + } +} |