]> source.dussan.org Git - sonarqube.git/commitdiff
SONARCLOUD-303 Allow to choose project visibility in manual project creation tutorial
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Mon, 24 Dec 2018 17:34:44 +0000 (18:34 +0100)
committerSonarTech <sonartech@sonarsource.com>
Fri, 4 Jan 2019 19:21:02 +0000 (20:21 +0100)
server/sonar-web/src/main/js/api/components.ts
server/sonar-web/src/main/js/apps/create/components/CardPlan.css
server/sonar-web/src/main/js/apps/create/project/CreateProjectPage.tsx
server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.css [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap
server/sonar-web/src/main/js/components/common/VisibilitySelector.tsx
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/VisibilitySelector-test.tsx.snap
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 72399ff7bcf013831f515618022e643be149ca39..efbeedbaa52cbd0b40f7d4438cf7f080878f68c5 100644 (file)
@@ -75,6 +75,7 @@ export function createProject(data: {
   name: string;
   project: string;
   organization?: string;
+  visibility?: T.Visibility;
 }): Promise<{ project: ProjectBase }> {
   return postJSON('/api/projects/create', data).catch(throwGlobalError);
 }
index cd76265205c1c969ce65b4050890342a883f0d46..174e3c33d0024ea848774fd7eacf4954ba4f47a8 100644 (file)
   transition: all 0.2s ease;
 }
 
+.card-plan.animated {
+  height: 0;
+  border-width: 0;
+  overflow: hidden;
+}
+
+.card-plan.animated.open {
+  height: 210px;
+  border-width: 1px;
+}
+
 .card-plan.highlight {
   box-shadow: var(--defaultShadow);
 }
index d03bad53618fef5fb46de00dc1952b320a2f73ff..32569e931225d60c43ad036856b4ae2e8dc5650c 100644 (file)
@@ -148,6 +148,7 @@ export class CreateProjectPage extends React.PureComponent<Props & WithRouterPro
               {showManualTab || !almApplication ? (
                 <ManualProjectCreate
                   currentUser={currentUser}
+                  fetchMyOrganizations={this.props.fetchMyOrganizations}
                   onProjectCreate={this.handleProjectCreate}
                   organization={state.organization}
                   userOrganizations={userOrganizations.filter(
diff --git a/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.css b/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.css
new file mode 100644 (file)
index 0000000..4f8d944
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+.manual-project-create {
+  max-width: 650px;
+}
+
+.manual-project-create .visibility-select-option {
+  margin-left: 0 !important;
+  margin-bottom: var(--gridSize);
+  display: flex;
+  align-items: center;
+  font-size: var(--mediumFontSize);
+}
+
+.manual-project-create .visibility-details {
+  display: block;
+  margin: var(--gridSize) 0;
+}
+
+.manual-project-create .visibility-select-wrapper {
+  padding: var(--gridSize) 0 calc(2 * var(--gridSize)) 0;
+}
+
+.manual-project-create .button {
+  margin-top: var(--gridSize);
+}
index 79f28a927c1260bea94f193f2d126e6b327792ee..0479087e78017828873e04231a2c08fb1e4e9472 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import * as classNames from 'classnames';
 import OrganizationInput from './OrganizationInput';
 import DeferredSpinner from '../../../components/common/DeferredSpinner';
 import { SubmitButton } from '../../../components/ui/buttons';
@@ -25,9 +26,14 @@ import { createProject } from '../../../api/components';
 import { translate } from '../../../helpers/l10n';
 import ProjectKeyInput from '../components/ProjectKeyInput';
 import ProjectNameInput from '../components/ProjectNameInput';
+import VisibilitySelector from '../../../components/common/VisibilitySelector';
+import { isSonarCloud } from '../../../helpers/system';
+import UpgradeOrganizationBox from '../components/UpgradeOrganizationBox';
+import './ManualProjectCreate.css';
 
 interface Props {
   currentUser: T.LoggedInUser;
+  fetchMyOrganizations: () => Promise<void>;
   onProjectCreate: (projectKeys: string[]) => void;
   organization?: string;
   userOrganizations: T.Organization[];
@@ -36,11 +42,13 @@ interface Props {
 interface State {
   projectName?: string;
   projectKey?: string;
-  selectedOrganization: string;
+  selectedOrganization?: T.Organization;
+  selectedVisibility?: T.Visibility;
   submitting: boolean;
 }
 
-type ValidState = State & Required<Pick<State, 'projectName' | 'projectKey'>>;
+type ValidState = State &
+  Required<Pick<State, 'projectName' | 'projectKey' | 'selectedOrganization'>>;
 
 export default class ManualProjectCreate extends React.PureComponent<Props, State> {
   mounted = false;
@@ -61,19 +69,27 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
     this.mounted = false;
   }
 
+  canChoosePrivate = (selectedOrganization: T.Organization | undefined) => {
+    return Boolean(selectedOrganization && selectedOrganization.subscription === 'PAID');
+  };
+
   canSubmit(state: State): state is ValidState {
     return Boolean(state.projectKey && state.projectName && state.selectedOrganization);
   }
 
-  getInitialSelectedOrganization(props: Props) {
+  getInitialSelectedOrganization = (props: Props) => {
     if (props.organization) {
-      return props.organization;
+      return this.getOrganization(props.organization);
     } else if (props.userOrganizations.length === 1) {
-      return props.userOrganizations[0].key;
+      return props.userOrganizations[0];
     } else {
-      return '';
+      return undefined;
     }
-  }
+  };
+
+  getOrganization = (organizationKey: string) => {
+    return this.props.userOrganizations.find(({ key }: T.Organization) => key === organizationKey);
+  };
 
   handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
     event.preventDefault();
@@ -83,7 +99,8 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
       createProject({
         project: state.projectKey,
         name: state.projectName,
-        organization: state.selectedOrganization
+        organization: state.selectedOrganization.key,
+        visibility: this.state.selectedVisibility
       }).then(
         ({ project }) => this.props.onProjectCreate([project.key]),
         () => {
@@ -96,7 +113,34 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
   };
 
   handleOrganizationSelect = ({ key }: T.Organization) => {
-    this.setState({ selectedOrganization: key });
+    const selectedOrganization = this.getOrganization(key);
+    let { selectedVisibility } = this.state;
+
+    if (selectedVisibility === undefined) {
+      selectedVisibility = this.canChoosePrivate(selectedOrganization) ? 'private' : 'public';
+    }
+
+    this.setState({
+      selectedOrganization,
+      selectedVisibility
+    });
+  };
+
+  handleOrganizationUpgrade = () => {
+    this.props.fetchMyOrganizations().then(
+      () => {
+        this.setState(prevState => {
+          if (prevState.selectedOrganization) {
+            const selectedOrganization = this.getOrganization(prevState.selectedOrganization.key);
+            return {
+              selectedOrganization
+            };
+          }
+          return null;
+        });
+      },
+      () => {}
+    );
   };
 
   handleProjectNameChange = (projectName?: string) => {
@@ -107,32 +151,65 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
     this.setState({ projectKey });
   };
 
+  handleVisibilityChange = (selectedVisibility: T.Visibility) => {
+    this.setState({ selectedVisibility });
+  };
+
   render() {
-    const { submitting } = this.state;
+    const { selectedOrganization, submitting } = this.state;
+    const canChoosePrivate = this.canChoosePrivate(selectedOrganization);
+
     return (
-      <>
-        <form onSubmit={this.handleFormSubmit}>
-          <OrganizationInput
-            onChange={this.handleOrganizationSelect}
-            organization={this.state.selectedOrganization}
-            organizations={this.props.userOrganizations}
-          />
-          <ProjectKeyInput
-            className="form-field"
-            initialValue={this.state.projectKey}
-            onChange={this.handleProjectKeyChange}
-          />
-          <ProjectNameInput
-            className="form-field"
-            initialValue={this.state.projectName}
-            onChange={this.handleProjectNameChange}
-          />
-          <SubmitButton disabled={!this.canSubmit(this.state) || submitting}>
-            {translate('setup')}
-          </SubmitButton>
-          <DeferredSpinner className="spacer-left" loading={submitting} />
-        </form>
-      </>
+      <div className="create-project">
+        <div className="flex-1 huge-spacer-right">
+          <form className="manual-project-create" onSubmit={this.handleFormSubmit}>
+            <OrganizationInput
+              onChange={this.handleOrganizationSelect}
+              organization={selectedOrganization ? selectedOrganization.key : ''}
+              organizations={this.props.userOrganizations}
+            />
+            <ProjectKeyInput
+              className="form-field"
+              initialValue={this.state.projectKey}
+              onChange={this.handleProjectKeyChange}
+            />
+            <ProjectNameInput
+              className="form-field"
+              initialValue={this.state.projectName}
+              onChange={this.handleProjectNameChange}
+            />
+            {isSonarCloud() &&
+              selectedOrganization && (
+                <div
+                  className={classNames('visibility-select-wrapper', {
+                    open: Boolean(this.state.selectedOrganization)
+                  })}>
+                  <VisibilitySelector
+                    canTurnToPrivate={canChoosePrivate}
+                    onChange={this.handleVisibilityChange}
+                    showDetails={true}
+                    visibility={canChoosePrivate ? this.state.selectedVisibility : 'public'}
+                  />
+                </div>
+              )}
+            <SubmitButton disabled={!this.canSubmit(this.state) || submitting}>
+              {translate('setup')}
+            </SubmitButton>
+            <DeferredSpinner className="spacer-left" loading={submitting} />
+          </form>
+        </div>
+
+        {isSonarCloud() &&
+          selectedOrganization && (
+            <div className="create-project-side-sticky">
+              <UpgradeOrganizationBox
+                className={classNames('animated', { open: !canChoosePrivate })}
+                onOrganizationUpgrade={this.handleOrganizationUpgrade}
+                organization={selectedOrganization}
+              />
+            </div>
+          )}
+      </div>
     );
   }
 }
index e3496c9c617b3817b101f7b9178e6f51cc0df3a2..52547e438912b1c1a81b2ff1eded7d2a5e14dd3a 100644 (file)
@@ -35,7 +35,7 @@ it('should render correctly', () => {
   expect(getWrapper()).toMatchSnapshot();
 });
 
-it('should correctly create a project', async () => {
+it('should correctly create a public project', async () => {
   const onProjectCreate = jest.fn();
   const wrapper = getWrapper({ onProjectCreate });
   wrapper.find('withRouter(OrganizationInput)').prop<Function>('onChange')({ key: 'foo' });
@@ -47,7 +47,32 @@ it('should correctly create a project', async () => {
   expect(wrapper.find('SubmitButton').prop('disabled')).toBe(false);
 
   submit(wrapper.find('form'));
-  expect(createProject).toBeCalledWith({ project: 'bar', name: 'Bar', organization: 'foo' });
+  expect(createProject).toBeCalledWith({
+    project: 'bar',
+    name: 'Bar',
+    organization: 'foo',
+    visibility: 'public'
+  });
+
+  await waitAndUpdate(wrapper);
+  expect(onProjectCreate).toBeCalledWith(['bar']);
+});
+
+it('should correctly create a private project', async () => {
+  const onProjectCreate = jest.fn();
+  const wrapper = getWrapper({ onProjectCreate });
+  wrapper.find('withRouter(OrganizationInput)').prop<Function>('onChange')({ key: 'bar' });
+
+  change(wrapper.find('ProjectKeyInput'), 'bar');
+  change(wrapper.find('ProjectNameInput'), 'Bar');
+
+  submit(wrapper.find('form'));
+  expect(createProject).toBeCalledWith({
+    project: 'bar',
+    name: 'Bar',
+    organization: 'bar',
+    visibility: 'private'
+  });
 
   await waitAndUpdate(wrapper);
   expect(onProjectCreate).toBeCalledWith(['bar']);
@@ -57,8 +82,12 @@ function getWrapper(props = {}) {
   return shallow(
     <ManualProjectCreate
       currentUser={{ groups: [], isLoggedIn: true, login: 'foo', name: 'Foo', scmAccounts: [] }}
+      fetchMyOrganizations={jest.fn()}
       onProjectCreate={jest.fn()}
-      userOrganizations={[{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }]}
+      userOrganizations={[
+        { key: 'foo', name: 'Foo' },
+        { key: 'bar', name: 'Bar', subscription: 'PAID' }
+      ]}
       {...props}
     />
   );
index 131324325a904ef5604ca43d726d28ffeca63194..9961f137b4d1b1e5c9fcb75b6ac3148f615def99 100644 (file)
@@ -1,44 +1,52 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`should render correctly 1`] = `
-<Fragment>
-  <form
-    onSubmit={[Function]}
+<div
+  className="create-project"
+>
+  <div
+    className="flex-1 huge-spacer-right"
   >
-    <withRouter(OrganizationInput)
-      onChange={[Function]}
-      organization=""
-      organizations={
-        Array [
-          Object {
-            "key": "foo",
-            "name": "Foo",
-          },
-          Object {
-            "key": "bar",
-            "name": "Bar",
-          },
-        ]
-      }
-    />
-    <ProjectKeyInput
-      className="form-field"
-      onChange={[Function]}
-    />
-    <ProjectNameInput
-      className="form-field"
-      onChange={[Function]}
-    />
-    <SubmitButton
-      disabled={true}
+    <form
+      className="manual-project-create"
+      onSubmit={[Function]}
     >
-      setup
-    </SubmitButton>
-    <DeferredSpinner
-      className="spacer-left"
-      loading={false}
-      timeout={100}
-    />
-  </form>
-</Fragment>
+      <withRouter(OrganizationInput)
+        onChange={[Function]}
+        organization=""
+        organizations={
+          Array [
+            Object {
+              "key": "foo",
+              "name": "Foo",
+            },
+            Object {
+              "key": "bar",
+              "name": "Bar",
+              "subscription": "PAID",
+            },
+          ]
+        }
+      />
+      <ProjectKeyInput
+        className="form-field"
+        onChange={[Function]}
+      />
+      <ProjectNameInput
+        className="form-field"
+        onChange={[Function]}
+      />
+      <SubmitButton
+        disabled={true}
+      >
+        setup
+      </SubmitButton>
+      <DeferredSpinner
+        className="spacer-left"
+        loading={false}
+        timeout={100}
+      />
+    </form>
+  </div>
+</div>
 `;
index d175845ec35debbc7bd26d77d5a71dce54f84b9c..447da212416d6a6ee5f89df6d379b7a1e1003aef 100644 (file)
@@ -25,6 +25,7 @@ interface Props {
   canTurnToPrivate?: boolean;
   className?: string;
   onChange: (visibility: T.Visibility) => void;
+  showDetails?: boolean;
   visibility?: T.Visibility;
 }
 
@@ -43,9 +44,9 @@ export default class VisibilitySelector extends React.PureComponent<Props> {
 
   render() {
     return (
-      <div className={this.props.className}>
+      <div className={classNames('visibility-select', this.props.className)}>
         <a
-          className="link-base-color link-no-underline"
+          className="link-base-color link-no-underline visibility-select-option"
           href="#"
           id="visibility-public"
           onClick={this.handlePublicClick}>
@@ -56,29 +57,50 @@ export default class VisibilitySelector extends React.PureComponent<Props> {
           />
           <span className="spacer-left">{translate('visibility.public')}</span>
         </a>
+        {this.props.showDetails && (
+          <span className="visibility-details note">
+            {translate('visibility.public.description.long')}
+          </span>
+        )}
 
         {this.props.canTurnToPrivate ? (
-          <a
-            className="link-base-color link-no-underline huge-spacer-left"
-            href="#"
-            id="visibility-private"
-            onClick={this.handlePrivateClick}>
-            <i
-              className={classNames('icon-radio', {
-                'is-checked': this.props.visibility === 'private'
-              })}
-            />
-            <span className="spacer-left">{translate('visibility.private')}</span>
-          </a>
+          <>
+            <a
+              className="link-base-color link-no-underline huge-spacer-left visibility-select-option"
+              href="#"
+              id="visibility-private"
+              onClick={this.handlePrivateClick}>
+              <i
+                className={classNames('icon-radio', {
+                  'is-checked': this.props.visibility === 'private'
+                })}
+              />
+              <span className="spacer-left">{translate('visibility.private')}</span>
+            </a>
+            {this.props.showDetails && (
+              <span className="visibility-details note">
+                {translate('visibility.private.description.long')}
+              </span>
+            )}
+          </>
         ) : (
-          <span className="huge-spacer-left text-muted cursor-not-allowed" id="visibility-private">
-            <i
-              className={classNames('icon-radio', {
-                'is-checked': this.props.visibility === 'private'
-              })}
-            />
-            <span className="spacer-left">{translate('visibility.private')}</span>
-          </span>
+          <>
+            <span
+              className="huge-spacer-left text-muted cursor-not-allowed visibility-select-option"
+              id="visibility-private">
+              <i
+                className={classNames('icon-radio', {
+                  'is-checked': this.props.visibility === 'private'
+                })}
+              />
+              <span className="spacer-left">{translate('visibility.private')}</span>
+            </span>
+            {this.props.showDetails && (
+              <span className="visibility-details note">
+                {translate('visibility.private.description.long')}
+              </span>
+            )}
+          </>
         )}
       </div>
     );
index 745a71b9451127ed65be24bad27074953d951160..98a9be4035cf93fff375b3088cbef8bec05e1ba0 100644 (file)
@@ -1,9 +1,11 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`changes visibility 1`] = `
-<div>
+<div
+  className="visibility-select"
+>
   <a
-    className="link-base-color link-no-underline"
+    className="link-base-color link-no-underline visibility-select-option"
     href="#"
     id="visibility-public"
     onClick={[Function]}
@@ -18,7 +20,7 @@ exports[`changes visibility 1`] = `
     </span>
   </a>
   <a
-    className="link-base-color link-no-underline huge-spacer-left"
+    className="link-base-color link-no-underline huge-spacer-left visibility-select-option"
     href="#"
     id="visibility-private"
     onClick={[Function]}
@@ -36,9 +38,11 @@ exports[`changes visibility 1`] = `
 `;
 
 exports[`changes visibility 2`] = `
-<div>
+<div
+  className="visibility-select"
+>
   <a
-    className="link-base-color link-no-underline"
+    className="link-base-color link-no-underline visibility-select-option"
     href="#"
     id="visibility-public"
     onClick={[Function]}
@@ -53,7 +57,7 @@ exports[`changes visibility 2`] = `
     </span>
   </a>
   <a
-    className="link-base-color link-no-underline huge-spacer-left"
+    className="link-base-color link-no-underline huge-spacer-left visibility-select-option"
     href="#"
     id="visibility-private"
     onClick={[Function]}
@@ -71,9 +75,11 @@ exports[`changes visibility 2`] = `
 `;
 
 exports[`renders disabled 1`] = `
-<div>
+<div
+  className="visibility-select"
+>
   <a
-    className="link-base-color link-no-underline"
+    className="link-base-color link-no-underline visibility-select-option"
     href="#"
     id="visibility-public"
     onClick={[Function]}
@@ -88,7 +94,7 @@ exports[`renders disabled 1`] = `
     </span>
   </a>
   <span
-    className="huge-spacer-left text-muted cursor-not-allowed"
+    className="huge-spacer-left text-muted cursor-not-allowed visibility-select-option"
     id="visibility-private"
   >
     <i
index 469709822628c8b487a94b42d7c1f25c1da52767..015c3b7265de1f30bdefd2ae4df196f9620b5f5a 100644 (file)
@@ -486,11 +486,13 @@ visibility.public.description.TRK=This project is public. Anyone can browse and
 visibility.public.description.VW=This portfolio is public. Anyone can browse it.
 visibility.public.description.APP=This application is public. Anyone can browse it.
 visibility.public.description.short=Anyone can browse and see the source code.
+visibility.public.description.long=Anyone will be able to browse your source code and see the result of your analysis.
 visibility.private=Private
 visibility.private.description.TRK=This project is private. Only authorized users can browse and see the source code.
 visibility.private.description.VW=This portfolio is private. Only authorized users can browse it.
 visibility.private.description.APP=This application is private. Only authorized users can browse it.
 visibility.private.description.short=Only authorized users can browse and see the source code.
+visibility.private.description.long=Only members of the organization will be able to browse your source code and see the result of your analysis.
 
 
 #------------------------------------------------------------------------------
@@ -2779,6 +2781,7 @@ onboarding.create_project.repository_imported=Already imported: {link}
 onboarding.create_project.see_project=See the project
 onboarding.create_project.select_repositories=Select repositories
 onboarding.create_project.subscribe_to_import_private_repositories=You need to subscribe your organization to a paid plan to import private projects
+onboarding.create_project.encourage_to_subscribe=Subscribe your organization to our paid plan to get unlimited private projects.
 onboarding.create_project.subscribtion_success_x={0} has been successfully upgraded to paid plan. You can now import and analyze private projects.
 
 onboarding.create_organization.page.header=Create Organization