]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-10345 Add webhooks management actions
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Fri, 2 Feb 2018 10:34:03 +0000 (11:34 +0100)
committerGuillaume Jambet <guillaume.jambet@gmail.com>
Thu, 1 Mar 2018 14:21:05 +0000 (15:21 +0100)
* SONAR-10345 Add the webhooks create/update form
* SONAR-10345 Add the webhooks delete action
* SONAR-10345 Add fields validation on webhook create page

36 files changed:
server/sonar-web/package.json
server/sonar-web/src/main/js/api/webhooks.ts
server/sonar-web/src/main/js/app/styles/components/modals.css
server/sonar-web/src/main/js/apps/webhooks/components/App.tsx
server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/PageActions.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/PageHeader.tsx
server/sonar-web/src/main/js/apps/webhooks/components/WebhookActions.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/WebhookItem.tsx
server/sonar-web/src/main/js/apps/webhooks/components/WebhooksList.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/CreateWebhookForm-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/PageActions-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/PageHeader-test.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookActions-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhookItem-test.tsx
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/WebhooksList-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__/CreateWebhookForm-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/PageActions-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/PageHeader-test.tsx.snap
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhookActions-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhookItem-test.tsx.snap
server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/WebhooksList-test.tsx.snap
server/sonar-web/src/main/js/components/controls/ConfirmButton.tsx
server/sonar-web/src/main/js/components/controls/InputValidationField.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/ModalValidationField.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/ValidationModal.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/InputValidationField-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/ModalValidationField-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/InputValidationField-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ModalValidationField-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ValidationModal-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/icons-components/AlertSuccessIcon.tsx [new file with mode: 0644]
server/sonar-web/yarn.lock
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 56658e361e85d3318fec7330b8c8170ad79470ed..ac465a81a0bddc48e03122e9663b4c4348104806 100644 (file)
@@ -18,6 +18,7 @@
     "d3-shape": "1.2.0",
     "date-fns": "1.29.0",
     "escape-html": "1.0.3",
+    "formik": "0.11.7",
     "handlebars": "2.0.0",
     "history": "3.3.0",
     "intl-relativeformat": "2.1.0",
index 547702464b10a71f835c3a7e89c57de31d5d7176..f23cb2f079bd6bf9c0df40235901bf980548040b 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { getJSON } from '../helpers/request';
+import { getJSON, post, postJSON } from '../helpers/request';
 import throwGlobalError from '../app/utils/throwGlobalError';
 import { Webhook } from '../app/types';
 
+export function createWebhook(data: {
+  name: string;
+  organization: string | undefined;
+  project?: string;
+  url: string;
+}): Promise<{ webhook: Webhook }> {
+  return postJSON('/api/webhooks/create', data).catch(throwGlobalError);
+}
+
+export function deleteWebhook(data: { webhook: string }): Promise<void | Response> {
+  return post('/api/webhooks/delete', data).catch(throwGlobalError);
+}
+
 export function searchWebhooks(data: {
   organization: string | undefined;
   project?: string;
 }): Promise<{ webhooks: Webhook[] }> {
-  return getJSON('/api/webhooks/search', data).catch(throwGlobalError);
+  return getJSON('/api/webhooks/list', data).catch(throwGlobalError);
+}
+
+export function updateWebhook(data: {
+  webhook: string;
+  name: string;
+  url: string;
+}): Promise<void | Response> {
+  return post('/api/webhooks/update', data).catch(throwGlobalError);
 }
index 3bf2f396559ae7707f1c2a14552db14e85f5210d..dbe8a64cb9815449a0877352a77bd7b91870a089 100644 (file)
@@ -121,19 +121,24 @@ ul.modal-head-metadata li {
   height: auto;
 }
 
-.modal-field {
+.modal-field,
+.modal-large-field,
+.modal-validation-field {
   clear: both;
   display: block;
   padding: 5px 0 5px 130px;
 }
 
 .modal-large-field {
-  clear: both;
-  display: block;
   padding: 20px 40px;
 }
 
-.modal-field label {
+.modal-validation-field {
+  padding: 3px 0 3px 130px;
+}
+
+.modal-field label,
+.modal-validation-field label {
   position: relative;
   left: -140px;
   display: block;
@@ -149,7 +154,8 @@ ul.modal-head-metadata li {
   text-overflow: ellipsis;
 }
 
-.modal-field label.simple-label {
+.modal-field label.simple-label,
+.modal-validation-field label.simple-label {
   display: inline-block;
   vertical-align: middle;
   float: none;
@@ -215,6 +221,29 @@ ul.modal-head-metadata li {
   width: 100%;
 }
 
+.modal-validation-field input,
+.modal-validation-field textarea,
+.modal-validation-field .Select {
+  margin-right: 5px;
+  margin-bottom: 2px;
+  width: 250px;
+}
+
+.modal-validation-field input:not(.has-error),
+.modal-validation-field .Select:not(.has-error) {
+  margin-bottom: 18px;
+}
+
+.modal-validation-field .has-error,
+.modal-validation-field .has-error > .Select-control {
+  border-color: var(--red);
+}
+
+.modal-validation-field .is-valid,
+.modal-validation-field .is-valid > .Select-control {
+  border-color: var(--green);
+}
+
 .modal-field-description {
   padding-bottom: 4px;
   line-height: 1.4;
@@ -224,6 +253,10 @@ ul.modal-head-metadata li {
   text-overflow: ellipsis;
 }
 
+.modal-validation-field .modal-field-description {
+  margin-top: 2px;
+}
+
 .modal-foot {
   line-height: var(--controlHeight);
   padding: 10px;
index e1d559ef3072e3ebecebd6a407d622cc41f7aac2..d48a369040021e764bee990a676e86fd9b815709 100644 (file)
  */
 import * as React from 'react';
 import { Helmet } from 'react-helmet';
+import PageActions from './PageActions';
 import PageHeader from './PageHeader';
 import WebhooksList from './WebhooksList';
-import { searchWebhooks } from '../../../api/webhooks';
+import { createWebhook, deleteWebhook, searchWebhooks, updateWebhook } from '../../../api/webhooks';
 import { LightComponent, Organization, Webhook } from '../../../app/types';
 import { translate } from '../../../helpers/l10n';
 
@@ -48,12 +49,8 @@ export default class App extends React.PureComponent<Props, State> {
     this.mounted = false;
   }
 
-  fetchWebhooks = ({ organization, component } = this.props) => {
-    this.setState({ loading: true });
-    searchWebhooks({
-      organization: organization && organization.key,
-      project: component && component.key
-    }).then(
+  fetchWebhooks = () => {
+    return searchWebhooks(this.getScopeParams()).then(
       ({ webhooks }) => {
         if (this.mounted) {
           this.setState({ loading: false, webhooks });
@@ -67,15 +64,65 @@ export default class App extends React.PureComponent<Props, State> {
     );
   };
 
+  getScopeParams = ({ organization, component } = this.props) => {
+    return { organization: organization && organization.key, project: component && component.key };
+  };
+
+  handleCreate = (data: { name: string; url: string }) => {
+    return createWebhook({
+      ...data,
+      ...this.getScopeParams()
+    }).then(({ webhook }) => {
+      if (this.mounted) {
+        this.setState(({ webhooks }) => ({ webhooks: [...webhooks, webhook] }));
+      }
+    });
+  };
+
+  handleDelete = (webhook: string) => {
+    return deleteWebhook({ webhook }).then(() => {
+      if (this.mounted) {
+        this.setState(({ webhooks }) => ({
+          webhooks: webhooks.filter(item => item.key !== webhook)
+        }));
+      }
+    });
+  };
+
+  handleUpdate = (data: { webhook: string; name: string; url: string }) => {
+    return updateWebhook(data).then(() => {
+      if (this.mounted) {
+        this.setState(({ webhooks }) => ({
+          webhooks: webhooks.map(
+            webhook => (webhook.key === data.webhook ? { ...webhook, ...data } : webhook)
+          )
+        }));
+      }
+    });
+  };
+
   render() {
     const { loading, webhooks } = this.state;
+
     return (
       <div className="page page-limited">
         <Helmet title={translate('webhooks.page')} />
-        <PageHeader loading={loading} />
+
+        <PageHeader loading={loading}>
+          <PageActions
+            loading={loading}
+            onCreate={this.handleCreate}
+            webhooksCount={webhooks.length}
+          />
+        </PageHeader>
+
         {!loading && (
           <div className="boxed-group boxed-group-inner">
-            <WebhooksList webhooks={webhooks} />
+            <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
new file mode 100644 (file)
index 0000000..91a12c2
--- /dev/null
@@ -0,0 +1,131 @@
+/*
+ * 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 { FormikProps } from 'formik';
+import ValidationModal from '../../../components/controls/ValidationModal';
+import InputValidationField from '../../../components/controls/InputValidationField';
+import { Webhook } from '../../../app/types';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  onClose: () => void;
+  onDone: (data: { name: string; url: string }) => Promise<void>;
+  webhook?: Webhook;
+}
+
+interface Values {
+  name: string;
+  url: string;
+}
+
+export default class CreateWebhookForm extends React.PureComponent<Props> {
+  handleCancelClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    this.props.onClose();
+  };
+
+  handleValidate = (data: Values) => {
+    const { name, url } = data;
+    const errors: { name?: string; url?: string } = {};
+    if (!name.trim()) {
+      errors.name = translate('webhooks.name.required');
+    }
+    if (!url.trim()) {
+      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');
+    }
+    return errors;
+  };
+
+  render() {
+    const { webhook } = this.props;
+    const isUpdate = !!webhook;
+    const modalHeader = isUpdate ? translate('webhooks.update') : translate('webhooks.create');
+    const confirmButtonText = isUpdate ? translate('update_verb') : translate('create');
+    return (
+      <ValidationModal
+        confirmButtonText={confirmButtonText}
+        header={modalHeader}
+        initialValues={{
+          name: webhook ? webhook.name : '',
+          url: webhook ? webhook.url : ''
+        }}
+        isInitialValid={isUpdate}
+        onClose={this.props.onClose}
+        onSubmit={this.props.onDone}
+        validate={this.handleValidate}>
+        {({
+          dirty,
+          errors,
+          handleBlur,
+          handleChange,
+          isSubmitting,
+          touched,
+          values
+        }: FormikProps<Values>) => (
+          <>
+            <InputValidationField
+              autoFocus={true}
+              dirty={dirty}
+              disabled={isSubmitting}
+              error={errors.name}
+              id="webhook-name"
+              label={
+                <label htmlFor="webhook-name">
+                  {translate('webhooks.name')}
+                  <em className="mandatory">*</em>
+                </label>
+              }
+              name="name"
+              onBlur={handleBlur}
+              onChange={handleChange}
+              touched={touched.name}
+              type="text"
+              value={values.name}
+            />
+            <InputValidationField
+              description={translate('webhooks.url.description')}
+              disabled={isSubmitting}
+              dirty={dirty}
+              error={errors.url}
+              id="webhook-url"
+              label={
+                <label htmlFor="webhook-url">
+                  {translate('webhooks.url')}
+                  <em className="mandatory">*</em>
+                </label>
+              }
+              name="url"
+              onBlur={handleBlur}
+              onChange={handleChange}
+              touched={touched.url}
+              type="text"
+              value={values.url}
+            />
+          </>
+        )}
+      </ValidationModal>
+    );
+  }
+}
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
new file mode 100644 (file)
index 0000000..92df63c
--- /dev/null
@@ -0,0 +1,89 @@
+/*
+ * 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 CreateWebhookForm from './CreateWebhookForm';
+import Tooltip from '../../../components/controls/Tooltip';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+
+interface Props {
+  loading: boolean;
+  onCreate: (data: { name: string; url: string }) => Promise<void>;
+  webhooksCount: number;
+}
+
+interface State {
+  openCreate: boolean;
+}
+
+const WEBHOOKS_LIMIT = 10;
+
+export default class PageActions extends React.PureComponent<Props, State> {
+  mounted: boolean = false;
+  state: State = { openCreate: false };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleCreateClose = () => {
+    if (this.mounted) {
+      this.setState({ openCreate: false });
+    }
+  };
+
+  handleCreateOpen = () => {
+    this.setState({ openCreate: true });
+  };
+
+  renderCreate = () => {
+    if (this.props.webhooksCount >= WEBHOOKS_LIMIT) {
+      return (
+        <Tooltip
+          overlay={translateWithParameters('webhooks.maximum_reached', WEBHOOKS_LIMIT)}
+          placement="left">
+          <button className="js-webhook-create disabled">{translate('create')}</button>
+        </Tooltip>
+      );
+    }
+
+    return (
+      <>
+        <button className="js-webhook-create" onClick={this.handleCreateOpen}>
+          {translate('create')}
+        </button>
+        {this.state.openCreate && (
+          <CreateWebhookForm onClose={this.handleCreateClose} onDone={this.props.onCreate} />
+        )}
+      </>
+    );
+  };
+
+  render() {
+    if (this.props.loading) {
+      return null;
+    }
+
+    return <div className="page-actions">{this.renderCreate()}</div>;
+  }
+}
index 229b835e5e97772b4478e0fa1691ec87ed904a63..2a23425df063249ea353033112cbc64c4308d2fb 100644 (file)
@@ -22,15 +22,18 @@ import { FormattedMessage } from 'react-intl';
 import { translate } from '../../../helpers/l10n';
 
 interface Props {
+  children?: React.ReactNode;
   loading: boolean;
 }
 
-export default function PageHeader({ loading }: Props) {
+export default function PageHeader({ children, loading }: Props) {
   return (
     <header className="page-header">
       <h1 className="page-title">{translate('webhooks.page')}</h1>
       {loading && <i className="spinner" />}
 
+      {children}
+
       <p className="page-description">
         <FormattedMessage
           defaultMessage={translate('webhooks.description')}
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
new file mode 100644 (file)
index 0000000..db7c401
--- /dev/null
@@ -0,0 +1,105 @@
+/*
+ * 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 CreateWebhookForm from './CreateWebhookForm';
+import ActionsDropdown, {
+  ActionsDropdownItem,
+  ActionsDropdownDivider
+} from '../../../components/controls/ActionsDropdown';
+import ConfirmButton from '../../../components/controls/ConfirmButton';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { Webhook } from '../../../app/types';
+
+interface Props {
+  onDelete: (webhook: string) => Promise<void>;
+  onUpdate: (data: { webhook: string; name: string; url: string }) => Promise<void>;
+  webhook: Webhook;
+}
+
+interface State {
+  updating: boolean;
+}
+
+export default class WebhookActions extends React.PureComponent<Props, State> {
+  mounted: boolean = false;
+  state: State = { updating: false };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleDelete = () => {
+    return this.props.onDelete(this.props.webhook.key);
+  };
+
+  handleUpdate = (data: { name: string; url: string }) => {
+    return this.props.onUpdate({ ...data, webhook: this.props.webhook.key });
+  };
+
+  handleUpdateClick = () => {
+    this.setState({ updating: true });
+  };
+
+  handleUpdatingStop = () => {
+    this.setState({ updating: false });
+  };
+
+  render() {
+    const { webhook } = this.props;
+
+    return (
+      <>
+        <ActionsDropdown className="big-spacer-left">
+          <ActionsDropdownItem className="js-webhook-update" onClick={this.handleUpdateClick}>
+            {translate('update_verb')}
+          </ActionsDropdownItem>
+          <ActionsDropdownDivider />
+          <ConfirmButton
+            confirmButtonText={translate('delete')}
+            isDestructive={true}
+            modalBody={translateWithParameters('webhooks.delete.confirm', webhook.name)}
+            modalHeader={translate('webhooks.delete')}
+            onConfirm={this.handleDelete}>
+            {({ onClick }) => (
+              <ActionsDropdownItem
+                className="js-webhook-delete"
+                destructive={true}
+                onClick={onClick}>
+                {translate('delete')}
+              </ActionsDropdownItem>
+            )}
+          </ConfirmButton>
+        </ActionsDropdown>
+
+        {this.state.updating && (
+          <CreateWebhookForm
+            onClose={this.handleUpdatingStop}
+            onDone={this.handleUpdate}
+            webhook={webhook}
+          />
+        )}
+      </>
+    );
+  }
+}
index f156900d581c4d3c7c24865da97305300271fc29..b51ceb085af1157953a6114cbcf48609e3287863 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import WebhookActions from './WebhookActions';
 import { Webhook } from '../../../app/types';
 
 interface Props {
+  onDelete: (webhook: string) => Promise<void>;
+  onUpdate: (data: { webhook: string; name: string; url: string }) => Promise<void>;
   webhook: Webhook;
 }
 
-export default function WebhookItem({ webhook }: Props) {
+export default function WebhookItem({ onDelete, onUpdate, webhook }: Props) {
   return (
     <tr>
       <td>{webhook.name}</td>
       <td>{webhook.url}</td>
+      <td className="thin nowrap text-right">
+        <WebhookActions onDelete={onDelete} onUpdate={onUpdate} webhook={webhook} />
+      </td>
     </tr>
   );
 }
index 600fad4e8d333190d7d17348db251bce0e4006dd..6bfaea6527b38fa6cdd50900924102b0cc0b8f81 100644 (file)
@@ -23,6 +23,8 @@ import { Webhook } from '../../../app/types';
 import { translate } from '../../../helpers/l10n';
 
 interface Props {
+  onDelete: (webhook: string) => Promise<void>;
+  onUpdate: (data: { webhook: string; name: string; url: string }) => Promise<void>;
   webhooks: Webhook[];
 }
 
@@ -32,6 +34,7 @@ export default class WebhooksList extends React.PureComponent<Props> {
       <tr>
         <th>{translate('name')}</th>
         <th>{translate('webhooks.url')}</th>
+        <th />
       </tr>
     </thead>
   );
@@ -45,7 +48,14 @@ export default class WebhooksList extends React.PureComponent<Props> {
       <table className="data zebra">
         {this.renderHeader()}
         <tbody>
-          {webhooks.map(webhook => <WebhookItem key={webhook.key} webhook={webhook} />)}
+          {webhooks.map(webhook => (
+            <WebhookItem
+              key={webhook.key}
+              onDelete={this.props.onDelete}
+              onUpdate={this.props.onUpdate}
+              webhook={webhook}
+            />
+          ))}
         </tbody>
       </table>
     );
diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/CreateWebhookForm-test.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/CreateWebhookForm-test.tsx
new file mode 100644 (file)
index 0000000..a5e20a6
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+ * 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 CreateWebhookForm from '../CreateWebhookForm';
+
+const webhook = { key: '1', name: 'foo', url: 'http://foo.bar' };
+
+it('should render correctly when creating a new webhook', () => {
+  expect(getWrapper()).toMatchSnapshot();
+});
+
+it('should render correctly when updating a webhook', () => {
+  expect(getWrapper({ webhook })).toMatchSnapshot();
+});
+
+function getWrapper(props = {}) {
+  return shallow(
+    <CreateWebhookForm onClose={jest.fn()} onDone={jest.fn(() => Promise.resolve())} {...props} />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/PageActions-test.tsx b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/PageActions-test.tsx
new file mode 100644 (file)
index 0000000..f50a9b0
--- /dev/null
@@ -0,0 +1,51 @@
+/*
+ * 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 PageActions from '../PageActions';
+import { click } from '../../../../helpers/testUtils';
+
+it('should render correctly', () => {
+  expect(getWrapper()).toMatchSnapshot();
+});
+
+it('should not render', () => {
+  expect(getWrapper({ loading: true }).type()).toBeNull();
+});
+
+it('should not allow to create a new webhook', () => {
+  expect(getWrapper({ webhooksCount: 10 })).toMatchSnapshot();
+});
+
+it('should display the create form', () => {
+  const onCreate = jest.fn();
+  const wrapper = getWrapper({ onCreate });
+  click(wrapper.find('.js-webhook-create'));
+  expect(wrapper.find('CreateWebhookForm').exists()).toBeTruthy();
+  wrapper.find('CreateWebhookForm').prop<Function>('onDone')({
+    name: 'foo',
+    url: 'http://foo.bar'
+  });
+  expect(onCreate).lastCalledWith({ name: 'foo', url: 'http://foo.bar' });
+});
+
+function getWrapper(props = {}) {
+  return shallow(<PageActions onCreate={jest.fn()} loading={false} webhooksCount={5} {...props} />);
+}
index 148d1570cd4f828168f31beabeea5a27fd923acb..055228c98a274b547071ed0fa8d4bec1c676d44c 100644 (file)
@@ -22,5 +22,11 @@ import { shallow } from 'enzyme';
 import PageHeader from '../PageHeader';
 
 it('should render correctly', () => {
-  expect(shallow(<PageHeader loading={true} />)).toMatchSnapshot();
+  expect(
+    shallow(
+      <PageHeader loading={true}>
+        <div />
+      </PageHeader>
+    )
+  ).toMatchSnapshot();
 });
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
new file mode 100644 (file)
index 0000000..51aa702
--- /dev/null
@@ -0,0 +1,66 @@
+/*
+ * 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 WebhookActions from '../WebhookActions';
+import { click } from '../../../../helpers/testUtils';
+
+const webhook = { key: '1', name: 'foo', url: 'http://foo.bar' };
+
+it('should render correctly', () => {
+  expect(getWrapper()).toMatchSnapshot();
+});
+
+it('should display the update webhook form', () => {
+  const onUpdate = jest.fn(() => Promise.resolve());
+  const wrapper = getWrapper({ onUpdate });
+  click(wrapper.find('.js-webhook-update'));
+  expect(wrapper.find('CreateWebhookForm').exists()).toBeTruthy();
+  wrapper.find('CreateWebhookForm').prop<Function>('onDone')({
+    name: webhook.name,
+    url: webhook.url
+  });
+  expect(onUpdate).lastCalledWith({ webhook: webhook.key, name: webhook.name, url: webhook.url });
+});
+
+it('should display the delete webhook form', () => {
+  const onDelete = jest.fn(() => Promise.resolve());
+  const wrapper = getWrapper({ onDelete });
+  click(
+    wrapper
+      .find('ConfirmButton')
+      .dive()
+      .find('.js-webhook-delete')
+  );
+  expect(wrapper.find('ConfirmButton').exists()).toBeTruthy();
+  wrapper.find('ConfirmButton').prop<Function>('onConfirm')();
+  expect(onDelete).lastCalledWith(webhook.key);
+});
+
+function getWrapper(props = {}) {
+  return shallow(
+    <WebhookActions
+      onDelete={jest.fn(() => Promise.resolve())}
+      onUpdate={jest.fn(() => Promise.resolve())}
+      webhook={webhook}
+      {...props}
+    />
+  );
+}
index ab0a1a5d0e45f49159c8c3b235793cd5c7e97caf..9322cf58e0a4b562d55564b1b84997a6291a4066 100644 (file)
@@ -24,5 +24,13 @@ import WebhookItem from '../WebhookItem';
 const webhook = { key: '1', name: 'my webhook', url: 'http://webhook.target' };
 
 it('should render correctly', () => {
-  expect(shallow(<WebhookItem webhook={webhook} />)).toMatchSnapshot();
+  expect(
+    shallow(
+      <WebhookItem
+        onDelete={jest.fn(() => Promise.resolve())}
+        onUpdate={jest.fn(() => Promise.resolve())}
+        webhook={webhook}
+      />
+    )
+  ).toMatchSnapshot();
 });
index e0337cc8f5c60d5dd8a6920a7b93cf53ef14791e..531eaeded8f5705c31c76271fc04b9ccae42aa90 100644 (file)
@@ -27,9 +27,20 @@ const webhooks = [
 ];
 
 it('should correctly render empty webhook list', () => {
-  expect(shallow(<WebhooksList webhooks={[]} />)).toMatchSnapshot();
+  expect(getWrapper({ webhooks: [] })).toMatchSnapshot();
 });
 
 it('should correctly render the webhooks', () => {
-  expect(shallow(<WebhooksList webhooks={webhooks} />)).toMatchSnapshot();
+  expect(getWrapper()).toMatchSnapshot();
 });
+
+function getWrapper(props = {}) {
+  return shallow(
+    <WebhooksList
+      onDelete={jest.fn(() => Promise.resolve())}
+      onUpdate={jest.fn(() => Promise.resolve())}
+      webhooks={webhooks}
+      {...props}
+    />
+  );
+}
index 717e2e76bb00e2d751244796626a866f36bbf9e4..3490f184a157621f9c01db559e62bcd911891f8d 100644 (file)
@@ -11,7 +11,13 @@ exports[`should be in loading status 1`] = `
   />
   <PageHeader
     loading={true}
-  />
+  >
+    <PageActions
+      loading={true}
+      onCreate={[Function]}
+      webhooksCount={0}
+    />
+  </PageHeader>
 </div>
 `;
 
@@ -26,11 +32,19 @@ exports[`should fetch webhooks and display them 1`] = `
   />
   <PageHeader
     loading={false}
-  />
+  >
+    <PageActions
+      loading={false}
+      onCreate={[Function]}
+      webhooksCount={0}
+    />
+  </PageHeader>
   <div
     className="boxed-group boxed-group-inner"
   >
     <WebhooksList
+      onDelete={[Function]}
+      onUpdate={[Function]}
       webhooks={Array []}
     />
   </div>
diff --git a/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/CreateWebhookForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/webhooks/components/__tests__/__snapshots__/CreateWebhookForm-test.tsx.snap
new file mode 100644 (file)
index 0000000..8ad194a
--- /dev/null
@@ -0,0 +1,35 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly when creating a new webhook 1`] = `
+<ValidationModal
+  confirmButtonText="create"
+  header="webhooks.create"
+  initialValues={
+    Object {
+      "name": "",
+      "url": "",
+    }
+  }
+  isInitialValid={false}
+  onClose={[MockFunction]}
+  onSubmit={[MockFunction]}
+  validate={[Function]}
+/>
+`;
+
+exports[`should render correctly when updating a webhook 1`] = `
+<ValidationModal
+  confirmButtonText="update_verb"
+  header="webhooks.update"
+  initialValues={
+    Object {
+      "name": "foo",
+      "url": "http://foo.bar",
+    }
+  }
+  isInitialValid={true}
+  onClose={[MockFunction]}
+  onSubmit={[MockFunction]}
+  validate={[Function]}
+/>
+`;
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
new file mode 100644 (file)
index 0000000..abbb9ca
--- /dev/null
@@ -0,0 +1,33 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should not allow to create a new webhook 1`] = `
+<div
+  className="page-actions"
+>
+  <Tooltip
+    overlay="webhooks.maximum_reached.10"
+    placement="left"
+  >
+    <button
+      className="js-webhook-create disabled"
+    >
+      create
+    </button>
+  </Tooltip>
+</div>
+`;
+
+exports[`should render correctly 1`] = `
+<div
+  className="page-actions"
+>
+  <React.Fragment>
+    <button
+      className="js-webhook-create"
+      onClick={[Function]}
+    >
+      create
+    </button>
+  </React.Fragment>
+</div>
+`;
index e40da7ba2d35bb9fae82dbd470daa7782023318c..e44b8019fca435203033bf7b8c205c15fe8caa72 100644 (file)
@@ -12,6 +12,7 @@ exports[`should render correctly 1`] = `
   <i
     className="spinner"
   />
+  <div />
   <p
     className="page-description"
   >
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
new file mode 100644 (file)
index 0000000..a82a5f3
--- /dev/null
@@ -0,0 +1,24 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<React.Fragment>
+  <ActionsDropdown
+    className="big-spacer-left"
+  >
+    <ActionsDropdownItem
+      className="js-webhook-update"
+      onClick={[Function]}
+    >
+      update_verb
+    </ActionsDropdownItem>
+    <ActionsDropdownDivider />
+    <ConfirmButton
+      confirmButtonText="delete"
+      isDestructive={true}
+      modalBody="webhooks.delete.confirm.foo"
+      modalHeader="webhooks.delete"
+      onConfirm={[Function]}
+    />
+  </ActionsDropdown>
+</React.Fragment>
+`;
index b6968c76bedb88f84d352132458c8e1891515059..7f45d646e0ab1790fbb49aa59e488536f2be7fa2 100644 (file)
@@ -8,5 +8,20 @@ exports[`should render correctly 1`] = `
   <td>
     http://webhook.target
   </td>
+  <td
+    className="thin nowrap text-right"
+  >
+    <WebhookActions
+      onDelete={[MockFunction]}
+      onUpdate={[MockFunction]}
+      webhook={
+        Object {
+          "key": "1",
+          "name": "my webhook",
+          "url": "http://webhook.target",
+        }
+      }
+    />
+  </td>
 </tr>
 `;
index 01bcb34685a7e5e5b166ef714ae93c1b832cc6bd..ddf82de6e998f96d19effd2cc2f2096ed7144b63 100644 (file)
@@ -18,11 +18,14 @@ exports[`should correctly render the webhooks 1`] = `
       <th>
         webhooks.url
       </th>
+      <th />
     </tr>
   </thead>
   <tbody>
     <WebhookItem
       key="1"
+      onDelete={[MockFunction]}
+      onUpdate={[MockFunction]}
       webhook={
         Object {
           "key": "1",
@@ -33,6 +36,8 @@ exports[`should correctly render the webhooks 1`] = `
     />
     <WebhookItem
       key="2"
+      onDelete={[MockFunction]}
+      onUpdate={[MockFunction]}
       webhook={
         Object {
           "key": "2",
index ab766a9eed6719ecbf06f0b976348e1a0699cf42..354e11add9ca051d8e592a12cffa5df68e730eb2 100644 (file)
@@ -110,7 +110,9 @@ export default class ConfirmButton extends React.PureComponent<Props, State> {
                     disabled={submitting}>
                     {confirmButtonText}
                   </SubmitButton>
-                  <ResetButtonLink onClick={onCloseClick}>{translate('cancel')}</ResetButtonLink>
+                  <ResetButtonLink disabled={submitting} onClick={onCloseClick}>
+                    {translate('cancel')}
+                  </ResetButtonLink>
                 </footer>
               </form>
             )}
diff --git a/server/sonar-web/src/main/js/components/controls/InputValidationField.tsx b/server/sonar-web/src/main/js/components/controls/InputValidationField.tsx
new file mode 100644 (file)
index 0000000..aae2cde
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * 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 * as classNames from 'classnames';
+import ModalValidationField from './ModalValidationField';
+
+interface Props {
+  autoFocus?: boolean;
+  className?: string;
+  description?: string;
+  dirty: boolean;
+  disabled: boolean;
+  error: string | undefined;
+  id?: string;
+  label?: React.ReactNode;
+  name: string;
+  onBlur: (event: React.FocusEvent<any>) => void;
+  onChange: (event: React.ChangeEvent<any>) => void;
+  placeholder?: string;
+  touched: boolean;
+  type?: string;
+  value: string;
+}
+
+export default function InputValidationField({ className, ...props }: Props) {
+  const { description, dirty, error, label, touched, ...inputProps } = props;
+  const modalValidationProps = { description, dirty, error, label, touched };
+  return (
+    <ModalValidationField {...modalValidationProps}>
+      {({ className: validationClassName }) => (
+        <input className={classNames(className, validationClassName)} {...inputProps} />
+      )}
+    </ModalValidationField>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/controls/ModalValidationField.tsx b/server/sonar-web/src/main/js/components/controls/ModalValidationField.tsx
new file mode 100644 (file)
index 0000000..503b589
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * 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 * as classNames from 'classnames';
+import AlertErrorIcon from '../icons-components/AlertErrorIcon';
+import AlertSuccessIcon from '../icons-components/AlertSuccessIcon';
+
+interface Props {
+  children: (props: { className?: string }) => React.ReactNode;
+  description?: string;
+  dirty: boolean;
+  error: string | undefined;
+  label?: React.ReactNode;
+  touched: boolean;
+}
+
+export default function ModalValidationField(props: Props) {
+  const { description, dirty, error } = props;
+
+  const isValid = dirty && props.touched && error === undefined;
+  const showError = dirty && props.touched && error !== undefined;
+  return (
+    <div className="modal-validation-field">
+      {props.label}
+      {props.children({ className: classNames({ 'has-error': showError, 'is-valid': isValid }) })}
+      {showError && <AlertErrorIcon className="little-spacer-top" />}
+      {isValid && <AlertSuccessIcon className="little-spacer-top" />}
+      {showError && <p className="text-danger">{error}</p>}
+      {description && <div className="modal-field-description">{description}</div>}
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/controls/ValidationModal.tsx b/server/sonar-web/src/main/js/components/controls/ValidationModal.tsx
new file mode 100644 (file)
index 0000000..d52a0b3
--- /dev/null
@@ -0,0 +1,108 @@
+/*
+ * 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 { withFormik, Form, FormikActions, FormikProps } from 'formik';
+import Modal from './Modal';
+import DeferredSpinner from '../common/DeferredSpinner';
+import { translate } from '../../helpers/l10n';
+
+interface InnerFormProps<Values> {
+  children: (props: FormikProps<Values>) => React.ReactNode;
+  confirmButtonText: string;
+  header: string;
+  initialValues: Values;
+}
+
+interface Props<Values> extends InnerFormProps<Values> {
+  isInitialValid?: boolean;
+  onClose: () => void;
+  validate: (data: Values) => void | object | Promise<object>;
+  onSubmit: (data: Values) => void | Promise<void>;
+}
+
+export default class ValidationModal<Values> extends React.PureComponent<Props<Values>> {
+  handleCancelClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    this.props.onClose();
+  };
+
+  handleSubmit = (data: Values, { setSubmitting }: FormikActions<Values>) => {
+    const result = this.props.onSubmit(data);
+    if (result) {
+      result.then(
+        () => {
+          setSubmitting(false);
+          this.props.onClose();
+        },
+        () => {
+          setSubmitting(false);
+        }
+      );
+    } else {
+      setSubmitting(false);
+      this.props.onClose();
+    }
+  };
+
+  render() {
+    const { header } = this.props;
+
+    const InnerForm = withFormik<InnerFormProps<Values>, Values>({
+      handleSubmit: this.handleSubmit,
+      isInitialValid: this.props.isInitialValid,
+      mapPropsToValues: props => props.initialValues,
+      validate: this.props.validate
+    })(props => (
+      <Form>
+        <div className="modal-head">
+          <h2>{props.header}</h2>
+        </div>
+
+        <div className="modal-body">{props.children(props)}</div>
+
+        <footer className="modal-foot">
+          <DeferredSpinner className="spacer-right" loading={props.isSubmitting} />
+          <button disabled={props.isSubmitting || !props.isValid || !props.dirty} type="submit">
+            {props.confirmButtonText}
+          </button>
+          <button
+            className="button-link"
+            disabled={props.isSubmitting}
+            onClick={this.handleCancelClick}
+            type="reset">
+            {translate('cancel')}
+          </button>
+        </footer>
+      </Form>
+    ));
+
+    return (
+      <Modal contentLabel={header} onRequestClose={this.props.onClose}>
+        <InnerForm
+          confirmButtonText={this.props.confirmButtonText}
+          header={header}
+          initialValues={this.props.initialValues}>
+          {this.props.children}
+        </InnerForm>
+      </Modal>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/InputValidationField-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/InputValidationField-test.tsx
new file mode 100644 (file)
index 0000000..e0864a1
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * 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 InputValidationField from '../InputValidationField';
+
+it('should render correctly', () => {
+  expect(getWrapper()).toMatchSnapshot();
+});
+
+function getWrapper(props = {}) {
+  return shallow(
+    <InputValidationField
+      description="Field description"
+      dirty={true}
+      disabled={false}
+      error="Bad formatting"
+      label="Foo field"
+      name="field"
+      onBlur={jest.fn()}
+      onChange={jest.fn()}
+      touched={true}
+      value="foo"
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/ModalValidationField-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/ModalValidationField-test.tsx
new file mode 100644 (file)
index 0000000..ae9a2f7
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * 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 ModalValidationField from '../ModalValidationField';
+
+it('should display the field without any error/validation', () => {
+  expect(getWrapper({ description: 'Describe Foo.', touched: false })).toMatchSnapshot();
+  expect(getWrapper({ dirty: false })).toMatchSnapshot();
+});
+
+it('should display the field as valid', () => {
+  expect(getWrapper({ error: undefined })).toMatchSnapshot();
+});
+
+it('should display the field with an error', () => {
+  expect(getWrapper()).toMatchSnapshot();
+});
+
+function getWrapper(props = {}) {
+  return shallow(
+    <ModalValidationField
+      dirty={true}
+      error="Is required"
+      label={<label>Foo</label>}
+      touched={true}
+      {...props}>
+      {({ className }) => <input className={className} type="text" />}
+    </ModalValidationField>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx
new file mode 100644 (file)
index 0000000..f304854
--- /dev/null
@@ -0,0 +1,67 @@
+/*
+ * 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 { FormikProps } from 'formik';
+import ValidationModal from '../ValidationModal';
+
+it('should render correctly', () => {
+  const { wrapper, inner } = getWrapper();
+  expect(wrapper).toMatchSnapshot();
+  expect(inner).toMatchSnapshot();
+});
+
+interface Values {
+  field: string;
+}
+
+function getWrapper(props = {}) {
+  const wrapper = shallow(
+    <ValidationModal
+      confirmButtonText="confirm"
+      header="title"
+      initialValues={{ field: 'foo' }}
+      isInitialValid={true}
+      onClose={jest.fn()}
+      validate={(values: Values) => ({ field: values.field.length < 2 && 'Too small' })}
+      onSubmit={jest.fn(() => Promise.resolve())}
+      {...props}>
+      {(props: FormikProps<Values>) => (
+        <form onSubmit={props.handleSubmit}>
+          <input
+            onChange={props.handleChange}
+            onBlur={props.handleBlur}
+            name="field"
+            type="text"
+            value={props.values.field}
+          />
+        </form>
+      )}
+    </ValidationModal>
+  );
+  return {
+    wrapper,
+    inner: wrapper
+      .childAt(0)
+      .dive()
+      .dive()
+      .dive()
+  };
+}
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/InputValidationField-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/InputValidationField-test.tsx.snap
new file mode 100644 (file)
index 0000000..8afa4d9
--- /dev/null
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<ModalValidationField
+  description="Field description"
+  dirty={true}
+  error="Bad formatting"
+  label="Foo field"
+  touched={true}
+/>
+`;
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ModalValidationField-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ModalValidationField-test.tsx.snap
new file mode 100644 (file)
index 0000000..dc901ce
--- /dev/null
@@ -0,0 +1,73 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should display the field as valid 1`] = `
+<div
+  className="modal-validation-field"
+>
+  <label>
+    Foo
+  </label>
+  <input
+    className="is-valid"
+    type="text"
+  />
+  <AlertSuccessIcon
+    className="little-spacer-top"
+  />
+</div>
+`;
+
+exports[`should display the field with an error 1`] = `
+<div
+  className="modal-validation-field"
+>
+  <label>
+    Foo
+  </label>
+  <input
+    className="has-error"
+    type="text"
+  />
+  <AlertErrorIcon
+    className="little-spacer-top"
+  />
+  <p
+    className="text-danger"
+  >
+    Is required
+  </p>
+</div>
+`;
+
+exports[`should display the field without any error/validation 1`] = `
+<div
+  className="modal-validation-field"
+>
+  <label>
+    Foo
+  </label>
+  <input
+    className=""
+    type="text"
+  />
+  <div
+    className="modal-field-description"
+  >
+    Describe Foo.
+  </div>
+</div>
+`;
+
+exports[`should display the field without any error/validation 2`] = `
+<div
+  className="modal-validation-field"
+>
+  <label>
+    Foo
+  </label>
+  <input
+    className=""
+    type="text"
+  />
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ValidationModal-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ValidationModal-test.tsx.snap
new file mode 100644 (file)
index 0000000..898438e
--- /dev/null
@@ -0,0 +1,68 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<Modal
+  contentLabel="title"
+  onRequestClose={[MockFunction]}
+>
+  <C
+    confirmButtonText="confirm"
+    header="title"
+    initialValues={
+      Object {
+        "field": "foo",
+      }
+    }
+  />
+</Modal>
+`;
+
+exports[`should render correctly 2`] = `
+<Form>
+  <div
+    className="modal-head"
+  >
+    <h2>
+      title
+    </h2>
+  </div>
+  <div
+    className="modal-body"
+  >
+    <form
+      onSubmit={[Function]}
+    >
+      <input
+        name="field"
+        onBlur={[Function]}
+        onChange={[Function]}
+        type="text"
+        value="foo"
+      />
+    </form>
+  </div>
+  <footer
+    className="modal-foot"
+  >
+    <DeferredSpinner
+      className="spacer-right"
+      loading={false}
+      timeout={100}
+    />
+    <button
+      disabled={true}
+      type="submit"
+    >
+      confirm
+    </button>
+    <button
+      className="button-link"
+      disabled={false}
+      onClick={[Function]}
+      type="reset"
+    >
+      cancel
+    </button>
+  </footer>
+</Form>
+`;
diff --git a/server/sonar-web/src/main/js/components/icons-components/AlertSuccessIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/AlertSuccessIcon.tsx
new file mode 100644 (file)
index 0000000..432461a
--- /dev/null
@@ -0,0 +1,40 @@
+/*
+ * 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 * as theme from '../../app/theme';
+import { IconProps } from './types';
+
+export default function AlertSuccessIcon({ className, fill = theme.green, size = 16 }: IconProps) {
+  return (
+    <svg
+      className={className}
+      width={size}
+      height={size}
+      viewBox="0 0 16 16"
+      version="1.1"
+      xmlnsXlink="http://www.w3.org/1999/xlink"
+      xmlSpace="preserve">
+      <path
+        style={{ fill }}
+        d="M12.607 6.554q0-0.25-0.161-0.411l-0.813-0.804q-0.17-0.17-0.402-0.17t-0.402 0.17l-3.643 3.634-2.018-2.018q-0.17-0.17-0.402-0.17t-0.402 0.17l-0.813 0.804q-0.161 0.161-0.161 0.411 0 0.241 0.161 0.402l3.232 3.232q0.17 0.17 0.402 0.17 0.241 0 0.411-0.17l4.848-4.848q0.161-0.161 0.161-0.402zM14.857 8q0 1.866-0.92 3.442t-2.496 2.496-3.442 0.92-3.442-0.92-2.496-2.496-0.92-3.442 0.92-3.442 2.496-2.496 3.442-0.92 3.442 0.92 2.496 2.496 0.92 3.442z"
+      />
+    </svg>
+  );
+}
index 7be3c394b578fae6715fcb9091c1143a2701ec1a..4f6610a2e05e10196f037a8db5c3d342d6aadd04 100644 (file)
@@ -3173,6 +3173,16 @@ form-data@~2.3.1:
     combined-stream "^1.0.5"
     mime-types "^2.1.12"
 
+formik@0.11.7:
+  version "0.11.7"
+  resolved "https://registry.yarnpkg.com/formik/-/formik-0.11.7.tgz#7b2c66a5546c793dfb07b39b965aef69dcd39326"
+  dependencies:
+    lodash.clonedeep "^4.5.0"
+    lodash.isequal "4.5.0"
+    lodash.topath "4.5.2"
+    prop-types "^15.5.10"
+    warning "^3.0.0"
+
 forwarded@~0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
@@ -4807,6 +4817,10 @@ lodash.camelcase@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
 
+lodash.clonedeep@^4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
+
 lodash.cond@^4.3.0:
   version "4.5.2"
   resolved "https://registry.yarnpkg.com/lodash.cond/-/lodash.cond-4.5.2.tgz#f471a1da486be60f6ab955d17115523dd1d255d5"
@@ -4823,6 +4837,10 @@ lodash.isarray@^3.0.0:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55"
 
+lodash.isequal@4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
+
 lodash.keys@^3.1.2:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
@@ -4839,6 +4857,10 @@ lodash.sortby@^4.7.0:
   version "4.7.0"
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
 
+lodash.topath@4.5.2:
+  version "4.5.2"
+  resolved "https://registry.yarnpkg.com/lodash.topath/-/lodash.topath-4.5.2.tgz#3616351f3bba61994a0931989660bd03254fd009"
+
 lodash.unescape@4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/lodash.unescape/-/lodash.unescape-4.0.1.tgz#bf2249886ce514cda112fae9218cdc065211fc9c"
index 93542c1876419736889b98dab7249c1aafd59e83..8c4b0f0c56b1218ebe8bd35d99ac488907229e10 100644 (file)
@@ -2814,7 +2814,18 @@ favorite.current.UTS=This test file is marked as favorite.
 #
 #------------------------------------------------------------------------------
 webhooks.page=Webhooks
+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.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
+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_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