]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12884 Improve the validation messages when creating a project
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Mon, 11 May 2020 13:14:53 +0000 (15:14 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 15 May 2020 20:03:42 +0000 (20:03 +0000)
12 files changed:
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/apps/create/project/constants.ts [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/ProjectKeyInput.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/__tests__/ProjectKeyInput-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/ProjectKeyInput-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/__tests__/projects-test.ts [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/constants.ts
server/sonar-web/src/main/js/helpers/projects.ts [new file with mode: 0644]
server/sonar-web/src/main/js/types/component.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 1cb5f7f93b466ebcacf79ebc7e501396a89d83fb..65212ebb525d8006cff8ea6ad8ac13b167685d8f 100644 (file)
@@ -25,6 +25,10 @@ import ValidationInput from 'sonar-ui-common/components/controls/ValidationInput
 import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
 import { translate } from 'sonar-ui-common/helpers/l10n';
 import { createProject, doesComponentExists } from '../../../api/components';
+import ProjectKeyInput from '../../../components/common/ProjectKeyInput';
+import { validateProjectKey } from '../../../helpers/projects';
+import { ProjectKeyValidationResult } from '../../../types/component';
+import { PROJECT_NAME_MAX_LEN } from './constants';
 import CreateProjectPageHeader from './CreateProjectPageHeader';
 import './ManualProjectCreate.css';
 
@@ -153,15 +157,19 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
   };
 
   validateKey = (projectKey: string) => {
-    return projectKey.length > 400 || !/^[\w-.:]*[a-zA-Z]+[\w-.:]*$/.test(projectKey)
-      ? translate('onboarding.create_project.project_key.error')
-      : undefined;
+    const result = validateProjectKey(projectKey);
+    return result === ProjectKeyValidationResult.Valid
+      ? undefined
+      : translate('onboarding.create_project.project_key.error', result);
   };
 
   validateName = (projectName: string) => {
-    return projectName.length === 0 || projectName.length > 255
-      ? translate('onboarding.create_project.display_name.error')
-      : undefined;
+    if (projectName.length === 0) {
+      return translate('onboarding.create_project.display_name.error.empty');
+    } else if (projectName.length > PROJECT_NAME_MAX_LEN) {
+      return translate('onboarding.create_project.display_name.error.too_long');
+    }
+    return undefined;
   };
 
   render() {
@@ -175,8 +183,6 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
       validating
     } = this.state;
     const { branchesEnabled } = this.props;
-    const projectKeyIsInvalid = touched && projectKeyError !== undefined;
-    const projectKeyIsValid = touched && !validating && projectKeyError === undefined;
     const projectNameIsInvalid = touched && projectNameError !== undefined;
     const projectNameIsValid = touched && projectNameError === undefined;
 
@@ -190,30 +196,15 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
         <div className="create-project-manual">
           <div className="flex-1 huge-spacer-right">
             <form className="manual-project-create" onSubmit={this.handleFormSubmit}>
-              <ValidationInput
-                className="form-field"
-                description={translate('onboarding.create_project.project_key.description')}
+              <ProjectKeyInput
                 error={projectKeyError}
                 help={translate('onboarding.create_project.project_key.help')}
-                id="project-key"
-                isInvalid={projectKeyIsInvalid}
-                isValid={projectKeyIsValid}
                 label={translate('onboarding.create_project.project_key')}
-                required={true}>
-                <input
-                  autoFocus={true}
-                  className={classNames('input-super-large', {
-                    'is-invalid': projectKeyIsInvalid,
-                    'is-valid': projectKeyIsValid
-                  })}
-                  id="project-key"
-                  maxLength={400}
-                  minLength={1}
-                  onChange={this.handleProjectKeyChange}
-                  type="text"
-                  value={projectKey}
-                />
-              </ValidationInput>
+                onProjectKeyChange={this.handleProjectKeyChange}
+                projectKey={projectKey}
+                touched={touched}
+                validating={validating}
+              />
 
               <ValidationInput
                 className="form-field"
@@ -231,7 +222,7 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat
                     'is-valid': projectNameIsValid
                   })}
                   id="project-name"
-                  maxLength={255}
+                  maxLength={PROJECT_NAME_MAX_LEN}
                   minLength={1}
                   onChange={this.handleProjectNameChange}
                   type="text"
index 45d80de06687f0f52aefe68aae97b67d58cede12..33123a28e998c1dacc5313f06474fcc033954d0c 100644 (file)
 /* eslint-disable sonarjs/no-duplicate-string */
 import { shallow } from 'enzyme';
 import * as React from 'react';
+import { SubmitButton } from 'sonar-ui-common/components/controls/buttons';
+import ValidationInput from 'sonar-ui-common/components/controls/ValidationInput';
 import { change, submit, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
-import { createProject } from '../../../../api/components';
+import { createProject, doesComponentExists } from '../../../../api/components';
+import ProjectKeyInput from '../../../../components/common/ProjectKeyInput';
+import { validateProjectKey } from '../../../../helpers/projects';
+import { mockEvent } from '../../../../helpers/testMocks';
+import { ProjectKeyValidationResult } from '../../../../types/component';
+import { PROJECT_NAME_MAX_LEN } from '../constants';
 import ManualProjectCreate from '../ManualProjectCreate';
 
 jest.mock('../../../../api/components', () => ({
@@ -31,9 +38,10 @@ jest.mock('../../../../api/components', () => ({
     .mockImplementation(({ component }) => Promise.resolve(component === 'exists'))
 }));
 
-jest.mock('../../../../helpers/system', () => ({
-  isSonarCloud: jest.fn().mockReturnValue(true)
-}));
+jest.mock('../../../../helpers/projects', () => {
+  const { ProjectKeyValidationResult } = jest.requireActual('../../../../types/component');
+  return { validateProjectKey: jest.fn(() => ProjectKeyValidationResult.Valid) };
+});
 
 beforeEach(() => {
   jest.clearAllMocks();
@@ -47,9 +55,14 @@ it('should correctly create a project', async () => {
   const onProjectCreate = jest.fn();
   const wrapper = shallowRender({ onProjectCreate });
 
-  change(wrapper.find('input#project-key'), 'bar');
+  wrapper
+    .find(ProjectKeyInput)
+    .props()
+    .onProjectKeyChange(mockEvent({ currentTarget: { value: 'bar' } }));
   change(wrapper.find('input#project-name'), 'Bar');
-  expect(wrapper.find('SubmitButton').prop('disabled')).toBe(false);
+  expect(wrapper.find(SubmitButton).props().disabled).toBe(false);
+  expect(validateProjectKey).toBeCalledWith('bar');
+  expect(doesComponentExists).toBeCalledWith({ component: 'bar' });
 
   submit(wrapper.find('form'));
   expect(createProject).toBeCalledWith({
@@ -61,73 +74,72 @@ it('should correctly create a project', async () => {
   expect(onProjectCreate).toBeCalledWith(['bar']);
 });
 
-it('should not display any status when the key is not defined', () => {
-  const wrapper = shallowRender();
-  const projectKeyInput = wrapper.find('ValidationInput').first();
-  expect(projectKeyInput.prop('isInvalid')).toBe(false);
-  expect(projectKeyInput.prop('isValid')).toBe(false);
-});
-
 it('should not display any status when the name is not defined', () => {
   const wrapper = shallowRender();
-  const projectKeyInput = wrapper.find('ValidationInput').last();
-  expect(projectKeyInput.prop('isInvalid')).toBe(false);
-  expect(projectKeyInput.prop('isValid')).toBe(false);
+  const projectNameInput = wrapper.find(ValidationInput);
+  expect(projectNameInput.props().isInvalid).toBe(false);
+  expect(projectNameInput.props().isValid).toBe(false);
 });
 
 it('should have an error when the key is invalid', () => {
+  (validateProjectKey as jest.Mock).mockReturnValueOnce(ProjectKeyValidationResult.TooLong);
   const wrapper = shallowRender();
-  change(wrapper.find('input#project-key'), 'KEy-with#speci@l_char');
-  expect(
-    wrapper
-      .find('ValidationInput')
-      .first()
-      .prop('isInvalid')
-  ).toBe(true);
+  const instance = wrapper.instance();
+  instance.handleProjectKeyChange(mockEvent());
+  expect(wrapper.find(ProjectKeyInput).props().error).toBe(
+    `onboarding.create_project.project_key.error.${ProjectKeyValidationResult.TooLong}`
+  );
 });
 
 it('should have an error when the key already exists', async () => {
   const wrapper = shallowRender();
-  change(wrapper.find('input#project-key'), 'exists');
-
+  wrapper.instance().handleProjectKeyChange(mockEvent({ currentTarget: { value: 'exists' } }));
   await waitAndUpdate(wrapper);
-  expect(
-    wrapper
-      .find('ValidationInput')
-      .first()
-      .prop('isInvalid')
-  ).toBe(true);
+  expect(wrapper.state().projectKeyError).toBe('onboarding.create_project.project_key.taken');
 });
 
 it('should ignore promise return if value has been changed in the meantime', async () => {
+  (validateProjectKey as jest.Mock)
+    .mockReturnValueOnce(ProjectKeyValidationResult.Valid)
+    .mockReturnValueOnce(ProjectKeyValidationResult.InvalidChar);
   const wrapper = shallowRender();
+  const instance = wrapper.instance();
 
-  change(wrapper.find('input#project-key'), 'exists');
-  change(wrapper.find('input#project-key'), 'exists%');
+  instance.handleProjectKeyChange(mockEvent({ currentTarget: { value: 'exists' } }));
+  instance.handleProjectKeyChange(mockEvent({ currentTarget: { value: 'exists%' } }));
 
   await waitAndUpdate(wrapper);
 
-  expect(wrapper.state('touched')).toBe(true);
-  expect(wrapper.state('projectKeyError')).toBe('onboarding.create_project.project_key.error');
+  expect(wrapper.state().touched).toBe(true);
+  expect(wrapper.state().projectKeyError).toBe(
+    `onboarding.create_project.project_key.error.${ProjectKeyValidationResult.InvalidChar}`
+  );
 });
 
 it('should autofill the name based on the key', () => {
   const wrapper = shallowRender();
-  change(wrapper.find('input#project-key'), 'bar');
-  expect(wrapper.find('input#project-name').prop('value')).toBe('bar');
+  wrapper.instance().handleProjectKeyChange(mockEvent({ currentTarget: { value: 'bar' } }));
+  expect(wrapper.find('input#project-name').props().value).toBe('bar');
 });
 
-it('should have an error when the name is empty', () => {
+it('should have an error when the name is incorrect', () => {
   const wrapper = shallowRender();
-  change(wrapper.find('input#project-key'), 'bar');
-  change(wrapper.find('input#project-name'), '');
-  expect(
-    wrapper
-      .find('ValidationInput')
-      .last()
-      .prop('isInvalid')
-  ).toBe(true);
-  expect(wrapper.state('projectNameError')).toBe('onboarding.create_project.display_name.error');
+  wrapper.setState({ touched: true });
+  const instance = wrapper.instance();
+
+  instance.handleProjectNameChange(mockEvent({ currentTarget: { value: '' } }));
+  expect(wrapper.find(ValidationInput).props().isInvalid).toBe(true);
+  expect(wrapper.state().projectNameError).toBe(
+    'onboarding.create_project.display_name.error.empty'
+  );
+
+  instance.handleProjectNameChange(
+    mockEvent({ currentTarget: { value: new Array(PROJECT_NAME_MAX_LEN + 1).fill('a').join('') } })
+  );
+  expect(wrapper.find(ValidationInput).props().isInvalid).toBe(true);
+  expect(wrapper.state().projectNameError).toBe(
+    'onboarding.create_project.display_name.error.too_long'
+  );
 });
 
 function shallowRender(props: Partial<ManualProjectCreate['props']> = {}) {
index baaac6282ea5d9d54a964991ec42460f5f96cb02..806b2c17e3e5619b2dec0c509ee3ac373829bf2a 100644 (file)
@@ -15,27 +15,14 @@ exports[`should render correctly 1`] = `
         className="manual-project-create"
         onSubmit={[Function]}
       >
-        <ValidationInput
-          className="form-field"
-          description="onboarding.create_project.project_key.description"
+        <ProjectKeyInput
           help="onboarding.create_project.project_key.help"
-          id="project-key"
-          isInvalid={false}
-          isValid={false}
           label="onboarding.create_project.project_key"
-          required={true}
-        >
-          <input
-            autoFocus={true}
-            className="input-super-large"
-            id="project-key"
-            maxLength={400}
-            minLength={1}
-            onChange={[Function]}
-            type="text"
-            value=""
-          />
-        </ValidationInput>
+          onProjectKeyChange={[Function]}
+          projectKey=""
+          touched={false}
+          validating={false}
+        />
         <ValidationInput
           className="form-field"
           description="onboarding.create_project.display_name.description"
diff --git a/server/sonar-web/src/main/js/apps/create/project/constants.ts b/server/sonar-web/src/main/js/apps/create/project/constants.ts
new file mode 100644 (file)
index 0000000..967aee5
--- /dev/null
@@ -0,0 +1,21 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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.
+ */
+
+export const PROJECT_NAME_MAX_LEN = 255;
diff --git a/server/sonar-web/src/main/js/components/common/ProjectKeyInput.tsx b/server/sonar-web/src/main/js/components/common/ProjectKeyInput.tsx
new file mode 100644 (file)
index 0000000..085acc1
--- /dev/null
@@ -0,0 +1,71 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import ValidationInput from 'sonar-ui-common/components/controls/ValidationInput';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import { PROJECT_KEY_MAX_LEN } from '../../helpers/constants';
+
+export interface ProjectKeyInputProps {
+  error?: string;
+  help?: string;
+  label?: string;
+  onProjectKeyChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
+  placeholder?: string;
+  projectKey?: string;
+  touched: boolean;
+  validating?: boolean;
+}
+
+export default function ProjectKeyInput(props: ProjectKeyInputProps) {
+  const { error, help, label, placeholder, projectKey, touched, validating } = props;
+
+  const isInvalid = touched && error !== undefined;
+  const isValid = touched && !validating && error === undefined;
+
+  return (
+    <ValidationInput
+      className="form-field"
+      description={translate('onboarding.create_project.project_key.description')}
+      error={error}
+      help={help}
+      id="project-key"
+      isInvalid={isInvalid}
+      isValid={isValid}
+      label={label}
+      required={label !== undefined}>
+      <input
+        autoFocus={true}
+        className={classNames('input-super-large', {
+          'is-invalid': isInvalid,
+          'is-valid': isValid
+        })}
+        id="project-key"
+        maxLength={PROJECT_KEY_MAX_LEN}
+        minLength={1}
+        onChange={props.onProjectKeyChange}
+        placeholder={placeholder}
+        type="text"
+        value={projectKey}
+      />
+    </ValidationInput>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/common/__tests__/ProjectKeyInput-test.tsx b/server/sonar-web/src/main/js/components/common/__tests__/ProjectKeyInput-test.tsx
new file mode 100644 (file)
index 0000000..f8d51f9
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import ValidationInput from 'sonar-ui-common/components/controls/ValidationInput';
+import ProjectKeyInput, { ProjectKeyInputProps } from '../ProjectKeyInput';
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot('default');
+  expect(shallowRender({ projectKey: 'foo' })).toMatchSnapshot('with value');
+  expect(
+    shallowRender({ help: 'foo.help', label: 'foo.label', placeholder: 'foo.placeholder' })
+  ).toMatchSnapshot('with label, help, and placeholder');
+  expect(shallowRender({ touched: true })).toMatchSnapshot('valid');
+  expect(shallowRender({ touched: true, error: 'bar.baz' })).toMatchSnapshot('invalid');
+  expect(shallowRender({ touched: true, validating: true })).toMatchSnapshot('validating');
+});
+
+it('should not display any status when the key is not defined', () => {
+  const wrapper = shallowRender();
+  const input = wrapper.find(ValidationInput);
+  expect(input.props().isInvalid).toBe(false);
+  expect(input.props().isValid).toBe(false);
+});
+
+function shallowRender(props: Partial<ProjectKeyInputProps> = {}) {
+  return shallow<ProjectKeyInputProps>(
+    <ProjectKeyInput onProjectKeyChange={jest.fn()} touched={false} {...props} />
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/ProjectKeyInput-test.tsx.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/ProjectKeyInput-test.tsx.snap
new file mode 100644 (file)
index 0000000..4265dc8
--- /dev/null
@@ -0,0 +1,132 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: default 1`] = `
+<ValidationInput
+  className="form-field"
+  description="onboarding.create_project.project_key.description"
+  id="project-key"
+  isInvalid={false}
+  isValid={false}
+  required={false}
+>
+  <input
+    autoFocus={true}
+    className="input-super-large"
+    id="project-key"
+    maxLength={400}
+    minLength={1}
+    onChange={[MockFunction]}
+    type="text"
+  />
+</ValidationInput>
+`;
+
+exports[`should render correctly: invalid 1`] = `
+<ValidationInput
+  className="form-field"
+  description="onboarding.create_project.project_key.description"
+  error="bar.baz"
+  id="project-key"
+  isInvalid={true}
+  isValid={false}
+  required={false}
+>
+  <input
+    autoFocus={true}
+    className="input-super-large is-invalid"
+    id="project-key"
+    maxLength={400}
+    minLength={1}
+    onChange={[MockFunction]}
+    type="text"
+  />
+</ValidationInput>
+`;
+
+exports[`should render correctly: valid 1`] = `
+<ValidationInput
+  className="form-field"
+  description="onboarding.create_project.project_key.description"
+  id="project-key"
+  isInvalid={false}
+  isValid={true}
+  required={false}
+>
+  <input
+    autoFocus={true}
+    className="input-super-large is-valid"
+    id="project-key"
+    maxLength={400}
+    minLength={1}
+    onChange={[MockFunction]}
+    type="text"
+  />
+</ValidationInput>
+`;
+
+exports[`should render correctly: validating 1`] = `
+<ValidationInput
+  className="form-field"
+  description="onboarding.create_project.project_key.description"
+  id="project-key"
+  isInvalid={false}
+  isValid={false}
+  required={false}
+>
+  <input
+    autoFocus={true}
+    className="input-super-large"
+    id="project-key"
+    maxLength={400}
+    minLength={1}
+    onChange={[MockFunction]}
+    type="text"
+  />
+</ValidationInput>
+`;
+
+exports[`should render correctly: with label, help, and placeholder 1`] = `
+<ValidationInput
+  className="form-field"
+  description="onboarding.create_project.project_key.description"
+  help="foo.help"
+  id="project-key"
+  isInvalid={false}
+  isValid={false}
+  label="foo.label"
+  required={true}
+>
+  <input
+    autoFocus={true}
+    className="input-super-large"
+    id="project-key"
+    maxLength={400}
+    minLength={1}
+    onChange={[MockFunction]}
+    placeholder="foo.placeholder"
+    type="text"
+  />
+</ValidationInput>
+`;
+
+exports[`should render correctly: with value 1`] = `
+<ValidationInput
+  className="form-field"
+  description="onboarding.create_project.project_key.description"
+  id="project-key"
+  isInvalid={false}
+  isValid={false}
+  required={false}
+>
+  <input
+    autoFocus={true}
+    className="input-super-large"
+    id="project-key"
+    maxLength={400}
+    minLength={1}
+    onChange={[MockFunction]}
+    type="text"
+    value="foo"
+  />
+</ValidationInput>
+`;
diff --git a/server/sonar-web/src/main/js/helpers/__tests__/projects-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/projects-test.ts
new file mode 100644 (file)
index 0000000..cfee705
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { ProjectKeyValidationResult } from '../../types/component';
+import { PROJECT_KEY_MAX_LEN } from '../constants';
+import { validateProjectKey } from '../projects';
+
+describe('validateProjectKey', () => {
+  it('should correctly flag an invalid key', () => {
+    // Cannot have special characters except whitelist.
+    expect(validateProjectKey('foo/bar')).toBe(ProjectKeyValidationResult.InvalidChar);
+    // Cannot contain only numbers.
+    expect(validateProjectKey('123')).toBe(ProjectKeyValidationResult.OnlyDigits);
+    // Cannot be more than 400 chars long.
+    expect(validateProjectKey(new Array(PROJECT_KEY_MAX_LEN + 1).fill('a').join(''))).toBe(
+      ProjectKeyValidationResult.TooLong
+    );
+    // Cannot be empty.
+    expect(validateProjectKey('')).toBe(ProjectKeyValidationResult.Empty);
+  });
+
+  it('should not flag a valid key', () => {
+    expect(validateProjectKey('foo:bar_baz-12.is')).toBe(ProjectKeyValidationResult.Valid);
+    expect(validateProjectKey('12:34')).toBe(ProjectKeyValidationResult.Valid);
+  });
+});
index c82212bee8e72679535ad9e63f74b5d1b8538eb9..f0d60622137f3a6020cb9dcea0aa54f80d03f9fd 100644 (file)
@@ -53,3 +53,5 @@ export const RATING_COLORS = [
   colors.orange,
   colors.red
 ];
+
+export const PROJECT_KEY_MAX_LEN = 400;
diff --git a/server/sonar-web/src/main/js/helpers/projects.ts b/server/sonar-web/src/main/js/helpers/projects.ts
new file mode 100644 (file)
index 0000000..f64688d
--- /dev/null
@@ -0,0 +1,39 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { ProjectKeyValidationResult } from '../types/component';
+import { PROJECT_KEY_MAX_LEN } from './constants';
+
+export function validateProjectKey(projectKey: string): ProjectKeyValidationResult {
+  // This is the regex used on the backend:
+  //   [\p{Alnum}\-_.:]*[\p{Alpha}\-_.:]+[\p{Alnum}\-_.:]*
+  // See sonar-core/src/main/java/org/sonar/core/component/ComponentKeys.java
+  const regex = /^[\w\-.:]*[a-z\-_.:]+[\w\-.:]*$/i;
+  if (projectKey.length === 0) {
+    return ProjectKeyValidationResult.Empty;
+  } else if (projectKey.length > PROJECT_KEY_MAX_LEN) {
+    return ProjectKeyValidationResult.TooLong;
+  } else if (regex.test(projectKey)) {
+    return ProjectKeyValidationResult.Valid;
+  } else {
+    return /^[0-9]+$/.test(projectKey)
+      ? ProjectKeyValidationResult.OnlyDigits
+      : ProjectKeyValidationResult.InvalidChar;
+  }
+}
index 682fe0ecc8d1e1795c4a21e3d40697296f335a5c..ac2812362afa98b475cf2d584a127f855db36531 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+export enum Visibility {
+  Public = 'public',
+  Private = 'private'
+}
 
 export enum ComponentQualifier {
   Application = 'APP',
@@ -30,6 +34,14 @@ export enum ComponentQualifier {
   TestFile = 'UTS'
 }
 
+export enum ProjectKeyValidationResult {
+  Valid = 'valid',
+  Empty = 'empty',
+  TooLong = 'too_long',
+  InvalidChar = 'invalid_char',
+  OnlyDigits = 'only_digits'
+}
+
 export function isPortfolioLike(componentQualifier?: string | ComponentQualifier) {
   return Boolean(
     componentQualifier &&
@@ -39,8 +51,3 @@ export function isPortfolioLike(componentQualifier?: string | ComponentQualifier
       ].includes(componentQualifier)
   );
 }
-
-export enum Visibility {
-  Public = 'public',
-  Private = 'private'
-}
index 197dd5b2431710552e4483f3e254a9fdaded3808..a7acab3b9f6647d35fd9f07b4e5e0377792e4d6f 100644 (file)
@@ -3077,12 +3077,16 @@ onboarding.project_analysis.guide_to_integrate_pipelines=follow the guide to int
 
 onboarding.create_project.setup_manually=Create manually
 onboarding.create_project.project_key=Project key
-onboarding.create_project.project_key.description=Up to 400 characters. All letters, digits, dash, underscore, period or colon.
-onboarding.create_project.project_key.error=The provided value doesn't match the expected format.
+onboarding.create_project.project_key.description=Up to 400 characters. Allowed characters are alphanumeric, '-' (dash), '_' (underscore), '.' (period) and ':' (colon), with at least one non-digit.
+onboarding.create_project.project_key.error.empty=You must provide at least one character.
+onboarding.create_project.project_key.error.too_long=The provided key is too long.
+onboarding.create_project.project_key.error.invalid_char=The provided key contains invalid characters.
+onboarding.create_project.project_key.error.only_digits=The provided key contains only digits.
 onboarding.create_project.project_key.help=Your project key is a unique identifier for your project. If you are using Maven, make sure the key matches the "groupId:artifactId" format.
 onboarding.create_project.project_key.taken=This project key is already taken.
 onboarding.create_project.display_name=Display name
-onboarding.create_project.display_name.error=The display name is required.
+onboarding.create_project.display_name.error.empty=The display name is required.
+onboarding.create_project.display_name.error.too_long=The display name is too long.
 onboarding.create_project.display_name.description=Up to 255 characters
 onboarding.create_project.display_name.help=Some scanners might override the value you provide.
 onboarding.create_project.repository_imported=Already set up