From 32ac811eb5f97d5a99756112173612961900d871 Mon Sep 17 00:00:00 2001 From: 7PH Date: Tue, 3 Oct 2023 13:23:28 +0200 Subject: [PATCH] SONAR-20366 Migrate quality profile create modal to MIUI --- .../src/components/input/FileInput.tsx | 89 ++++ .../input/__tests__/FileInput-test.tsx | 50 +++ .../src/components/input/index.ts | 1 + .../api/mocks/QualityProfilesServiceMock.ts | 13 +- .../__tests__/QualityProfilesApp-it.tsx | 35 +- .../home/CreateProfileForm.tsx | 404 +++++++++--------- .../resources/org/sonar/l10n/core.properties | 3 + 7 files changed, 383 insertions(+), 212 deletions(-) create mode 100644 server/sonar-web/design-system/src/components/input/FileInput.tsx create mode 100644 server/sonar-web/design-system/src/components/input/__tests__/FileInput-test.tsx diff --git a/server/sonar-web/design-system/src/components/input/FileInput.tsx b/server/sonar-web/design-system/src/components/input/FileInput.tsx new file mode 100644 index 00000000000..533d10e4c92 --- /dev/null +++ b/server/sonar-web/design-system/src/components/input/FileInput.tsx @@ -0,0 +1,89 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 classNames from 'classnames'; +import { useCallback, useRef, useState } from 'react'; +import { Note } from '../Text'; +import { ButtonSecondary } from '../buttons/ButtonSecondary'; + +interface Props { + chooseLabel: string; + className?: string; + clearLabel: string; + id?: string; + name?: string; + noFileLabel: string; + onFileSelected?: (file?: File) => void; + required?: boolean; +} + +export function FileInput(props: Readonly) { + const { className, id, name, onFileSelected, required } = props; + const { chooseLabel, clearLabel, noFileLabel } = props; + + const [selectedFileName, setSelectedFileName] = useState(undefined); + const fileInputRef = useRef(null); + + const handleFileInputChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + onFileSelected?.(file); + setSelectedFileName(file?.name); + }, + [onFileSelected], + ); + + const handleFileInputReset = useCallback(() => { + if (fileInputRef.current) { + onFileSelected?.(undefined); + fileInputRef.current.value = ''; + setSelectedFileName(undefined); + } + }, [fileInputRef, onFileSelected]); + + const handleFileInputClick = useCallback(() => { + fileInputRef.current?.click(); + }, [fileInputRef]); + + return ( +
+ {selectedFileName ? ( + <> + {clearLabel} + {selectedFileName} + + ) : ( + <> + {chooseLabel} + {noFileLabel} + + )} + +
+ ); +} diff --git a/server/sonar-web/design-system/src/components/input/__tests__/FileInput-test.tsx b/server/sonar-web/design-system/src/components/input/__tests__/FileInput-test.tsx new file mode 100644 index 00000000000..fcfb6d728cf --- /dev/null +++ b/server/sonar-web/design-system/src/components/input/__tests__/FileInput-test.tsx @@ -0,0 +1,50 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { fireEvent, screen } from '@testing-library/react'; +import { render } from '../../../helpers/testUtils'; +import { FCProps } from '../../../types/misc'; +import { FileInput } from '../FileInput'; + +it('should correclty choose a file and reset it', async () => { + const file = new File([''], 'file.txt', { type: 'text/plain' }); + const onFileSelected = jest.fn(); + const { user } = setupWithProps({ onFileSelected }); + + expect(screen.getByRole('button')).toHaveTextContent('Choose'); + expect(screen.getByText('No file selected')).toBeVisible(); + + await user.click(screen.getByRole('button')); + fireEvent.change(screen.getByTestId('file-input'), { + target: { files: [file] }, + }); + expect(onFileSelected).toHaveBeenCalledWith(file); + expect(screen.getByText('file.txt')).toBeVisible(); + expect(screen.getByRole('button')).toHaveTextContent('Clear'); + + await user.click(screen.getByRole('button')); + expect(screen.getByText('No file selected')).toBeVisible(); + expect(onFileSelected).toHaveBeenCalledWith(undefined); +}); + +function setupWithProps(props: Partial> = {}) { + return render( + , + ); +} diff --git a/server/sonar-web/design-system/src/components/input/index.ts b/server/sonar-web/design-system/src/components/input/index.ts index ad389e9f27f..ca90354aa44 100644 --- a/server/sonar-web/design-system/src/components/input/index.ts +++ b/server/sonar-web/design-system/src/components/input/index.ts @@ -21,6 +21,7 @@ export * from './Checkbox'; export * from './DatePicker'; export * from './DateRangePicker'; export * from './DiscreetSelect'; +export * from './FileInput'; export * from './FormField'; export * from './InputField'; export * from './InputMultiSelect'; diff --git a/server/sonar-web/src/main/js/api/mocks/QualityProfilesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/QualityProfilesServiceMock.ts index 712447b19c8..8283e354334 100644 --- a/server/sonar-web/src/main/js/api/mocks/QualityProfilesServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/QualityProfilesServiceMock.ts @@ -305,7 +305,18 @@ export default class QualityProfilesServiceMock { } handleGetImporters = () => { - return this.reply([]); + return this.reply([ + { + key: 'sonar-importer-a', + name: 'Importer A', + languages: ['c'], + }, + { + key: 'sonar-importer-b', + name: 'Importer B', + languages: ['c'], + }, + ]); }; handleCopyProfile = (fromKey: string, name: string): Promise => { diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfilesApp-it.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfilesApp-it.tsx index ebef45448cb..4533c4883f3 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfilesApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfilesApp-it.tsx @@ -23,7 +23,7 @@ import selectEvent from 'react-select-event'; import QualityProfilesServiceMock from '../../../api/mocks/QualityProfilesServiceMock'; import { mockPaging, mockRule } from '../../../helpers/testMocks'; import { renderAppRoutes } from '../../../helpers/testReactTestingUtils'; -import { byRole } from '../../../helpers/testSelector'; +import { byRole, byText } from '../../../helpers/testSelector'; import routes from '../routes'; jest.mock('../../../api/quality-profiles'); @@ -93,14 +93,16 @@ const ui = { }), listLinkJavaQualityProfile: byRole('link', { name: 'java quality profile' }), returnToList: byRole('link', { name: 'quality_profiles.page' }), - languageSelect: byRole('combobox', { name: 'language field_required' }), + languageSelect: byRole('combobox', { name: 'language' }), profileExtendSelect: byRole('combobox', { - name: 'quality_profiles.creation.choose_parent_quality_profile field_required', + name: 'quality_profiles.creation.choose_parent_quality_profile', }), profileCopySelect: byRole('combobox', { - name: 'quality_profiles.creation.choose_copy_quality_profile field_required', + name: 'quality_profiles.creation.choose_copy_quality_profile', }), - nameCreatePopupInput: byRole('textbox', { name: 'name field_required' }), + nameCreatePopupInput: byRole('textbox', { name: 'name required' }), + importerA: byText('Importer A'), + importerB: byText('Importer B'), comparisonDiffTableHeading: (rulesQuantity: number, profileName: string) => byRole('columnheader', { name: `quality_profiles.x_rules_only_in.${rulesQuantity}.${profileName}`, @@ -253,6 +255,29 @@ describe('Create', () => { expect(await ui.headingNewCQualityProfile.find()).toBeInTheDocument(); }); + + it('should render importers', async () => { + const user = userEvent.setup(); + serviceMock.setAdmin(); + renderQualityProfiles(); + + await act(async () => { + await user.click(await ui.createButton.find()); + }); + await user.click(ui.blankRadio.get()); + await selectEvent.select(ui.languageSelect.get(), 'C'); + + expect(ui.importerA.get()).toBeInTheDocument(); + expect(ui.importerB.get()).toBeInTheDocument(); + + await user.click(ui.copyRadio.get()); + expect(ui.importerA.query()).not.toBeInTheDocument(); + expect(ui.importerB.query()).not.toBeInTheDocument(); + + await user.click(ui.extendRadio.get()); + expect(ui.importerA.query()).not.toBeInTheDocument(); + expect(ui.importerB.query()).not.toBeInTheDocument(); + }); }); it('should be able to restore a quality profile', async () => { diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/home/CreateProfileForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/home/CreateProfileForm.tsx index c9662824a2c..9309079a249 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/home/CreateProfileForm.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/home/CreateProfileForm.tsx @@ -17,27 +17,34 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { FlagMessage } from 'design-system'; +import { + ButtonPrimary, + FileInput, + FlagMessage, + FormField, + InputField, + LabelValueSelectOption, + LightLabel, + Modal, + Note, + PopupZLevel, + SearchSelectDropdown, + SelectionCard, + Spinner, +} from 'design-system'; import { sortBy } from 'lodash'; import * as React from 'react'; +import { useRef } from 'react'; +import { useIntl } from 'react-intl'; +import { SingleValue } from 'react-select'; import { changeProfileParent, copyProfile, createQualityProfile, getImporters, } from '../../../api/quality-profiles'; -import Modal from '../../../components/controls/Modal'; -import RadioCard from '../../../components/controls/RadioCard'; -import Select from '../../../components/controls/Select'; -import ValidationInput from '../../../components/controls/ValidationInput'; -import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import { Location } from '../../../components/hoc/withRouter'; -import CopyQualityProfileIcon from '../../../components/icons/CopyQualityProfileIcon'; -import ExtendQualityProfileIcon from '../../../components/icons/ExtendQualityProfileIcon'; -import NewQualityProfileIcon from '../../../components/icons/NewQualityProfileIcon'; import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation'; -import Spinner from '../../../components/ui/Spinner'; -import { translate } from '../../../helpers/l10n'; import { parseAsOptionalString } from '../../../helpers/query'; import { useProfileInheritanceQuery } from '../../../queries/quality-profiles'; import { Profile, ProfileActionModals } from '../types'; @@ -52,6 +59,9 @@ interface Props { export default function CreateProfileForm(props: Readonly) { const { languages, profiles, onCreate } = props; + + const intl = useIntl(); + const [importers, setImporters] = React.useState< Array<{ key: string; languages: string[]; name: string }> >([]); @@ -67,6 +77,8 @@ export default function CreateProfileForm(props: Readonly) { const [isValidProfile, setIsValidProfile] = React.useState(); const [profile, setProfile] = React.useState(); + const backupForm = useRef(null); + const fetchImporters = React.useCallback(async () => { setLoading(true); try { @@ -98,8 +110,8 @@ export default function CreateProfileForm(props: Readonly) { ); const handleLanguageChange = React.useCallback( - (option: { value: string }) => { - setLanguage(option.value); + (option: SingleValue>) => { + setLanguage(option?.value); setIsValidLanguage(true); setProfile(undefined); setIsValidProfile(false); @@ -108,43 +120,40 @@ export default function CreateProfileForm(props: Readonly) { ); const handleQualityProfileChange = React.useCallback( - (option: { value: Profile } | null) => { + (option: SingleValue>) => { setProfile(option?.value); - setIsValidProfile(option !== null); + setIsValidProfile(Boolean(option?.value)); }, [setProfile, setIsValidProfile], ); - const handleFormSubmit = React.useCallback( - async (event: React.SyntheticEvent) => { - event.preventDefault(); - - setSubmitting(true); - const profileKey = profile?.key; - try { - if (action === ProfileActionModals.Copy && profileKey && name) { - const profile = await copyProfile(profileKey, name); - onCreate(profile); - } else if (action === ProfileActionModals.Extend) { - const { profile } = await createQualityProfile({ language, name }); - - const parentProfile = profiles.find((p) => p.key === profileKey); - if (parentProfile) { - await changeProfileParent(profile, parentProfile); - } + const handleFormSubmit = React.useCallback(async () => { + setSubmitting(true); + const profileKey = profile?.key; + try { + if (action === ProfileActionModals.Copy && profileKey && name) { + const profile = await copyProfile(profileKey, name); + onCreate(profile); + } else if (action === ProfileActionModals.Extend) { + const { profile } = await createQualityProfile({ language, name }); - onCreate(profile); - } else { - const data = new FormData(event.currentTarget); - const { profile } = await createQualityProfile(data); - onCreate(profile); + const parentProfile = profiles.find((p) => p.key === profileKey); + if (parentProfile) { + await changeProfileParent(profile, parentProfile); } - } finally { - setSubmitting(false); + + onCreate(profile); + } else { + const formData = new FormData(backupForm?.current ? backupForm.current : undefined); + formData.set('language', language ?? ''); + formData.set('name', name); + const { profile } = await createQualityProfile(formData); + onCreate(profile); } - }, - [setSubmitting, onCreate, profiles, action, language, name, profile], - ); + } finally { + setSubmitting(false); + } + }, [setSubmitting, onCreate, profiles, action, language, name, profile]); React.useEffect(() => { fetchImporters(); @@ -175,7 +184,7 @@ export default function CreateProfileForm(props: Readonly) { const canSubmit = (action === undefined && isValidName && isValidLanguage) || (action !== undefined && isValidLanguage && isValidName && isValidProfile); - const header = translate('quality_profiles.new_profile'); + const header = intl.formatMessage({ id: 'quality_profiles.new_profile' }); const languageQueryFilter = parseAsOptionalString(props.location.query.language); const selectedLanguage = language ?? languageQueryFilter; @@ -186,7 +195,7 @@ export default function CreateProfileForm(props: Readonly) { const profilesForSelectedLanguage = profiles.filter((p) => p.language === selectedLanguage); const profileOptions = sortBy(profilesForSelectedLanguage, 'name').map((profile) => ({ label: profile.isBuiltIn - ? `${profile.name} (${translate('quality_profiles.built_in')})` + ? `${profile.name} (${intl.formatMessage({ id: 'quality_profiles.built_in' })})` : profile.name, value: profile, })); @@ -196,179 +205,162 @@ export default function CreateProfileForm(props: Readonly) { value: l.key, })); - return ( - -
-
-

{header}

-
+ function handleSearch( + options: { label: string; value: T }[], + query: string, + cb: (options: LabelValueSelectOption[]) => void, + ) { + cb(options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase()))); + } - {loading ? ( -
- + return ( + + {intl.formatMessage({ id: 'create' })} + + ) + } + secondaryButtonLabel={intl.formatMessage({ id: 'cancel' })} + body={ + <> + + {intl.formatMessage({ id: 'quality_profiles.chose_creation_type' })} + +
+ +

+ {intl.formatMessage({ id: 'quality_profiles.creation_from_extend_description_1' })} +

+

+ {intl.formatMessage({ id: 'quality_profiles.creation_from_extend_description_2' })} +

+
+ +

+ {intl.formatMessage({ id: 'quality_profiles.creation_from_copy_description_1' })} +

+

+ {intl.formatMessage({ id: 'quality_profiles.creation_from_copy_description_2' })} +

+
+ + {intl.formatMessage({ id: 'quality_profiles.creation_from_blank_description' })} +
- ) : ( -
-
- -
- } - > -

- {translate('quality_profiles.creation_from_extend')} -

-

- {translate('quality_profiles.creation_from_extend_description_1')} -

-

{translate('quality_profiles.creation_from_extend_description_2')}

-
- } - > -

- {translate('quality_profiles.creation_from_copy')} -

-

- {translate('quality_profiles.creation_from_copy_description_1')} -

-

{translate('quality_profiles.creation_from_copy_description_2')}

-
- } - > -

- {translate('quality_profiles.creation_from_blank')} -

-

{translate('quality_profiles.creation_from_blank_description')}

-
+ {!isLoading && showBuiltInWarning && ( + +
+ {intl.formatMessage({ + id: 'quality_profiles.no_built_in_updates_warning.new_profile', + })} + + {intl.formatMessage({ + id: 'quality_profiles.no_built_in_updates_warning.new_profile.2', + })} +
-
- - {!isLoading && showBuiltInWarning && ( - -
- {translate('quality_profiles.no_built_in_updates_warning.new_profile')} - - {translate('quality_profiles.no_built_in_updates_warning.new_profile.2')} - -
-
- )} - - - - - o.value === profile)} - /> - - )} - + )} + + - - + size="full" + type="text" + value={name} + /> + - {action === undefined && - filteredImporters.map((importer) => ( -
+ {filteredImporters.map((importer) => ( + - - -

- {translate('quality_profiles.optional_configuration_file')} -

-
- ))} -
- )} - -
- {(submitting || isLoading) && } - {!loading && ( - - {translate('create')} - + + {intl.formatMessage({ id: 'quality_profiles.optional_configuration_file' })} + + + ))}{' '} + )} - - {translate('cancel')} - -
- -
+ + + + } + /> ); } 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 a83a184f419..4941aab5cd1 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -40,8 +40,10 @@ see_changelog=See Changelog changelog=Changelog change_verb=Change check_all=Check all +choose_file=Choose file class=Class classes=Classes +clear_file=Clear file close=Close closed=Closed code=Code @@ -146,6 +148,7 @@ next=Next new_name=New name next_=next none=None +no_file_selected=No file selected no_tags=No tags not_now=Not now or=Or -- 2.39.5