]> source.dussan.org Git - sonarqube.git/commitdiff
SONARCLOUD-120 Add new "Create Organization" page (#691)
authorStas Vilchik <stas.vilchik@sonarsource.com>
Mon, 10 Sep 2018 13:40:46 +0000 (15:40 +0200)
committerSonarTech <sonartech@sonarsource.com>
Tue, 25 Sep 2018 18:21:00 +0000 (20:21 +0200)
37 files changed:
server/sonar-web/package.json
server/sonar-web/src/main/js/app/components/StartupModal.tsx
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap
server/sonar-web/src/main/js/app/styles/components/modals.css
server/sonar-web/src/main/js/app/styles/init/forms.css
server/sonar-web/src/main/js/app/utils/startReactApp.js
server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.tsx [deleted file]
server/sonar-web/src/main/js/apps/account/organizations/UserOrganizations.tsx
server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsInput-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/__tests__/whenLoggedIn-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/create/ManualProjectCreate.tsx
server/sonar-web/src/main/js/apps/projects/create/__tests__/ManualProjectCreate-test.tsx
server/sonar-web/src/main/js/apps/projects/create/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap
server/sonar-web/src/main/js/apps/tutorials/onboarding/OnboardingPage.tsx
server/sonar-web/src/main/js/apps/webhooks/components/CreateWebhookForm.tsx
server/sonar-web/src/main/js/components/controls/InputValidationField.tsx
server/sonar-web/src/main/js/components/controls/ModalValidationField.tsx
server/sonar-web/src/main/js/components/controls/ValidationForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/ValidationModal.tsx
server/sonar-web/src/main/js/components/controls/__tests__/ValidationForm-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/ValidationModal-test.tsx
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ModalValidationField-test.tsx.snap
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ValidationForm-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ValidationModal-test.tsx.snap
server/sonar-web/src/main/js/helpers/testUtils.ts
server/sonar-web/yarn.lock
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index f8bcb27794b8cd283652da72659b40b92b603a40..2f7a6c21da2bc8554e5afb4f71f877c925e05de6 100644 (file)
@@ -16,7 +16,7 @@
     "d3-shape": "1.2.0",
     "d3-zoom": "1.7.1",
     "date-fns": "1.29.0",
-    "formik": "0.11.11",
+    "formik": "1.2.0",
     "history": "3.3.0",
     "intl-relativeformat": "2.1.0",
     "keymaster": "1.6.2",
index ee49e969d4969b8ad977a7782a64f3c9c8962007..8481e4dc86ff8fc8f1330cd27eb31dbb7f7edae6 100644 (file)
@@ -20,7 +20,7 @@
 import * as React from 'react';
 import * as PropTypes from 'prop-types';
 import { connect } from 'react-redux';
-import { CurrentUser, isLoggedIn, Organization } from '../types';
+import { CurrentUser, isLoggedIn } from '../types';
 import { differenceInDays, parseDate, toShortNotSoISOString } from '../../helpers/dates';
 import { EditionKey } from '../../apps/marketplace/utils';
 import { getCurrentUser, getAppState, Store } from '../../store/rootReducer';
@@ -32,9 +32,6 @@ import { isSonarCloud } from '../../helpers/system';
 import { skipOnboarding } from '../../api/users';
 import { lazyLoad } from '../../components/lazyLoad';
 
-const CreateOrganizationForm = lazyLoad(() =>
-  import('../../apps/account/organizations/CreateOrganizationForm')
-);
 const OnboardingModal = lazyLoad(() => import('../../apps/tutorials/onboarding/OnboardingModal'));
 const LicensePromptModal = lazyLoad(
   () => import('../../apps/marketplace/components/LicensePromptModal'),
@@ -68,7 +65,6 @@ type Props = StateProps & DispatchProps & OwnProps;
 enum ModalKey {
   license,
   onboarding,
-  organizationOnboarding,
   projectOnboarding,
   teamOnboarding
 }
@@ -119,17 +115,13 @@ export class StartupModal extends React.PureComponent<Props, State> {
     });
   };
 
-  closeOrganizationOnboarding = ({ key }: Pick<Organization, 'key'>) => {
-    this.closeOnboarding();
-    this.context.router.push(`/organizations/${key}`);
-  };
-
   openOnboarding = () => {
     this.setState({ modal: ModalKey.onboarding });
   };
 
   openOrganizationOnboarding = () => {
-    this.setState({ modal: ModalKey.organizationOnboarding });
+    this.closeOnboarding();
+    this.context.router.push('/create-organization');
   };
 
   openProjectOnboarding = () => {
@@ -160,11 +152,11 @@ export class StartupModal extends React.PureComponent<Props, State> {
             this.setState({ automatic: true, modal: ModalKey.license });
             return Promise.resolve();
           }
-          return Promise.reject('License exists');
+          return Promise.reject();
         });
       }
     }
-    return Promise.reject('No license prompt');
+    return Promise.reject();
   };
 
   tryAutoOpenOnboarding = () => {
@@ -201,12 +193,6 @@ export class StartupModal extends React.PureComponent<Props, State> {
         {modal === ModalKey.projectOnboarding && (
           <ProjectOnboardingModal automatic={automatic} onFinish={this.closeOnboarding} />
         )}
-        {modal === ModalKey.organizationOnboarding && (
-          <CreateOrganizationForm
-            onClose={this.closeOnboarding}
-            onCreate={this.closeOrganizationOnboarding}
-          />
-        )}
         {modal === ModalKey.teamOnboarding && (
           <TeamOnboardingModal onFinish={this.closeOnboarding} />
         )}
index a191797ca53abbf850b9df569e6e1949724e3bcc..57f21824533dde88cf680d8c8d0db8e153a2b6f9 100644 (file)
@@ -18,8 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import * as PropTypes from 'prop-types';
-import CreateOrganizationForm from '../../../../apps/account/organizations/CreateOrganizationForm';
+import { Link } from 'react-router';
 import PlusIcon from '../../../../components/icons-components/PlusIcon';
 import Dropdown from '../../../../components/controls/Dropdown';
 import { translate } from '../../../../helpers/l10n';
@@ -28,40 +27,12 @@ interface Props {
   openProjectOnboarding: () => void;
 }
 
-interface State {
-  createOrganization: boolean;
-}
-
-export default class GlobalNavPlus extends React.PureComponent<Props, State> {
-  static contextTypes = {
-    router: PropTypes.object
-  };
-
-  constructor(props: Props) {
-    super(props);
-    this.state = { createOrganization: false };
-  }
-
+export default class GlobalNavPlus extends React.PureComponent<Props> {
   handleNewProjectClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
     event.preventDefault();
     this.props.openProjectOnboarding();
   };
 
-  openCreateOrganizationForm = () => this.setState({ createOrganization: true });
-
-  closeCreateOrganizationForm = () => this.setState({ createOrganization: false });
-
-  handleNewOrganizationClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
-    event.preventDefault();
-    event.currentTarget.blur();
-    this.openCreateOrganizationForm();
-  };
-
-  handleCreateOrganization = ({ key }: { key: string }) => {
-    this.closeCreateOrganizationForm();
-    this.context.router.push(`/organizations/${key}`);
-  };
-
   render() {
     return (
       <Dropdown
@@ -74,33 +45,19 @@ export default class GlobalNavPlus extends React.PureComponent<Props, State> {
             </li>
             <li className="divider" />
             <li>
-              <a className="js-new-organization" href="#" onClick={this.handleNewOrganizationClick}>
+              <Link className="js-new-organization" to="/create-organization">
                 {translate('my_account.create_new_organization')}
-              </a>
+              </Link>
             </li>
           </ul>
         }
         tagName="li">
-        {({ onToggleClick, open }) => (
-          <>
-            <a
-              aria-expanded={open}
-              aria-haspopup="true"
-              className="navbar-plus"
-              href="#"
-              onClick={onToggleClick}
-              title={translate('my_account.create_new_project_or_organization')}>
-              <PlusIcon />
-            </a>
-
-            {this.state.createOrganization && (
-              <CreateOrganizationForm
-                onClose={this.closeCreateOrganizationForm}
-                onCreate={this.handleCreateOrganization}
-              />
-            )}
-          </>
-        )}
+        <a
+          className="navbar-plus"
+          href="#"
+          title={translate('my_account.create_new_project_or_organization')}>
+          <PlusIcon />
+        </a>
       </Dropdown>
     );
   }
index 56a23c2dc5eaa0ee29614e87379338f02beb5ce0..a051a721d213b3371aea6ff2c266972d2dfdd5a8 100644 (file)
@@ -19,16 +19,25 @@ exports[`render 1`] = `
         className="divider"
       />
       <li>
-        <a
+        <Link
           className="js-new-organization"
-          href="#"
-          onClick={[Function]}
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to="/create-organization"
         >
           my_account.create_new_organization
-        </a>
+        </Link>
       </li>
     </ul>
   }
   tagName="li"
-/>
+>
+  <a
+    className="navbar-plus"
+    href="#"
+    title="my_account.create_new_project_or_organization"
+  >
+    <PlusIcon />
+  </a>
+</Dropdown>
 `;
index dd8be6880942039ad745195deef84781bc9efeaf..1a4274b1451387f310d19680a679d387f86bba17 100644 (file)
   min-height: var(--controlHeight);
 }
 
-.modal-validation-field input:not(.has-error),
-.modal-validation-field .Select:not(.has-error) {
+.modal-validation-field input:not(.is-invalid),
+.modal-validation-field .Select:not(.is-invalid) {
   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;
index 93a7d5c7a151b90fa8a7764b57e099ad03244c3a..4d9d83329fd5dfbc4636503dfb4e1879b31ac438 100644 (file)
@@ -69,14 +69,27 @@ select:invalid {
   outline: none;
 }
 
-input[type='text'].invalid,
-input[type='password'].invalid,
-input[type='email'].invalid,
-input[type='search'].invalid,
-input[type='date'].invalid,
-input[type='number'].invalid,
-textarea.invalid,
-select.invalid {
+input[type='text'].is-valid,
+input[type='password'].is-valid,
+input[type='email'].is-valid,
+input[type='search'].is-valid,
+input[type='date'].is-valid,
+input[type='number'].is-valid,
+textarea.is-valid,
+select.is-valid,
+.is-valid > .Select-control {
+  border-color: var(--green);
+}
+
+input[type='text'].is-invalid,
+input[type='password'].is-invalid,
+input[type='email'].is-invalid,
+input[type='search'].is-invalid,
+input[type='date'].is-invalid,
+input[type='number'].is-invalid,
+textarea.is-invalid,
+select.is-invalid,
+.is-invalid > .Select-control {
   border-color: var(--red);
 }
 
index d521a298da7274a403d2acd615cd43b394f9cfd7..474e9f56083b7173e740eba32b615a33a6055fae 100644 (file)
@@ -67,6 +67,7 @@ import webhooksRoutes from '../../apps/webhooks/routes';
 import { maintenanceRoutes, setupRoutes } from '../../apps/maintenance/routes';
 import { globalPermissionsRoutes, projectPermissionsRoutes } from '../../apps/permissions/routes';
 import { lazyLoad } from '../../components/lazyLoad';
+import { isSonarCloud } from '../../helpers/system';
 
 function handleUpdate() {
   const { action } = this.state.location;
@@ -171,6 +172,14 @@ const startReactApp = (lang, currentUser, appState) => {
                 />
                 <Route path="issues" component={IssuesPageSelector} />
                 <Route path="onboarding" childRoutes={onboardingRoutes} />
+                {isSonarCloud() && (
+                  <Route
+                    path="create-organization"
+                    component={lazyLoad(() =>
+                      import('../../apps/create/organization/CreateOrganization')
+                    )}
+                  />
+                )}
                 <Route path="organizations" childRoutes={organizationsRoutes} />
                 <Route path="projects" childRoutes={projectsRoutes} />
                 <Route path="quality_gates" childRoutes={qualityGatesRoutes} />
diff --git a/server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.tsx b/server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.tsx
deleted file mode 100644 (file)
index 2563696..0000000
+++ /dev/null
@@ -1,252 +0,0 @@
-/*
- * 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 { debounce } from 'lodash';
-import { connect } from 'react-redux';
-import * as PropTypes from 'prop-types';
-import { createOrganization } from '../../organizations/actions';
-import { Organization, OrganizationBase } from '../../../app/types';
-import Modal from '../../../components/controls/Modal';
-import DocTooltip from '../../../components/docs/DocTooltip';
-import { translate } from '../../../helpers/l10n';
-import { SubmitButton, ResetButtonLink } from '../../../components/ui/buttons';
-
-interface DispatchProps {
-  createOrganization: (fields: OrganizationBase) => Promise<Organization>;
-}
-
-interface Props extends DispatchProps {
-  onClose: () => void;
-  onCreate: (organization: { key: string }) => void;
-}
-
-interface State {
-  avatar: string;
-  avatarImage: string;
-  description: string;
-  key: string;
-  loading: boolean;
-  name: string;
-  url: string;
-}
-
-class CreateOrganizationForm extends React.PureComponent<Props, State> {
-  mounted = false;
-
-  static contextTypes = {
-    router: PropTypes.object
-  };
-
-  constructor(props: Props) {
-    super(props);
-    this.state = {
-      avatar: '',
-      avatarImage: '',
-      description: '',
-      key: '',
-      loading: false,
-      name: '',
-      url: ''
-    };
-    this.changeAvatarImage = debounce(this.changeAvatarImage, 500);
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  stopProcessing = () => {
-    if (this.mounted) {
-      this.setState({ loading: false });
-    }
-  };
-
-  handleAvatarInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
-    const { value } = event.currentTarget;
-    this.setState({ avatar: value });
-    this.changeAvatarImage(value);
-  };
-
-  changeAvatarImage = (value: string) => {
-    this.setState({ avatarImage: value });
-  };
-
-  handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
-    this.setState({ name: event.currentTarget.value });
-
-  handleKeyChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
-    this.setState({ key: event.currentTarget.value });
-
-  handleDescriptionChange = (event: React.SyntheticEvent<HTMLTextAreaElement>) =>
-    this.setState({ description: event.currentTarget.value });
-
-  handleUrlChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
-    this.setState({ url: event.currentTarget.value });
-
-  handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
-    event.preventDefault();
-    const organization = { name: this.state.name };
-    if (this.state.avatar) {
-      Object.assign(organization, { avatar: this.state.avatar });
-    }
-    if (this.state.description) {
-      Object.assign(organization, { description: this.state.description });
-    }
-    if (this.state.key) {
-      Object.assign(organization, { key: this.state.key });
-    }
-    if (this.state.url) {
-      Object.assign(organization, { url: this.state.url });
-    }
-    this.setState({ loading: true });
-    this.props.createOrganization(organization).then(this.props.onCreate, this.stopProcessing);
-  };
-
-  render() {
-    return (
-      <Modal contentLabel="modal form" onRequestClose={this.props.onClose}>
-        <header className="modal-head">
-          <h2>
-            {translate('my_account.create_organization')}
-            <DocTooltip
-              className="spacer-left"
-              doc={import(/* webpackMode: "eager" */ 'Docs/tooltips/organizations/organization.md')}
-            />
-          </h2>
-        </header>
-
-        <form onSubmit={this.handleSubmit}>
-          <div className="modal-body">
-            <div className="modal-field">
-              <label htmlFor="organization-name">
-                {translate('organization.name')}
-                <em className="mandatory">*</em>
-              </label>
-              <input
-                autoFocus={true}
-                disabled={this.state.loading}
-                id="organization-name"
-                maxLength={64}
-                minLength={2}
-                name="name"
-                onChange={this.handleNameChange}
-                required={true}
-                type="text"
-                value={this.state.name}
-              />
-              <div className="modal-field-description">
-                {translate('organization.name.description')}
-              </div>
-            </div>
-            <div className="modal-field">
-              <label htmlFor="organization-key">{translate('organization.key')}</label>
-              <input
-                disabled={this.state.loading}
-                id="organization-key"
-                maxLength={64}
-                minLength={2}
-                name="key"
-                onChange={this.handleKeyChange}
-                type="text"
-                value={this.state.key}
-              />
-              <div className="modal-field-description">
-                {translate('organization.key.description')}
-              </div>
-            </div>
-            <div className="modal-field">
-              <label htmlFor="organization-avatar">{translate('organization.avatar')}</label>
-              <input
-                disabled={this.state.loading}
-                id="organization-avatar"
-                maxLength={256}
-                name="avatar"
-                onChange={this.handleAvatarInputChange}
-                type="text"
-                value={this.state.avatar}
-              />
-              <div className="modal-field-description">
-                {translate('organization.avatar.description')}
-              </div>
-              {!!this.state.avatarImage && (
-                <div className="spacer-top spacer-bottom">
-                  <div className="little-spacer-bottom">
-                    {translate('organization.avatar.preview')}
-                    {':'}
-                  </div>
-                  <img alt="" height={30} src={this.state.avatarImage} />
-                </div>
-              )}
-            </div>
-            <div className="modal-field">
-              <label htmlFor="organization-description">{translate('description')}</label>
-              <textarea
-                disabled={this.state.loading}
-                id="organization-description"
-                maxLength={256}
-                name="description"
-                onChange={this.handleDescriptionChange}
-                rows={3}
-                value={this.state.description}
-              />
-              <div className="modal-field-description">
-                {translate('organization.description.description')}
-              </div>
-            </div>
-            <div className="modal-field">
-              <label htmlFor="organization-url">{translate('organization.url')}</label>
-              <input
-                disabled={this.state.loading}
-                id="organization-url"
-                maxLength={256}
-                name="url"
-                onChange={this.handleUrlChange}
-                type="text"
-                value={this.state.url}
-              />
-              <div className="modal-field-description">
-                {translate('organization.url.description')}
-              </div>
-            </div>
-          </div>
-
-          <footer className="modal-foot">
-            <div>
-              {this.state.loading && <i className="spinner spacer-right" />}
-              <SubmitButton disabled={this.state.loading}>{translate('create')}</SubmitButton>
-              <ResetButtonLink onClick={this.props.onClose}>{translate('cancel')}</ResetButtonLink>
-            </div>
-          </footer>
-        </form>
-      </Modal>
-    );
-  }
-}
-
-const mapDispatchToProps: DispatchProps = { createOrganization: createOrganization as any };
-
-export default connect(
-  null,
-  mapDispatchToProps
-)(CreateOrganizationForm);
index 60d477890436cbfc55ef693edb266cb3b8b52047..94d4f9a79520b59d72b00ac4c8b07423789f4bae 100644 (file)
@@ -20,8 +20,8 @@
 import * as React from 'react';
 import Helmet from 'react-helmet';
 import { connect } from 'react-redux';
+import { Link } from 'react-router';
 import OrganizationsList from './OrganizationsList';
-import CreateOrganizationForm from './CreateOrganizationForm';
 import { fetchIfAnyoneCanCreateOrganizations } from './actions';
 import { translate } from '../../../helpers/l10n';
 import {
@@ -31,7 +31,6 @@ import {
   Store
 } from '../../../store/rootReducer';
 import { Organization } from '../../../app/types';
-import { Button } from '../../../components/ui/buttons';
 
 interface StateProps {
   anyoneCanCreate?: { value: string };
@@ -46,13 +45,12 @@ interface DispatchProps {
 interface Props extends StateProps, DispatchProps {}
 
 interface State {
-  createOrganization: boolean;
   loading: boolean;
 }
 
 class UserOrganizations extends React.PureComponent<Props, State> {
   mounted = false;
-  state: State = { createOrganization: false, loading: true };
+  state: State = { loading: true };
 
   componentDidMount() {
     this.mounted = true;
@@ -69,14 +67,6 @@ class UserOrganizations extends React.PureComponent<Props, State> {
     }
   };
 
-  openCreateOrganizationForm = () => {
-    this.setState({ createOrganization: true });
-  };
-
-  closeCreateOrganizationForm = () => {
-    this.setState({ createOrganization: false });
-  };
-
   render() {
     const anyoneCanCreate =
       this.props.anyoneCanCreate != null && this.props.anyoneCanCreate.value === 'true';
@@ -91,7 +81,9 @@ class UserOrganizations extends React.PureComponent<Props, State> {
           {canCreateOrganizations && (
             <div className="clearfix">
               <div className="boxed-group-actions">
-                <Button onClick={this.openCreateOrganizationForm}>{translate('create')}</Button>
+                <Link className="button" to="/create-organization">
+                  {translate('create')}
+                </Link>
               </div>
             </div>
           )}
@@ -103,13 +95,6 @@ class UserOrganizations extends React.PureComponent<Props, State> {
             )}
           </div>
         </div>
-
-        {this.state.createOrganization && (
-          <CreateOrganizationForm
-            onClose={this.closeCreateOrganizationForm}
-            onCreate={this.closeCreateOrganizationForm}
-          />
-        )}
       </div>
     );
   }
diff --git a/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx b/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx
new file mode 100644 (file)
index 0000000..cf61cd1
--- /dev/null
@@ -0,0 +1,107 @@
+/*
+ * 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 { Helmet } from 'react-helmet';
+import { FormattedMessage } from 'react-intl';
+import { Link, withRouter, WithRouterProps } from 'react-router';
+import { connect } from 'react-redux';
+import OrganizationDetailsStep from './OrganizationDetailsStep';
+import { whenLoggedIn } from './whenLoggedIn';
+import { translate } from '../../../helpers/l10n';
+import { OrganizationBase, Organization } from '../../../app/types';
+import { createOrganization } from '../../organizations/actions';
+import { getOrganizationUrl } from '../../../helpers/urls';
+import '../../../app/styles/sonarcloud.css';
+import '../../tutorials/styles.css'; // TODO remove me
+
+interface Props {
+  createOrganization: (organization: OrganizationBase) => Promise<Organization>;
+}
+
+export class CreateOrganization extends React.PureComponent<Props & WithRouterProps> {
+  mounted = false;
+
+  componentDidMount() {
+    this.mounted = true;
+    document.body.classList.add('white-page');
+    document.documentElement.classList.add('white-page');
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+    document.body.classList.remove('white-page');
+  }
+
+  handleOrganizationCreate = (organization: Required<OrganizationBase>) => {
+    return this.props
+      .createOrganization({
+        avatar: organization.avatar,
+        description: organization.description,
+        key: organization.key,
+        name: organization.name || organization.key,
+        url: organization.url
+      })
+      .then(organization => {
+        this.props.router.push(getOrganizationUrl(organization.key));
+      });
+  };
+
+  render() {
+    const header = translate('onboarding.create_organization.page.header');
+
+    return (
+      <>
+        <Helmet title={header} titleTemplate="%s" />
+        <div className="sonarcloud page page-limited">
+          <header className="page-header">
+            <h1 className="page-title big-spacer-bottom">{header}</h1>
+            <div className="page-actions">
+              <Link to="/">{translate('cancel')}</Link>
+            </div>
+            <p className="page-description">
+              <FormattedMessage
+                defaultMessage={translate('onboarding.create_organization.page.description')}
+                id="onboarding.create_organization.page.description"
+                values={{
+                  break: <br />,
+                  price: '€10', // TODO
+                  more: (
+                    <Link to="/documentation/sonarcloud-pricing">{translate('learn_more')}</Link>
+                  )
+                }}
+              />
+            </p>
+          </header>
+
+          <OrganizationDetailsStep onContinue={this.handleOrganizationCreate} />
+        </div>
+      </>
+    );
+  }
+}
+
+const mapDispatchToProps = { createOrganization: createOrganization as any };
+
+export default whenLoggedIn(
+  connect(
+    null,
+    mapDispatchToProps
+  )(withRouter(CreateOrganization))
+);
diff --git a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsInput.tsx
new file mode 100644 (file)
index 0000000..8357fa5
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * 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 '../../../components/icons-components/AlertErrorIcon';
+import AlertSuccessIcon from '../../../components/icons-components/AlertSuccessIcon';
+
+interface Props {
+  description?: string;
+  dirty: boolean;
+  children: (inputProps: React.InputHTMLAttributes<Element>) => React.ReactElement<any>;
+  error: string | undefined;
+  id: string;
+  isSubmitting: boolean;
+  label: React.ReactNode;
+  name: string;
+  onBlur: React.FocusEventHandler;
+  onChange: React.ChangeEventHandler;
+  required?: boolean;
+  touched?: boolean;
+  value: string;
+}
+
+export default function OrganizationDetailsInput(props: Props) {
+  const hasError = props.dirty && props.touched && props.error !== undefined;
+  const isValid = props.dirty && props.touched && props.error === undefined;
+  return (
+    <div>
+      <label htmlFor={props.id}>
+        {props.label}
+        {props.required && <em className="mandatory">*</em>}
+      </label>
+      <div className="little-spacer-top spacer-bottom">
+        {props.children({
+          className: classNames('input-super-large', 'text-middle', {
+            'is-invalid': hasError,
+            'is-valid': isValid
+          }),
+          disabled: props.isSubmitting,
+          id: props.id,
+          name: props.name,
+          onBlur: props.onBlur,
+          onChange: props.onChange,
+          type: 'text',
+          value: props.value
+        })}
+        {hasError && (
+          <>
+            <AlertErrorIcon className="spacer-left text-middle" />
+            <span className="little-spacer-left text-danger text-middle">{props.error}</span>
+          </>
+        )}
+        {isValid && <AlertSuccessIcon className="spacer-left text-middle" />}
+      </div>
+      {props.description && <div className="note abs-width-400">{props.description}</div>}
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx b/server/sonar-web/src/main/js/apps/create/organization/OrganizationDetailsStep.tsx
new file mode 100644 (file)
index 0000000..ca40212
--- /dev/null
@@ -0,0 +1,216 @@
+/*
+ * 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 OrganizationDetailsInput from './OrganizationDetailsInput';
+import Step from '../../tutorials/components/Step';
+import ValidationForm, { ChildrenProps } from '../../../components/controls/ValidationForm';
+import { translate } from '../../../helpers/l10n';
+import { ResetButtonLink, SubmitButton } from '../../../components/ui/buttons';
+import DropdownIcon from '../../../components/icons-components/DropdownIcon';
+import { isUrl } from '../../../helpers/urls';
+import { OrganizationBase } from '../../../app/types';
+import { getOrganization } from '../../../api/organizations';
+
+type Values = Required<OrganizationBase>;
+
+const initialValues: Values = {
+  avatar: '',
+  description: '',
+  name: '',
+  key: '',
+  url: ''
+};
+
+interface Props {
+  onContinue: (organization: Required<OrganizationBase>) => Promise<void>;
+}
+
+interface State {
+  additional: boolean;
+}
+
+export default class OrganizationDetailsStep extends React.PureComponent<Props, State> {
+  state: State = { additional: false };
+
+  handleAdditionalClick = () => {
+    this.setState(state => ({ additional: !state.additional }));
+  };
+
+  checkFreeKey = (key: string) => {
+    return getOrganization(key).then(organization => organization === undefined, () => true);
+  };
+
+  handleValidate = ({ avatar, name, key, url }: Values) => {
+    const errors: { [P in keyof Values]?: string } = {};
+
+    if (avatar.length > 0 && !isUrl(avatar)) {
+      errors.avatar = translate('onboarding.create_organization.avatar.error');
+    }
+
+    if (name.length > 0 && (name.length < 2 || name.length > 64)) {
+      errors.name = translate('onboarding.create_organization.display_name.error');
+    }
+
+    if (key.length < 2 || key.length > 32 || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(key)) {
+      errors.key = translate('onboarding.create_organization.organization_name.error');
+    }
+
+    if (url.length > 0 && !isUrl(url)) {
+      errors.url = translate('onboarding.create_organization.url.error');
+    }
+
+    // don't try to check if the organization key is already taken if the key is invalid
+    if (errors.key) {
+      return Promise.reject(errors);
+    }
+
+    return this.checkFreeKey(key).then(free => {
+      if (!free) {
+        errors.key = translate('onboarding.create_organization.organization_name.taken');
+      }
+      return Object.keys(errors).length ? Promise.reject(errors) : Promise.resolve(errors);
+    });
+  };
+
+  renderInnerForm = (props: ChildrenProps<Values>) => {
+    const {
+      dirty,
+      errors,
+      handleBlur,
+      handleChange,
+      isSubmitting,
+      isValid,
+      touched,
+      values
+    } = props;
+    const commonProps = { dirty, isSubmitting, onBlur: handleBlur, onChange: handleChange };
+    return (
+      <>
+        <OrganizationDetailsInput
+          {...commonProps}
+          description={translate('onboarding.create_organization.organization_name.description')}
+          error={errors.key}
+          id="organization-key"
+          label={translate('onboarding.create_organization.organization_name')}
+          name="key"
+          required={true}
+          touched={touched.key}
+          value={values.key}>
+          {props => <input autoFocus={true} {...props} />}
+        </OrganizationDetailsInput>
+        <div className="big-spacer-top">
+          <ResetButtonLink onClick={this.handleAdditionalClick}>
+            {translate(
+              this.state.additional
+                ? 'onboarding.create_organization.hide_additional_info'
+                : 'onboarding.create_organization.add_additional_info'
+            )}
+            <DropdownIcon className="little-spacer-left" turned={this.state.additional} />
+          </ResetButtonLink>
+        </div>
+        <div className="js-additional-info" hidden={!this.state.additional}>
+          <div className="big-spacer-top">
+            <OrganizationDetailsInput
+              {...commonProps}
+              description={translate('onboarding.create_organization.display_name.description')}
+              error={errors.name}
+              id="organization-display-name"
+              label={translate('onboarding.create_organization.display_name')}
+              name="name"
+              touched={touched.name && values.name !== ''}
+              value={values.name}>
+              {props => <input {...props} />}
+            </OrganizationDetailsInput>
+          </div>
+          <div className="big-spacer-top">
+            <OrganizationDetailsInput
+              {...commonProps}
+              description={translate('onboarding.create_organization.avatar.description')}
+              error={errors.avatar}
+              id="organization-avatar"
+              label={translate('onboarding.create_organization.avatar')}
+              name="avatar"
+              touched={touched.avatar && values.avatar !== ''}
+              value={values.avatar}>
+              {props => <input {...props} />}
+            </OrganizationDetailsInput>
+          </div>
+          <div className="big-spacer-top">
+            <OrganizationDetailsInput
+              {...commonProps}
+              error={errors.description}
+              id="organization-description"
+              label={translate('description')}
+              name="description"
+              touched={touched.description && values.description !== ''}
+              value={values.description}>
+              {props => <textarea {...props} rows={3} />}
+            </OrganizationDetailsInput>
+          </div>
+          <div className="big-spacer-top">
+            <OrganizationDetailsInput
+              {...commonProps}
+              error={errors.url}
+              id="organization-url"
+              label={translate('onboarding.create_organization.url')}
+              name="url"
+              touched={touched.url && values.url !== ''}
+              value={values.url}>
+              {props => <input {...props} />}
+            </OrganizationDetailsInput>
+          </div>
+        </div>
+        <div className="big-spacer-top">
+          <SubmitButton disabled={isSubmitting || !isValid || !dirty}>
+            {/* // TODO change me */}
+            {translate('onboarding.create_organization.page.header')}
+          </SubmitButton>
+        </div>
+      </>
+    );
+  };
+
+  renderForm = () => {
+    return (
+      <div className="boxed-group-inner">
+        <ValidationForm<Values>
+          initialValues={initialValues}
+          onSubmit={this.props.onContinue}
+          validate={this.handleValidate}>
+          {this.renderInnerForm}
+        </ValidationForm>
+      </div>
+    );
+  };
+
+  render() {
+    return (
+      <Step
+        finished={false}
+        onOpen={() => {}}
+        open={true}
+        renderForm={this.renderForm}
+        renderResult={() => <div />}
+        stepNumber={1}
+        stepTitle={translate('onboarding.create_organization.enter_org_details')}
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx
new file mode 100644 (file)
index 0000000..09a6cef
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * 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 { CreateOrganization } from '../CreateOrganization';
+import { mockRouter } from '../../../../helpers/testUtils';
+
+it('should render and create organization', async () => {
+  const createOrganization = jest.fn().mockResolvedValue({ key: 'foo' });
+  const router = mockRouter();
+  const wrapper = shallow(
+    // @ts-ignore avoid passing everything from WithRouterProps
+    <CreateOrganization createOrganization={createOrganization} router={router} />
+  );
+  expect(wrapper).toMatchSnapshot();
+
+  const organization = {
+    avatar: 'http://example.com/avatar',
+    description: 'description-foo',
+    key: 'key-foo',
+    name: 'name-foo',
+    url: 'http://example.com/foo'
+  };
+  wrapper.find('OrganizationDetailsStep').prop<Function>('onContinue')(organization);
+  await new Promise(setImmediate);
+  expect(createOrganization).toBeCalledWith(organization);
+  expect(router.push).toBeCalledWith('/organizations/foo');
+});
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsInput-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsInput-test.tsx
new file mode 100644 (file)
index 0000000..38629f8
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * 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 OrganizationDetailsInput from '../OrganizationDetailsInput';
+
+it('should render', () => {
+  const render = jest.fn().mockReturnValue(<div />);
+  expect(
+    shallow(
+      <OrganizationDetailsInput
+        dirty={true}
+        error="This field is bad!"
+        id="field"
+        isSubmitting={true}
+        label="Label"
+        name="field"
+        onBlur={jest.fn()}
+        onChange={jest.fn()}
+        required={true}
+        touched={true}
+        value="foo">
+        {render}
+      </OrganizationDetailsInput>
+    )
+  ).toMatchSnapshot();
+  expect(render).toBeCalledWith(
+    expect.objectContaining({
+      className: 'input-super-large text-middle is-invalid',
+      disabled: true,
+      id: 'field',
+      name: 'field',
+      type: 'text',
+      value: 'foo'
+    })
+  );
+});
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/OrganizationDetailsStep-test.tsx
new file mode 100644 (file)
index 0000000..8d6ddf7
--- /dev/null
@@ -0,0 +1,101 @@
+/*
+ * 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, ShallowWrapper } from 'enzyme';
+import OrganizationDetailsStep from '../OrganizationDetailsStep';
+import { click } from '../../../../helpers/testUtils';
+import { getOrganization } from '../../../../api/organizations';
+
+jest.mock('../../../../api/organizations', () => ({
+  getOrganization: jest.fn()
+}));
+
+beforeEach(() => {
+  (getOrganization as jest.Mock).mockResolvedValue(undefined);
+});
+
+it('should render', () => {
+  const wrapper = shallow(<OrganizationDetailsStep onContinue={jest.fn()} />);
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.dive()).toMatchSnapshot();
+  expect(getForm(wrapper)).toMatchSnapshot();
+  expect(
+    getForm(wrapper)
+      .find('.js-additional-info')
+      .prop('hidden')
+  ).toBe(true);
+
+  click(getForm(wrapper).find('ResetButtonLink'));
+  wrapper.update();
+  expect(
+    getForm(wrapper)
+      .find('.js-additional-info')
+      .prop('hidden')
+  ).toBe(false);
+});
+
+it('should validate', () => {
+  const wrapper = shallow(<OrganizationDetailsStep onContinue={jest.fn()} />);
+  const instance = wrapper.instance() as OrganizationDetailsStep;
+
+  expect(
+    instance.handleValidate({ avatar: '', description: '', name: '', key: 'foo', url: '' })
+  ).resolves.toEqual({});
+
+  expect(
+    instance.handleValidate({ avatar: '', description: '', name: '', key: '', url: '' })
+  ).rejects.toEqual({ key: 'onboarding.create_organization.organization_name.error' });
+
+  expect(
+    instance.handleValidate({ avatar: 'bla', description: '', name: '', key: 'foo', url: '' })
+  ).rejects.toEqual({ avatar: 'onboarding.create_organization.avatar.error' });
+
+  expect(
+    instance.handleValidate({ avatar: '', description: '', name: 'x', key: 'foo', url: '' })
+  ).rejects.toEqual({ name: 'onboarding.create_organization.display_name.error' });
+
+  expect(
+    instance.handleValidate({
+      avatar: '',
+      description: '',
+      name: 'x'.repeat(65),
+      key: 'foo',
+      url: ''
+    })
+  ).rejects.toEqual({ name: 'onboarding.create_organization.display_name.error' });
+
+  expect(
+    instance.handleValidate({ avatar: '', description: '', name: '', key: 'foo', url: 'bla' })
+  ).rejects.toEqual({ url: 'onboarding.create_organization.url.error' });
+
+  (getOrganization as jest.Mock).mockResolvedValue({});
+  expect(
+    instance.handleValidate({ avatar: '', description: '', name: '', key: 'foo', url: '' })
+  ).rejects.toEqual({ key: 'onboarding.create_organization.organization_name.taken' });
+});
+
+function getForm(wrapper: ShallowWrapper) {
+  return wrapper
+    .dive()
+    .find('ValidationForm')
+    .dive()
+    .dive()
+    .children();
+}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap
new file mode 100644 (file)
index 0000000..9611749
--- /dev/null
@@ -0,0 +1,60 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render and create organization 1`] = `
+<React.Fragment>
+  <HelmetWrapper
+    defer={true}
+    encodeSpecialCharacters={true}
+    title="onboarding.create_organization.page.header"
+    titleTemplate="%s"
+  />
+  <div
+    className="sonarcloud page page-limited"
+  >
+    <header
+      className="page-header"
+    >
+      <h1
+        className="page-title big-spacer-bottom"
+      >
+        onboarding.create_organization.page.header
+      </h1>
+      <div
+        className="page-actions"
+      >
+        <Link
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to="/"
+        >
+          cancel
+        </Link>
+      </div>
+      <p
+        className="page-description"
+      >
+        <FormattedMessage
+          defaultMessage="onboarding.create_organization.page.description"
+          id="onboarding.create_organization.page.description"
+          values={
+            Object {
+              "break": <br />,
+              "more": <Link
+                onlyActiveOnIndex={false}
+                style={Object {}}
+                to="/documentation/sonarcloud-pricing"
+              >
+                learn_more
+              </Link>,
+              "price": "€10",
+            }
+          }
+        />
+      </p>
+    </header>
+    <OrganizationDetailsStep
+      onContinue={[Function]}
+    />
+  </div>
+</React.Fragment>
+`;
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsInput-test.tsx.snap
new file mode 100644 (file)
index 0000000..76e4291
--- /dev/null
@@ -0,0 +1,31 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render 1`] = `
+<div>
+  <label
+    htmlFor="field"
+  >
+    Label
+    <em
+      className="mandatory"
+    >
+      *
+    </em>
+  </label>
+  <div
+    className="little-spacer-top spacer-bottom"
+  >
+    <div />
+    <React.Fragment>
+      <AlertErrorIcon
+        className="spacer-left text-middle"
+      />
+      <span
+        className="little-spacer-left text-danger text-middle"
+      >
+        This field is bad!
+      </span>
+    </React.Fragment>
+  </div>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/OrganizationDetailsStep-test.tsx.snap
new file mode 100644 (file)
index 0000000..71347ae
--- /dev/null
@@ -0,0 +1,155 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render 1`] = `
+<Step
+  finished={false}
+  onOpen={[Function]}
+  open={true}
+  renderForm={[Function]}
+  renderResult={[Function]}
+  stepNumber={1}
+  stepTitle="onboarding.create_organization.enter_org_details"
+/>
+`;
+
+exports[`should render 2`] = `
+<div
+  className="boxed-group onboarding-step is-open"
+>
+  <div
+    className="onboarding-step-number"
+  >
+    1
+  </div>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      onboarding.create_organization.enter_org_details
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+  >
+    <ValidationForm
+      initialValues={
+        Object {
+          "avatar": "",
+          "description": "",
+          "key": "",
+          "name": "",
+          "url": "",
+        }
+      }
+      onSubmit={[MockFunction]}
+      validate={[Function]}
+    />
+  </div>
+</div>
+`;
+
+exports[`should render 3`] = `
+<form
+  onSubmit={[Function]}
+>
+  <React.Fragment>
+    <OrganizationDetailsInput
+      description="onboarding.create_organization.organization_name.description"
+      dirty={false}
+      id="organization-key"
+      isSubmitting={false}
+      label="onboarding.create_organization.organization_name"
+      name="key"
+      onBlur={[Function]}
+      onChange={[Function]}
+      required={true}
+      value=""
+    />
+    <div
+      className="big-spacer-top"
+    >
+      <ResetButtonLink
+        onClick={[Function]}
+      >
+        onboarding.create_organization.add_additional_info
+        <DropdownIcon
+          className="little-spacer-left"
+          turned={false}
+        />
+      </ResetButtonLink>
+    </div>
+    <div
+      className="js-additional-info"
+      hidden={true}
+    >
+      <div
+        className="big-spacer-top"
+      >
+        <OrganizationDetailsInput
+          description="onboarding.create_organization.display_name.description"
+          dirty={false}
+          id="organization-display-name"
+          isSubmitting={false}
+          label="onboarding.create_organization.display_name"
+          name="name"
+          onBlur={[Function]}
+          onChange={[Function]}
+          value=""
+        />
+      </div>
+      <div
+        className="big-spacer-top"
+      >
+        <OrganizationDetailsInput
+          description="onboarding.create_organization.avatar.description"
+          dirty={false}
+          id="organization-avatar"
+          isSubmitting={false}
+          label="onboarding.create_organization.avatar"
+          name="avatar"
+          onBlur={[Function]}
+          onChange={[Function]}
+          value=""
+        />
+      </div>
+      <div
+        className="big-spacer-top"
+      >
+        <OrganizationDetailsInput
+          dirty={false}
+          id="organization-description"
+          isSubmitting={false}
+          label="description"
+          name="description"
+          onBlur={[Function]}
+          onChange={[Function]}
+          value=""
+        />
+      </div>
+      <div
+        className="big-spacer-top"
+      >
+        <OrganizationDetailsInput
+          dirty={false}
+          id="organization-url"
+          isSubmitting={false}
+          label="onboarding.create_organization.url"
+          name="url"
+          onBlur={[Function]}
+          onChange={[Function]}
+          value=""
+        />
+      </div>
+    </div>
+    <div
+      className="big-spacer-top"
+    >
+      <SubmitButton
+        disabled={true}
+      >
+        onboarding.create_organization.page.header
+      </SubmitButton>
+    </div>
+  </React.Fragment>
+</form>
+`;
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/whenLoggedIn-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/whenLoggedIn-test.tsx
new file mode 100644 (file)
index 0000000..c3c1fde
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * 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, ShallowWrapper } from 'enzyme';
+import { createStore } from 'redux';
+import { whenLoggedIn } from '../whenLoggedIn';
+import { mockRouter } from '../../../../helpers/testUtils';
+
+class X extends React.Component {
+  render() {
+    return <div />;
+  }
+}
+
+const UnderTest = whenLoggedIn(X);
+
+it('should render for logged in user', () => {
+  const store = createStore(state => state, { users: { currentUser: { isLoggedIn: true } } });
+  const wrapper = shallow(<UnderTest />, { context: { store } });
+  expect(getRenderedType(wrapper)).toBe(X);
+});
+
+it('should not render for anonymous user', () => {
+  const store = createStore(state => state, { users: { currentUser: { isLoggedIn: false } } });
+  const router = mockRouter({ replace: jest.fn() });
+  const wrapper = shallow(<UnderTest />, { context: { store, router } });
+  expect(getRenderedType(wrapper)).toBe(null);
+  expect(router.replace).toBeCalledWith(expect.objectContaining({ pathname: '/sessions/new' }));
+});
+
+function getRenderedType(wrapper: ShallowWrapper) {
+  return wrapper
+    .dive()
+    .dive()
+    .type();
+}
diff --git a/server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx b/server/sonar-web/src/main/js/apps/create/organization/whenLoggedIn.tsx
new file mode 100644 (file)
index 0000000..1535c85
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * 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 { connect } from 'react-redux';
+import { withRouter, WithRouterProps } from 'react-router';
+import { CurrentUser, isLoggedIn } from '../../../app/types';
+import { Store, getCurrentUser } from '../../../store/rootReducer';
+
+export function whenLoggedIn<P>(WrappedComponent: React.ComponentClass<P>) {
+  class Wrapper extends React.Component<P & { currentUser: CurrentUser } & WithRouterProps> {
+    static displayName = `whenLoggedIn(${WrappedComponent.displayName})`;
+
+    componentDidMount() {
+      if (!isLoggedIn(this.props.currentUser)) {
+        const returnTo = window.location.pathname + window.location.search + window.location.hash;
+        this.props.router.replace({
+          pathname: '/sessions/new',
+          query: { return_to: returnTo } // eslint-disable-line camelcase
+        });
+      }
+    }
+
+    render() {
+      if (isLoggedIn(this.props.currentUser)) {
+        return <WrappedComponent {...this.props} />;
+      } else {
+        return null;
+      }
+    }
+  }
+
+  function mapStateToProps(state: Store) {
+    return { currentUser: getCurrentUser(state) };
+  }
+
+  return connect(mapStateToProps)(withRouter(Wrapper));
+}
index f2e63f0865028345c1e5e01501a82a37ec3bb4f4..9b6c21429deb5bc00ea0450ab583fbf06c0f01ea 100644 (file)
@@ -20,9 +20,9 @@
 import * as React from 'react';
 import { sortBy } from 'lodash';
 import { connect } from 'react-redux';
-import CreateOrganizationForm from '../../account/organizations/CreateOrganizationForm';
+import { Link } from 'react-router';
 import Select from '../../../components/controls/Select';
-import { Button, SubmitButton } from '../../../components/ui/buttons';
+import { SubmitButton } from '../../../components/ui/buttons';
 import { LoggedInUser, Organization } from '../../../app/types';
 import { fetchMyOrganizations } from '../../account/organizations/actions';
 import { getMyOrganizations, Store } from '../../../store/rootReducer';
@@ -46,7 +46,6 @@ interface OwnProps {
 type Props = OwnProps & StateProps & DispatchProps;
 
 interface State {
-  createOrganizationModal: boolean;
   projectName: string;
   projectKey: string;
   selectedOrganization: string;
@@ -59,7 +58,6 @@ export class ManualProjectCreate extends React.PureComponent<Props, State> {
   constructor(props: Props) {
     super(props);
     this.state = {
-      createOrganizationModal: false,
       projectName: '',
       projectKey: '',
       selectedOrganization:
@@ -76,10 +74,6 @@ export class ManualProjectCreate extends React.PureComponent<Props, State> {
     this.mounted = false;
   }
 
-  closeCreateOrganization = () => {
-    this.setState({ createOrganizationModal: false });
-  };
-
   handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
     event.preventDefault();
 
@@ -118,22 +112,6 @@ export class ManualProjectCreate extends React.PureComponent<Props, State> {
     return Boolean(projectKey && projectName && selectedOrganization);
   };
 
-  onCreateOrganization = (organization: { key: string }) => {
-    this.props.fetchMyOrganizations().then(
-      () => {
-        this.handleOrganizationSelect({ value: organization.key });
-        this.closeCreateOrganization();
-      },
-      () => {
-        this.closeCreateOrganization();
-      }
-    );
-  };
-
-  showCreateOrganization = () => {
-    this.setState({ createOrganizationModal: true });
-  };
-
   render() {
     const { submitting } = this.state;
     return (
@@ -159,11 +137,9 @@ export class ManualProjectCreate extends React.PureComponent<Props, State> {
               required={true}
               value={this.state.selectedOrganization}
             />
-            <Button
-              className="button-link big-spacer-left js-new-org"
-              onClick={this.showCreateOrganization}>
+            <Link className="big-spacer-left js-new-org" to="/create-organization">
               {translate('onboarding.create_project.create_new_org')}
-            </Button>
+            </Link>
           </div>
           <div className="form-field">
             <label htmlFor="project-name">
@@ -202,12 +178,6 @@ export class ManualProjectCreate extends React.PureComponent<Props, State> {
           </SubmitButton>
           <DeferredSpinner className="spacer-left" loading={submitting} />
         </form>
-        {this.state.createOrganizationModal && (
-          <CreateOrganizationForm
-            onClose={this.closeCreateOrganization}
-            onCreate={this.onCreateOrganization}
-          />
-        )}
       </>
     );
   }
index b2051ab3f53cfeb2b2ab4fee6b9a0649d09be6ea..5777d22aaaad1d5af5fb8f9a77827c24632b0cd9 100644 (file)
@@ -20,7 +20,7 @@
 import * as React from 'react';
 import { shallow } from 'enzyme';
 import { ManualProjectCreate } from '../ManualProjectCreate';
-import { change, click, submit, waitAndUpdate } from '../../../../helpers/testUtils';
+import { change, submit, waitAndUpdate } from '../../../../helpers/testUtils';
 import { createProject } from '../../../../api/components';
 
 jest.mock('../../../../api/components', () => ({
@@ -35,20 +35,6 @@ it('should render correctly', () => {
   expect(getWrapper()).toMatchSnapshot();
 });
 
-it('should allow to create a new org', async () => {
-  const fetchMyOrganizations = jest.fn().mockResolvedValueOnce([]);
-  const wrapper = getWrapper({ fetchMyOrganizations });
-
-  click(wrapper.find('.js-new-org'));
-  const createForm = wrapper.find('Connect(CreateOrganizationForm)');
-  expect(createForm.exists()).toBeTruthy();
-
-  createForm.prop<Function>('onCreate')({ key: 'baz' });
-  expect(fetchMyOrganizations).toHaveBeenCalled();
-  await waitAndUpdate(wrapper);
-  expect(wrapper.state('selectedOrganization')).toBe('baz');
-});
-
 it('should correctly create a project', async () => {
   const onProjectCreate = jest.fn();
   const wrapper = getWrapper({ onProjectCreate });
index 8f1ddb22631adc61da137b43787b8bf96c5980bc..d6ba573550197b3ef62cb6c3d4a198ef9210559b 100644 (file)
@@ -55,12 +55,14 @@ exports[`should render correctly 1`] = `
         required={true}
         value=""
       />
-      <Button
-        className="button-link big-spacer-left js-new-org"
-        onClick={[Function]}
+      <Link
+        className="big-spacer-left js-new-org"
+        onlyActiveOnIndex={false}
+        style={Object {}}
+        to="/create-organization"
       >
         onboarding.create_project.create_new_org
-      </Button>
+      </Link>
     </div>
     <div
       className="form-field"
index 2d6901b63c9207c6022174b637db5591ff9d8d22..baca2d164672abb257576679baeb29922805aea6 100644 (file)
@@ -23,7 +23,6 @@ import { connect } from 'react-redux';
 import OnboardingModal from './OnboardingModal';
 import { skipOnboarding } from '../../../api/users';
 import { skipOnboarding as skipOnboardingAction } from '../../../store/users';
-import CreateOrganizationForm from '../../account/organizations/CreateOrganizationForm';
 import TeamOnboardingModal from '../teamOnboarding/TeamOnboardingModal';
 import { Organization } from '../../../app/types';
 
@@ -33,7 +32,6 @@ interface DispatchProps {
 
 enum ModalKey {
   onboarding,
-  organizationOnboarding,
   teamOnboarding
 }
 
@@ -61,7 +59,7 @@ export class OnboardingPage extends React.PureComponent<DispatchProps, State> {
   };
 
   openOrganizationOnboarding = () => {
-    this.setState({ modal: ModalKey.organizationOnboarding });
+    this.context.router.push('/create-organizations');
   };
 
   openTeamOnboarding = () => {
@@ -80,12 +78,6 @@ export class OnboardingPage extends React.PureComponent<DispatchProps, State> {
             onOpenTeamOnboarding={this.openTeamOnboarding}
           />
         )}
-        {modal === ModalKey.organizationOnboarding && (
-          <CreateOrganizationForm
-            onClose={this.closeOnboarding}
-            onCreate={this.closeOrganizationOnboarding}
-          />
-        )}
         {modal === ModalKey.teamOnboarding && (
           <TeamOnboardingModal onFinish={this.closeOnboarding} />
         )}
index 4b0c3e26e379b1f7174f34ba6385cddea2dd89e8..6a76095990fa5970ab17cc07be8c41e8343c6356 100644 (file)
@@ -101,7 +101,7 @@ export default class CreateWebhookForm extends React.PureComponent<Props> {
               name="name"
               onBlur={handleBlur}
               onChange={handleChange}
-              touched={touched.name !== ''}
+              touched={touched.name}
               type="text"
               value={values.name}
             />
@@ -120,7 +120,7 @@ export default class CreateWebhookForm extends React.PureComponent<Props> {
               name="url"
               onBlur={handleBlur}
               onChange={handleChange}
-              touched={touched.url !== ''}
+              touched={touched.url}
               type="text"
               value={values.url}
             />
index aae2cde6025ed7afd084c53990038c3fd0aa9fde..1ba0deca3e68b79dcdce1beb3a5a632e7017934f 100644 (file)
@@ -34,7 +34,7 @@ interface Props {
   onBlur: (event: React.FocusEvent<any>) => void;
   onChange: (event: React.ChangeEvent<any>) => void;
   placeholder?: string;
-  touched: boolean;
+  touched: boolean | undefined;
   type?: string;
   value: string;
 }
index 503b58952e3256963ade6e47986d43ac714739e7..4c3f643e43353b73152a315442f03b398b476d99 100644 (file)
@@ -28,7 +28,7 @@ interface Props {
   dirty: boolean;
   error: string | undefined;
   label?: React.ReactNode;
-  touched: boolean;
+  touched: boolean | undefined;
 }
 
 export default function ModalValidationField(props: Props) {
@@ -39,7 +39,7 @@ export default function ModalValidationField(props: Props) {
   return (
     <div className="modal-validation-field">
       {props.label}
-      {props.children({ className: classNames({ 'has-error': showError, 'is-valid': isValid }) })}
+      {props.children({ className: classNames({ 'is-invalid': showError, 'is-valid': isValid }) })}
       {showError && <AlertErrorIcon className="little-spacer-top" />}
       {isValid && <AlertSuccessIcon className="little-spacer-top" />}
       {showError && <p className="text-danger">{error}</p>}
diff --git a/server/sonar-web/src/main/js/components/controls/ValidationForm.tsx b/server/sonar-web/src/main/js/components/controls/ValidationForm.tsx
new file mode 100644 (file)
index 0000000..5f33868
--- /dev/null
@@ -0,0 +1,65 @@
+/*
+ * 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 { FormikActions, FormikProps, Formik } from 'formik';
+import { Omit } from '../../app/types';
+
+export type ChildrenProps<V> = Omit<FormikProps<V>, 'handleSubmit'>;
+
+interface Props<V> {
+  children: (props: ChildrenProps<V>) => React.ReactNode;
+  initialValues: V;
+  isInitialValid?: boolean;
+  onSubmit: (data: V) => Promise<void>;
+  validate: (data: V) => { [P in keyof V]?: string } | Promise<{ [P in keyof V]?: string }>;
+}
+
+export default class ValidationForm<V> extends React.Component<Props<V>> {
+  handleSubmit = (data: V, { setSubmitting }: FormikActions<V>) => {
+    const result = this.props.onSubmit(data);
+
+    if (result) {
+      result.then(
+        () => {
+          setSubmitting(false);
+        },
+        () => {
+          setSubmitting(false);
+        }
+      );
+    } else {
+      setSubmitting(false);
+    }
+  };
+
+  render() {
+    return (
+      <Formik<V>
+        initialValues={this.props.initialValues}
+        isInitialValid={this.props.isInitialValid}
+        onSubmit={this.handleSubmit}
+        validate={this.props.validate}>
+        {({ handleSubmit, ...props }) => (
+          <form onSubmit={handleSubmit}>{this.props.children(props)}</form>
+        )}
+      </Formik>
+    );
+  }
+}
index cae0a6787c5b8b0e744d02942f142de0c24ac2bb..a31e79c1f0dbd88cbc2409a673cd574a9ccc565c 100644 (file)
  * 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 { ResetButtonLink, SubmitButton } from '../ui/buttons';
+import ValidationForm, { ChildrenProps } from './ValidationForm';
 import DeferredSpinner from '../common/DeferredSpinner';
+import { SubmitButton, ResetButtonLink } from '../ui/buttons';
 import { translate } from '../../helpers/l10n';
 
-interface InnerFormProps<Values> {
-  children: (props: FormikProps<Values>) => React.ReactNode;
+interface Props<V> {
+  children: (props: ChildrenProps<V>) => React.ReactNode;
   confirmButtonText: string;
   header: string;
-  initialValues: Values;
-}
-
-interface Props<Values> extends InnerFormProps<Values> {
+  initialValues: V;
   isInitialValid?: boolean;
   onClose: () => void;
-  validate: (data: Values) => void | object | Promise<object>;
-  onSubmit: (data: Values) => void | Promise<void>;
+  onSubmit: (data: V) => Promise<void>;
+  validate: (data: V) => { [P in keyof V]?: string };
 }
 
-export default class ValidationModal<Values> extends React.PureComponent<Props<Values>> {
-  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);
+export default class ValidationModal<V> extends React.PureComponent<Props<V>> {
+  handleSubmit = (data: V) => {
+    return this.props.onSubmit(data).then(() => {
       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>
+    return (
+      <Modal contentLabel={this.props.header} onRequestClose={this.props.onClose}>
+        <ValidationForm
+          initialValues={this.props.initialValues}
+          isInitialValid={this.props.isInitialValid}
+          onSubmit={this.handleSubmit}
+          validate={this.props.validate}>
+          {props => (
+            <>
+              <header className="modal-head">
+                <h2>{this.props.header}</h2>
+              </header>
 
-        <footer className="modal-foot">
-          <DeferredSpinner className="spacer-right" loading={props.isSubmitting} />
-          <SubmitButton disabled={props.isSubmitting || !props.isValid || !props.dirty}>
-            {props.confirmButtonText}
-          </SubmitButton>
-          <ResetButtonLink disabled={props.isSubmitting} onClick={this.props.onClose}>
-            {translate('cancel')}
-          </ResetButtonLink>
-        </footer>
-      </Form>
-    ));
+              <div className="modal-body">{this.props.children(props)}</div>
 
-    return (
-      <Modal contentLabel={header} onRequestClose={this.props.onClose}>
-        <InnerForm
-          confirmButtonText={this.props.confirmButtonText}
-          header={header}
-          initialValues={this.props.initialValues}>
-          {this.props.children}
-        </InnerForm>
+              <footer className="modal-foot">
+                <DeferredSpinner className="spacer-right" loading={props.isSubmitting} />
+                <SubmitButton disabled={props.isSubmitting || !props.isValid || !props.dirty}>
+                  {this.props.confirmButtonText}
+                </SubmitButton>
+                <ResetButtonLink disabled={props.isSubmitting} onClick={this.props.onClose}>
+                  {translate('cancel')}
+                </ResetButtonLink>
+              </footer>
+            </>
+          )}
+        </ValidationForm>
       </Modal>
     );
   }
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/ValidationForm-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/ValidationForm-test.tsx
new file mode 100644 (file)
index 0000000..806f2a9
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * 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 ValidationForm from '../ValidationForm';
+
+it('should render and submit', async () => {
+  const render = jest.fn();
+  const onSubmit = jest.fn();
+  const setSubmitting = jest.fn();
+  const wrapper = shallow(
+    <ValidationForm initialValues={{ foo: 'bar' }} onSubmit={onSubmit} validate={jest.fn()}>
+      {render}
+    </ValidationForm>
+  );
+  expect(wrapper).toMatchSnapshot();
+  wrapper.dive();
+  expect(render).toBeCalledWith(
+    expect.objectContaining({ dirty: false, errors: {}, values: { foo: 'bar' } })
+  );
+
+  wrapper.prop<Function>('onSubmit')({ foo: 'bar' }, { setSubmitting });
+  expect(setSubmitting).toBeCalledWith(false);
+
+  onSubmit.mockResolvedValue(undefined).mockClear();
+  setSubmitting.mockClear();
+  wrapper.prop<Function>('onSubmit')({ foo: 'bar' }, { setSubmitting });
+  await new Promise(setImmediate);
+  expect(setSubmitting).toBeCalledWith(false);
+});
index 741736da349573641fbf79c6ebbd61d252e9d9cd..86cf3cdbbdf6b1f4d3d3455f80074ad606c7d4b0 100644 (file)
  */
 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
+    <ValidationModal<{ field: string }>
       confirmButtonText="confirm"
       header="title"
       initialValues={{ field: 'foo' }}
       isInitialValid={true}
       onClose={jest.fn()}
-      onSubmit={jest.fn(() => Promise.resolve())}
-      validate={(values: Values) => ({ field: values.field.length < 2 && 'Too small' })}
-      {...props}>
-      {(props: FormikProps<Values>) => (
-        <form onSubmit={props.handleSubmit}>
-          <input
-            name="field"
-            onBlur={props.handleBlur}
-            onChange={props.handleChange}
-            type="text"
-            value={props.values.field}
-          />
-        </form>
+      onSubmit={jest.fn()}
+      validate={jest.fn()}>
+      {props => (
+        <input
+          name="field"
+          onBlur={props.handleBlur}
+          onChange={props.handleChange}
+          type="text"
+          value={props.values.field}
+        />
       )}
     </ValidationModal>
   );
-  return {
-    wrapper,
-    inner: wrapper
-      .childAt(0)
-      .dive()
-      .dive()
-      .dive()
-  };
-}
+  expect(wrapper).toMatchSnapshot();
+});
index dc901ce06f2cf8f2a53c53dc36f74944b251c012..4b4e605c0adfeab30f3f607af8c0730c6c902ed6 100644 (file)
@@ -25,7 +25,7 @@ exports[`should display the field with an error 1`] = `
     Foo
   </label>
   <input
-    className="has-error"
+    className="is-invalid"
     type="text"
   />
   <AlertErrorIcon
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ValidationForm-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/ValidationForm-test.tsx.snap
new file mode 100644 (file)
index 0000000..203b457
--- /dev/null
@@ -0,0 +1,17 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render and submit 1`] = `
+<Formik
+  enableReinitialize={false}
+  initialValues={
+    Object {
+      "foo": "bar",
+    }
+  }
+  isInitialValid={false}
+  onSubmit={[Function]}
+  validate={[MockFunction]}
+  validateOnBlur={true}
+  validateOnChange={true}
+/>
+`;
index 82124b3c34aa9b4fa938791f3c2b395b47faf33e..b9e6385bab661720aabce629d8c193ab1334939c 100644 (file)
@@ -5,61 +5,15 @@ exports[`should render correctly 1`] = `
   contentLabel="title"
   onRequestClose={[MockFunction]}
 >
-  <C
-    confirmButtonText="confirm"
-    header="title"
+  <ValidationForm
     initialValues={
       Object {
         "field": "foo",
       }
     }
+    isInitialValid={true}
+    onSubmit={[Function]}
+    validate={[MockFunction]}
   />
 </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}
-    />
-    <SubmitButton
-      disabled={true}
-    >
-      confirm
-    </SubmitButton>
-    <ResetButtonLink
-      disabled={false}
-      onClick={[MockFunction]}
-    >
-      cancel
-    </ResetButtonLink>
-  </footer>
-</Form>
-`;
index f3f96a9506ade3bac52c2499b6086ba4d86b688b..a665ab08fe899f9b0eeaa32fee1d5242ca465f78 100644 (file)
@@ -114,3 +114,18 @@ export async function waitAndUpdate(wrapper: ShallowWrapper<any, any> | ReactWra
   await new Promise(setImmediate);
   wrapper.update();
 }
+
+export function mockRouter(overrides: { push?: Function; replace?: Function } = {}) {
+  return {
+    createHref: jest.fn(),
+    createPath: jest.fn(),
+    go: jest.fn(),
+    goBack: jest.fn(),
+    goForward: jest.fn(),
+    isActive: jest.fn(),
+    push: jest.fn(),
+    replace: jest.fn(),
+    setRouteLeaveHook: jest.fn(),
+    ...overrides
+  };
+}
index ad5afff92e9ddc78cc9ef2f307cf3855945aa1ab..afb6808bf152f2586226e47f252a501266f6efce 100644 (file)
@@ -2172,6 +2172,13 @@ create-react-class@15.6.3, create-react-class@^15.5.1:
     loose-envify "^1.3.1"
     object-assign "^4.1.1"
 
+create-react-context@^0.2.2:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.2.3.tgz#9ec140a6914a22ef04b8b09b7771de89567cb6f3"
+  dependencies:
+    fbjs "^0.8.0"
+    gud "^1.0.0"
+
 cross-spawn@5.1.0, cross-spawn@^5.0.1, cross-spawn@^5.1.0:
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
@@ -2482,6 +2489,10 @@ deep-is@~0.1.3:
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
 
+deepmerge@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.1.1.tgz#e862b4e45ea0555072bf51e7fd0d9845170ae768"
+
 default-require-extensions@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-1.0.0.tgz#f37ea15d3e13ffd9b437d33e1a75b5fb97874cb8"
@@ -3288,6 +3299,18 @@ fb-watchman@^2.0.0:
   dependencies:
     bser "^2.0.0"
 
+fbjs@^0.8.0:
+  version "0.8.17"
+  resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd"
+  dependencies:
+    core-js "^1.0.0"
+    isomorphic-fetch "^2.1.1"
+    loose-envify "^1.0.0"
+    object-assign "^4.1.0"
+    promise "^7.1.1"
+    setimmediate "^1.0.5"
+    ua-parser-js "^0.7.18"
+
 fbjs@^0.8.16, fbjs@^0.8.9:
   version "0.8.16"
   resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.16.tgz#5e67432f550dc41b572bf55847b8aca64e5337db"
@@ -3453,14 +3476,18 @@ form-data@~2.3.1:
     combined-stream "^1.0.5"
     mime-types "^2.1.12"
 
-formik@0.11.11:
-  version "0.11.11"
-  resolved "https://registry.yarnpkg.com/formik/-/formik-0.11.11.tgz#4b02838133c0196b1ef443aa973766cd097ec4a5"
+formik@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/formik/-/formik-1.2.0.tgz#a0daf8512ce2ec18d88ff59a5bb172b0167e85d1"
   dependencies:
+    create-react-context "^0.2.2"
+    deepmerge "^2.1.1"
+    hoist-non-react-statics "^2.5.5"
     lodash.clonedeep "^4.5.0"
-    lodash.isequal "4.5.0"
     lodash.topath "4.5.2"
-    prop-types "^15.5.10"
+    prop-types "^15.6.1"
+    react-fast-compare "^1.0.0"
+    tslib "^1.9.3"
     warning "^3.0.0"
 
 forwarded@~0.1.2:
@@ -3699,6 +3726,10 @@ growly@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
 
+gud@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0"
+
 gzip-size@3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-3.0.0.tgz#546188e9bdc337f673772f81660464b389dce520"
@@ -3896,6 +3927,10 @@ hoist-non-react-statics@^2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.0.tgz#d2ca2dfc19c5a91c5a6615ce8e564ef0347e2a40"
 
+hoist-non-react-statics@^2.5.5:
+  version "2.5.5"
+  resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47"
+
 home-or-tmp@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
@@ -5232,10 +5267,6 @@ lodash.flattendeep@^4.4.0:
   version "4.4.0"
   resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2"
 
-lodash.isequal@4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
-
 lodash.memoize@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
@@ -6862,6 +6893,10 @@ react-error-overlay@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-4.0.0.tgz#d198408a85b4070937a98667f500c832f86bd5d4"
 
+react-fast-compare@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-1.0.0.tgz#813a039155e49b43ceffe99528fe5e9d97a6c938"
+
 react-ga@2.5.3:
   version "2.5.3"
   resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-2.5.3.tgz#0f447c73664c069a5fc341f6f431262e3d4c23c4"
@@ -8258,7 +8293,7 @@ ts-loader@4.3.0:
     micromatch "^3.1.4"
     semver "^5.0.1"
 
-tslib@^1.9.0:
+tslib@^1.9.0, tslib@^1.9.3:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286"
 
@@ -8304,6 +8339,10 @@ typescript@3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.1.tgz#43738f29585d3a87575520a4b93ab6026ef11fdb"
 
+ua-parser-js@^0.7.18:
+  version "0.7.18"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed"
+
 ua-parser-js@^0.7.9:
   version "0.7.17"
   resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac"
index 30cdd42c3c47baae93f8be3941412aa658c7061a..d901aca6df8bfd861926fe6295b41266df0a4888 100644 (file)
@@ -2699,6 +2699,25 @@ onboarding.create_project.project_key=Project key
 onboarding.create_project.project_name=Project name
 onboarding.create_project.select_repositories=Select repositories
 
+onboarding.create_organization.page.header=Create Organization
+onboarding.create_organization.page.description=An organization is a space where a team or a whole company can collaborate accross many projects.{break}To analyze a private project you must subscribe your organization to a paid plan. From {price} a month. {more}
+onboarding.create_organization.organization_name=Organization Name
+onboarding.create_organization.organization_name.description=2 to 32 characters. All chars must be lower-case letters (a to z), digits or dash (but dash can neither be trailing nor heading). The display name can be specified in the additional info.
+onboarding.create_organization.organization_name.error=The provided value doesn't match the expected format.
+onboarding.create_organization.organization_name.taken=This name is already taken.
+onboarding.create_organization.add_additional_info=Add additional info
+onboarding.create_organization.hide_additional_info=Hide additional info
+onboarding.create_organization.display_name=Display Name
+onboarding.create_organization.display_name.description=2 to 64 characters
+onboarding.create_organization.display_name.error=The provided value doesn't match the expected format.
+onboarding.create_organization.avatar=Avatar
+onboarding.create_organization.avatar.description=Url of a small image that represents the organization (preferably 30px height).
+onboarding.create_organization.avatar.error=The value must be a valid url.
+onboarding.create_organization.url=URL
+onboarding.create_organization.url.error=The value must be a valid url.
+onboarding.create_organization.description=Description
+onboarding.create_organization.enter_org_details=Enter your organization details
+
 onboarding.team.header=Join a team
 onboarding.team.first_step=Well congrats, the first step is done!
 onboarding.team.how_to_join=To join a team, the only thing you need to do is to be a user registered on Sonarcloud. The administrator of the Sonarcloud organization you wish to join has to add you to his organization's members {link}. Ask him to do so!