From e86e8c1fe6f96ea84d2f38ee01b3610d21eebe94 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Gr=C3=A9goire=20Aubert?= Date: Fri, 9 Nov 2018 14:44:07 +0100 Subject: [PATCH] SONAR-11321 Improve project page manual fields validate --- .../sonar-web/src/main/js/api/components.ts | 9 ++ .../create/components/ProjectKeyInput.tsx | 145 ++++++++++++++++++ .../create/components/ProjectNameInput.tsx | 101 ++++++++++++ .../__tests__/OrganizationNameInput-test.tsx | 2 +- .../__tests__/ProjectKeyInput-test.tsx | 61 ++++++++ .../__tests__/ProjectNameInput-test.tsx | 37 +++++ .../ProjectKeyInput-test.tsx.snap | 28 ++++ .../ProjectNameInput-test.tsx.snap | 27 ++++ .../create/project/ManualProjectCreate.tsx | 86 ++++------- .../__tests__/ManualProjectCreate-test.tsx | 9 +- .../ManualProjectCreate-test.tsx.snap | 68 +------- .../components/controls/ValidationInput.tsx | 12 +- .../__tests__/ValidationInput-test.tsx | 1 + .../ValidationInput-test.tsx.snap | 50 ++++-- .../src/main/js/helpers/testUtils.ts | 18 ++- .../resources/org/sonar/l10n/core.properties | 8 +- 16 files changed, 516 insertions(+), 146 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/create/components/ProjectKeyInput.tsx create mode 100644 server/sonar-web/src/main/js/apps/create/components/ProjectNameInput.tsx create mode 100644 server/sonar-web/src/main/js/apps/create/components/__tests__/ProjectKeyInput-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/create/components/__tests__/ProjectNameInput-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/ProjectKeyInput-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/ProjectNameInput-test.tsx.snap diff --git a/server/sonar-web/src/main/js/api/components.ts b/server/sonar-web/src/main/js/api/components.ts index df793ee8a87..ae01e608b7b 100644 --- a/server/sonar-web/src/main/js/api/components.ts +++ b/server/sonar-web/src/main/js/api/components.ts @@ -164,6 +164,15 @@ export function getTree(data: { return getJSON('/api/components/tree', data).catch(throwGlobalError); } +export function doesComponentExists( + data: { component: string } & BranchParameters +): Promise { + return getJSON('/api/components/show', data).then( + ({ component }) => component !== undefined, + () => false + ); +} + export function getComponentShow(data: { component: string } & BranchParameters): Promise { return getJSON('/api/components/show', data).catch(throwGlobalError); } diff --git a/server/sonar-web/src/main/js/apps/create/components/ProjectKeyInput.tsx b/server/sonar-web/src/main/js/apps/create/components/ProjectKeyInput.tsx new file mode 100644 index 00000000000..f6a5a4adb02 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/ProjectKeyInput.tsx @@ -0,0 +1,145 @@ +/* + * 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 { debounce } from 'lodash'; +import ValidationInput from '../../../components/controls/ValidationInput'; +import { doesComponentExists } from '../../../api/components'; +import { translate } from '../../../helpers/l10n'; + +interface Props { + className?: string; + initialValue?: string; + onChange: (value: string | undefined) => void; +} + +interface State { + editing: boolean; + error?: string; + touched: boolean; + validating: boolean; + value: string; +} + +export default class ProjectKeyInput extends React.PureComponent { + mounted = false; + constructor(props: Props) { + super(props); + this.state = { error: undefined, editing: false, touched: false, validating: false, value: '' }; + this.checkFreeKey = debounce(this.checkFreeKey, 250); + } + + componentDidMount() { + this.mounted = true; + if (this.props.initialValue !== undefined) { + this.setState({ value: this.props.initialValue }); + this.validateKey(this.props.initialValue); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + checkFreeKey = (key: string) => { + this.setState({ validating: true }); + return doesComponentExists({ component: key }) + .then(alreadyExist => { + if (this.mounted) { + if (!alreadyExist) { + this.setState({ error: undefined, validating: false }); + this.props.onChange(key); + } else { + this.setState({ + error: translate('onboarding.create_project.project_key.taken'), + touched: true, + validating: false + }); + this.props.onChange(undefined); + } + } + }) + .catch(() => { + if (this.mounted) { + this.setState({ error: undefined, validating: false }); + this.props.onChange(key); + } + }); + }; + + handleChange = (event: React.ChangeEvent) => { + const { value } = event.currentTarget; + this.setState({ touched: true, value }); + this.validateKey(value); + }; + + handleBlur = () => { + this.setState({ editing: false }); + }; + + handleFocus = () => { + this.setState({ editing: true }); + }; + + validateKey(key: string) { + if (key.length > 400 || !/^[\w-.:]*[a-zA-Z]+[\w-.:]*$/.test(key)) { + this.setState({ + error: translate('onboarding.create_project.project_key.error'), + touched: true + }); + this.props.onChange(undefined); + } else { + this.checkFreeKey(key); + } + } + + render() { + const isInvalid = this.state.touched && !this.state.editing && this.state.error !== undefined; + const isValid = this.state.touched && !this.state.validating && this.state.error === undefined; + return ( + + + + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/components/ProjectNameInput.tsx b/server/sonar-web/src/main/js/apps/create/components/ProjectNameInput.tsx new file mode 100644 index 00000000000..79eb39ca233 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/ProjectNameInput.tsx @@ -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 * as classNames from 'classnames'; +import ValidationInput from '../../../components/controls/ValidationInput'; +import { translate } from '../../../helpers/l10n'; + +interface Props { + className?: string; + initialValue?: string; + onChange: (value: string | undefined) => void; +} + +interface State { + editing: boolean; + error?: string; + touched: boolean; + value: string; +} + +export default class ProjectNameInput extends React.PureComponent { + state: State = { error: undefined, editing: false, touched: false, value: '' }; + + componentDidMount() { + if (this.props.initialValue) { + const error = this.validateName(this.props.initialValue); + this.setState({ error, touched: Boolean(error), value: this.props.initialValue }); + } + } + + handleChange = (event: React.ChangeEvent) => { + const { value } = event.currentTarget; + const error = this.validateName(value); + this.setState({ error, touched: true, value }); + this.props.onChange(error === undefined ? value : undefined); + }; + + handleBlur = () => { + this.setState({ editing: false }); + }; + + handleFocus = () => { + this.setState({ editing: true }); + }; + + validateName(name: string) { + if (name.length > 255) { + return translate('onboarding.create_project.display_name.error'); + } + return undefined; + } + + render() { + const isInvalid = this.state.touched && !this.state.editing && this.state.error !== undefined; + const isValid = this.state.touched && this.state.error === undefined && this.state.value !== ''; + return ( + + + + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/OrganizationNameInput-test.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/OrganizationNameInput-test.tsx index ecbfdb1f190..ff99e0dd15d 100644 --- a/server/sonar-web/src/main/js/apps/create/components/__tests__/OrganizationNameInput-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/OrganizationNameInput-test.tsx @@ -28,7 +28,7 @@ it('should render correctly', () => { expect(wrapper.find('ValidationInput').prop('isValid')).toMatchSnapshot(); }); -it('should have an error when description is too long', () => { +it('should have an error when name is too long', () => { expect( shallow() .find('ValidationInput') diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/ProjectKeyInput-test.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/ProjectKeyInput-test.tsx new file mode 100644 index 00000000000..29227f371d7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/ProjectKeyInput-test.tsx @@ -0,0 +1,61 @@ +/* + * 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 ProjectKeyInput from '../ProjectKeyInput'; +import { doesComponentExists } from '../../../../api/components'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; + +jest.mock('../../../../api/components', () => ({ + doesComponentExists: jest.fn().mockResolvedValue(false) +})); + +beforeEach(() => { + (doesComponentExists as jest.Mock).mockClear(); +}); + +it('should render correctly', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + wrapper.setState({ touched: true }); + expect(wrapper.find('ValidationInput').prop('isValid')).toMatchSnapshot(); +}); + +it('should not display any status when the key is not defined', async () => { + const wrapper = shallow(); + await waitAndUpdate(wrapper); + expect(wrapper.find('ValidationInput').prop('isInvalid')).toBe(false); + expect(wrapper.find('ValidationInput').prop('isValid')).toBe(false); +}); + +it('should have an error when the key is invalid', async () => { + const wrapper = shallow( + + ); + await waitAndUpdate(wrapper); + expect(wrapper.find('ValidationInput').prop('isInvalid')).toBe(true); +}); + +it('should have an error when the key already exists', async () => { + (doesComponentExists as jest.Mock).mockResolvedValue(true); + const wrapper = shallow(); + await waitAndUpdate(wrapper); + expect(wrapper.find('ValidationInput').prop('isInvalid')).toBe(true); +}); diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/ProjectNameInput-test.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/ProjectNameInput-test.tsx new file mode 100644 index 00000000000..9082a0b1671 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/ProjectNameInput-test.tsx @@ -0,0 +1,37 @@ +/* + * 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 ProjectNameInput from '../ProjectNameInput'; + +it('should render correctly', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + wrapper.setState({ touched: true }); + expect(wrapper.find('ValidationInput').prop('isValid')).toMatchSnapshot(); +}); + +it('should have an error when name is too long', () => { + expect( + shallow() + .find('ValidationInput') + .prop('isInvalid') + ).toBe(true); +}); diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/ProjectKeyInput-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/ProjectKeyInput-test.tsx.snap new file mode 100644 index 00000000000..18423976d60 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/ProjectKeyInput-test.tsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` + + + +`; + +exports[`should render correctly 2`] = `true`; diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/ProjectNameInput-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/ProjectNameInput-test.tsx.snap new file mode 100644 index 00000000000..41b7f890f51 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/__snapshots__/ProjectNameInput-test.tsx.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` + + + +`; + +exports[`should render correctly 2`] = `true`; diff --git a/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx b/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx index 41d05c735ed..2fc7cb8d183 100644 --- a/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/ManualProjectCreate.tsx @@ -21,9 +21,11 @@ import * as React from 'react'; import OrganizationInput from './OrganizationInput'; import DeferredSpinner from '../../../components/common/DeferredSpinner'; import { SubmitButton } from '../../../components/ui/buttons'; +import { createProject } from '../../../api/components'; import { LoggedInUser, Organization } from '../../../app/types'; import { translate } from '../../../helpers/l10n'; -import { createProject } from '../../../api/components'; +import ProjectKeyInput from '../components/ProjectKeyInput'; +import ProjectNameInput from '../components/ProjectNameInput'; interface Props { currentUser: LoggedInUser; @@ -33,20 +35,20 @@ interface Props { } interface State { - projectName: string; - projectKey: string; + projectName?: string; + projectKey?: string; selectedOrganization: string; submitting: boolean; } +type ValidState = State & Required>; + export default class ManualProjectCreate extends React.PureComponent { mounted = false; constructor(props: Props) { super(props); this.state = { - projectName: '', - projectKey: '', selectedOrganization: this.getInitialSelectedOrganization(props), submitting: false }; @@ -60,6 +62,10 @@ export default class ManualProjectCreate extends React.PureComponent) => { event.preventDefault(); - - if (this.isValid()) { - const { projectKey, projectName, selectedOrganization } = this.state; + const { state } = this; + if (this.canSubmit(state)) { this.setState({ submitting: true }); createProject({ - project: projectKey, - name: projectName, - organization: selectedOrganization + project: state.projectKey, + name: state.projectName, + organization: state.selectedOrganization }).then( ({ project }) => this.props.onProjectCreate([project.key]), () => { @@ -95,17 +100,12 @@ export default class ManualProjectCreate extends React.PureComponent) => { - this.setState({ projectName: event.currentTarget.value }); - }; - - handleProjectKeyChange = (event: React.ChangeEvent) => { - this.setState({ projectKey: event.currentTarget.value }); + handleProjectNameChange = (projectName?: string) => { + this.setState({ projectName }); }; - isValid = () => { - const { projectKey, projectName, selectedOrganization } = this.state; - return Boolean(projectKey && projectName && selectedOrganization); + handleProjectKeyChange = (projectKey?: string) => { + this.setState({ projectKey }); }; render() { @@ -118,39 +118,19 @@ export default class ManualProjectCreate extends React.PureComponent -
- - -
-
- - -
- {translate('setup')} + + + + {translate('setup')} + diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx index aad0cab3ad2..dedf50e95e1 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/ManualProjectCreate-test.tsx @@ -39,11 +39,12 @@ it('should correctly create a project', async () => { const onProjectCreate = jest.fn(); const wrapper = getWrapper({ onProjectCreate }); wrapper.find('withRouter(OrganizationInput)').prop('onChange')({ key: 'foo' }); - change(wrapper.find('#project-name'), 'Bar'); - expect(wrapper.find('SubmitButton')).toMatchSnapshot(); - change(wrapper.find('#project-key'), 'bar'); - expect(wrapper.find('SubmitButton')).toMatchSnapshot(); + change(wrapper.find('ProjectKeyInput'), 'bar'); + expect(wrapper.find('SubmitButton').prop('disabled')).toBe(true); + + change(wrapper.find('ProjectNameInput'), 'Bar'); + expect(wrapper.find('SubmitButton').prop('disabled')).toBe(false); submit(wrapper.find('form')); expect(createProject).toBeCalledWith({ project: 'bar', name: 'Bar', organization: 'foo' }); diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap index 66d10b4b7be..131324325a9 100644 --- a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap @@ -1,21 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should correctly create a project 1`] = ` - - setup - -`; - -exports[`should correctly create a project 2`] = ` - - setup - -`; - exports[`should render correctly 1`] = `
-
- - -
-
+ - - -
+ onChange={[Function]} + /> diff --git a/server/sonar-web/src/main/js/components/controls/ValidationInput.tsx b/server/sonar-web/src/main/js/components/controls/ValidationInput.tsx index f95fb297879..a0be10383b3 100644 --- a/server/sonar-web/src/main/js/components/controls/ValidationInput.tsx +++ b/server/sonar-web/src/main/js/components/controls/ValidationInput.tsx @@ -18,13 +18,16 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import HelpTooltip from './HelpTooltip'; import AlertErrorIcon from '../icons-components/AlertErrorIcon'; import AlertSuccessIcon from '../icons-components/AlertSuccessIcon'; interface Props { description?: string; children: React.ReactNode; + className?: string; error: string | undefined; + help?: string; id: string; isInvalid: boolean; isValid: boolean; @@ -35,10 +38,13 @@ interface Props { export default function ValidationInput(props: Props) { const hasError = props.isInvalid && props.error !== undefined; return ( -
+
{props.children} diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/ValidationInput-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/ValidationInput-test.tsx index 37b3d8e6a41..c5245f0d81b 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/ValidationInput-test.tsx +++ b/server/sonar-web/src/main/js/components/controls/__tests__/ValidationInput-test.tsx @@ -27,6 +27,7 @@ it('should render', () => { - - Field label - - - * - + + Field label + + + * + + +
- - Field label - - - * - + + Field label + + + * + +
- - Field label - + + + Field label + +
('onChange')(value); + // TODO find out if `root` is a public api + // https://github.com/airbnb/enzyme/blob/master/packages/enzyme/src/ReactWrapper.js#L109 + (element as any).root().update(); + } else { + element.simulate('change', { + target: { value }, + currentTarget: { value }, + ...event + }); + } } export function keydown(keyCode: number): void { diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 59eba87effe..93e061c69ac 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -2725,7 +2725,13 @@ onboarding.create_project.install_app_description.bitbucket=We need you to insta onboarding.create_project.install_app_description.github=We need you to install the SonarCloud GitHub application on one of your organization in order to select which repositories you want to analyze. onboarding.create_project.organization=Organization onboarding.create_project.project_key=Project key -onboarding.create_project.project_name=Project name +onboarding.create_project.project_key.description=Up to 400 characters. All letters, digits, dash, underscore, point or colon. +onboarding.create_project.project_key.error=The provided value doesn't match the expected format. +onboarding.create_project.project_key.help=Your project key is a unique identifier for your project. +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 provided value doesn't match the expected format. +onboarding.create_project.display_name.description=Up to 500 characters onboarding.create_project.repository_imported=Already imported: {link} onboarding.create_project.see_project=See the project onboarding.create_project.select_repositories=Select repositories -- 2.39.5