]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-10347 Add ability to browse webhook deliveries payloads
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Fri, 9 Feb 2018 14:44:52 +0000 (15:44 +0100)
committerGuillaume Jambet <guillaume.jambet@gmail.com>
Thu, 1 Mar 2018 14:21:05 +0000 (15:21 +0100)
24 files changed:
server/sonar-web/src/main/js/api/webhooks.ts
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx
server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx
server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationMenu.tsx
server/sonar-web/src/main/js/apps/webhooks/components/App.tsx
server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx
server/sonar-web/src/main/js/apps/webhooks/components/DeliveriesForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/DeliveryItem.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/PageActions.tsx
server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx
server/sonar-web/src/main/js/apps/webhooks/components/WebhooksList.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/App-test.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveriesForm-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/DeliveryItem-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookActions-test.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/App-test.tsx.snap
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveriesForm-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/DeliveryItem-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/PageActions-test.tsx.snap
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhookActions-test.tsx.snap
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhooksList-test.tsx.snap
server/sonar-web/src/main/js/helpers/__tests__/urls-test.ts
server/sonar-web/src/main/js/helpers/urls.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index f23cb2f079bd6bf9c0df40235901bf980548040b..a51a01c09486fc195f0fb638effbb7dd96d49ab5 100644 (file)
@@ -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);
+}
index bb56f7751a8fa3eda9c395afe33148f173558676..7771dce09bb98533bdab8e64b07741e4fef4e6ca 100644 (file)
@@ -42,7 +42,8 @@ const SETTINGS_URLS = [
   '/project/history',
   'background_tasks',
   '/project/key',
-  '/project/deletion'
+  '/project/deletion',
+  '/project/webhooks'
 ];
 
 interface Props {
index cc2b9631ee8a969f2c2f237bdf1cf95302042c96..338494e760424247af94f3f83ad9c7c3da1ff0fc 100644 (file)
@@ -36,7 +36,8 @@ const ADMIN_PATHS = [
   'delete',
   'permissions',
   'permission_templates',
-  'projects_management'
+  'projects_management',
+  'webhooks'
 ];
 
 export default function OrganizationNavigationAdministration({ location, organization }: Props) {
index 2c7b4f8b8631ce31cb762644c242d4d9bc7809a8..bbce61646477bf8cb10499b8d641c910d78928f5 100644 (file)
@@ -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>
index d48a369040021e764bee990a676e86fd9b815709..68a8f9d42bd66fef259430f1515d0e1acc5c1e80 100644 (file)
@@ -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>
+      </>
     );
   }
 }
index 91a12c2ba7cb49ba754fba4fa06cb5c5994b79f2..6a76095990fa5970ab17cc07be8c41e8343c6356 100644 (file)
@@ -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 (file)
index 0000000..33c0439
--- /dev/null
@@ -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 (file)
index 0000000..61cf8bb
--- /dev/null
@@ -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>
+    );
+  }
+}
index 92df63c688b513498a8c7f853ef1d038a2b0670c..3968f8bdfbe6c9c6e03ca1a1e7a9517c50944c52 100644 (file)
@@ -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 && (
index db7c401f832e4e2cd2628774d499533808b90c2f..42351e01a1f0525b7cfa32177725ec525c239223 100644 (file)
@@ -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}
index 6bfaea6527b38fa6cdd50900924102b0cc0b8f81..57f33ffe151e1acc04a50bd427c83ebb47c2cd9b 100644 (file)
@@ -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}
index 085def84f20a5884efed15f395b93a75cdbdd663..62d45726d81eb5140395a3576cd819f227c7b80a 100644 (file)
 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 (file)
index 0000000..cf7cc33
--- /dev/null
@@ -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 (file)
index 0000000..585df65
--- /dev/null
@@ -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} />);
+}
index 51aa702e132be5dc558c00e60bea80935a4d3770..b4331ce8ce81ca317ba97576d0ad6ee89ee3187b 100644 (file)
@@ -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
index 3490f184a157621f9c01db559e62bcd911891f8d..43c7785c6e03f430fd11f18254068a5ab0599dae 100644 (file)
@@ -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 (file)
index 0000000..42cff70
--- /dev/null
@@ -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 (file)
index 0000000..350ab57
--- /dev/null
@@ -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>
+`;
index abbb9ca3fb9424d4a14f942e286db32bb3e25f5e..7ba2be6a55f4d15a8e11acb0bd2c880444fdaf83 100644 (file)
@@ -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>
index a82a5f3ed35b3c639143c3f9c6b5970b1acb55f5..e3d35952ba5cceaff7b7f50c24479d2a1598c811 100644 (file)
@@ -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"
index ddf82de6e998f96d19effd2cc2f2096ed7144b63..ff8401ea4612d8abd46939cfa610dbb797b19a69 100644 (file)
@@ -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",
         }
       }
     />
index 5b457e11305fd22a066623cad1219503b90a4140..ecd54ab1adbe0a509867cecc1c271cb4ee3e7b3d 100644 (file)
@@ -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();
+  });
+});
index 732c0b6a6bebc8e7e73a738ebed4ff3df3a72cb3..2bde3a1184e9ca3e9f3d9693c917699d3dff8d9c 100644 (file)
@@ -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;
+  }
+}
index 8c4b0f0c56b1218ebe8bd35d99ac488907229e10..daf129eab20862b8cfcae87f7e52430ed99fd412 100644 (file)
@@ -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.
\ No newline at end of file