Browse Source

SONAR-20356 Add message in QP copy, extend and create modal to warn user when a QP will not inherit from a built-in

tags/10.3.0.82913
7PH 9 months ago
parent
commit
b2e75be285

+ 4
- 1
server/sonar-web/src/main/js/api/quality-profiles.ts View File

@@ -103,7 +103,10 @@ export function getProfileProjects(
return getJSON('/api/qualityprofiles/projects', data).catch(throwGlobalError);
}

export function getProfileInheritance({ language, name: qualityProfile }: Profile): Promise<{
export function getProfileInheritance({
language,
name: qualityProfile,
}: Pick<Profile, 'language' | 'name'>): Promise<{
ancestors: ProfileInheritanceDetails[];
children: ProfileInheritanceDetails[];
profile: ProfileInheritanceDetails;

+ 69
- 9
server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfileApp-it.tsx View File

@@ -18,7 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

import { act, screen } from '@testing-library/react';
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import selectEvent from 'react-select-event';
import QualityProfilesServiceMock from '../../../api/mocks/QualityProfilesServiceMock';
@@ -35,6 +35,7 @@ beforeEach(() => {

const serviceMock = new QualityProfilesServiceMock();
const ui = {
loading: byRole('status', { name: 'loading' }),
permissionSection: byRole('region', { name: 'permissions.page' }),
projectSection: byRole('region', { name: 'projects' }),
rulesSection: byRole('region', { name: 'rules' }),
@@ -80,6 +81,12 @@ const ui = {
rulesMissingSonarWayLink: byRole('link', { name: '2' }),
rulesDeprecatedWarning: byText('quality_profiles.deprecated_rules_description'),
rulesDeprecatedLink: byRole('link', { name: '8' }),

waitForDataLoaded: async () => {
await waitFor(() => {
expect(ui.loading.query()).not.toBeInTheDocument();
});
},
};

describe('Admin or user with permission', () => {
@@ -91,6 +98,7 @@ describe('Admin or user with permission', () => {
it('should be able to grant permission to a user and remove it', async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.permissionSection.find()).toBeInTheDocument();

@@ -117,6 +125,7 @@ describe('Admin or user with permission', () => {
it('should be able to grant permission to a group and remove it', async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.permissionSection.find()).toBeInTheDocument();

@@ -142,6 +151,7 @@ describe('Admin or user with permission', () => {

it('should not be able to grant permission if the profile is built-in', async () => {
renderQualityProfile('sonar');
await ui.waitForDataLoaded();
expect(await screen.findByText('Sonar way')).toBeInTheDocument();
expect(ui.permissionSection.query()).not.toBeInTheDocument();
});
@@ -151,14 +161,20 @@ describe('Admin or user with permission', () => {
it('should be able to add a project to Quality Profile with active rules', async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.projectSection.find()).toBeInTheDocument();

expect(ui.projectSection.byText('Twitter').query()).not.toBeInTheDocument();
await user.click(ui.changeProjectsButton.get());
await act(async () => {
await user.click(ui.changeProjectsButton.get());
});
expect(ui.dialog.get()).toBeInTheDocument();

await user.click(ui.withoutFilterButton.get());
await user.click(ui.twitterCheckbox.get());
await act(async () => {
await user.click(ui.withoutFilterButton.get());
await user.click(ui.twitterCheckbox.get());
});
await user.click(ui.closeButton.get());
expect(ui.projectSection.byText('Twitter').get()).toBeInTheDocument();
});
@@ -166,27 +182,38 @@ describe('Admin or user with permission', () => {
it('should be able to remove a project from a Quality Profile with active rules', async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.projectSection.find()).toBeInTheDocument();

expect(ui.projectSection.byText('Benflix').get()).toBeInTheDocument();
await user.click(ui.changeProjectsButton.get());
await act(async () => {
await user.click(ui.changeProjectsButton.get());
});
expect(ui.dialog.get()).toBeInTheDocument();

await user.click(ui.benflixCheckbox.get());
await act(async () => {
await user.click(ui.benflixCheckbox.get());
});
await user.click(ui.closeButton.get());
expect(ui.projectSection.byText('Benflix').query()).not.toBeInTheDocument();
});

it('should not be able to change project for Quality Profile with no active rules', async () => {
renderQualityProfile('no-rule-qp');
await ui.waitForDataLoaded();

expect(await ui.projectSection.find()).toBeInTheDocument();

expect(ui.changeProjectsButton.get()).toHaveAttribute('disabled');
});

it('should not be able to change projects for default profiles', async () => {
renderQualityProfile('sonar');
await ui.waitForDataLoaded();

expect(await ui.projectSection.find()).toBeInTheDocument();

expect(
await ui.projectSection.byText('quality_profiles.projects_for_default').get(),
).toBeInTheDocument();
@@ -196,6 +223,7 @@ describe('Admin or user with permission', () => {
describe('Rules', () => {
it('should be able to activate more rules', async () => {
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.rulesSection.find()).toBeInTheDocument();

@@ -208,6 +236,7 @@ describe('Admin or user with permission', () => {

it("shouldn't be able to activate more rules for built in Quality Profile", async () => {
renderQualityProfile('sonar');
await ui.waitForDataLoaded();
expect(await ui.rulesSection.find()).toBeInTheDocument();
expect(ui.activateMoreRulesButton.get()).toBeInTheDocument();
expect(ui.activateMoreRulesButton.get()).toBeDisabled();
@@ -218,21 +247,29 @@ describe('Admin or user with permission', () => {
it("should be able to change a quality profile's parents", async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.inheritanceSection.find()).toBeInTheDocument();

// Parents
expect(ui.inheritanceSection.byText('PHP Sonar way 1').get()).toBeInTheDocument();
expect(ui.inheritanceSection.byText('PHP Sonar way 2').query()).not.toBeInTheDocument();
// Children
expect(ui.inheritanceSection.byText('PHP way').get()).toBeInTheDocument();

await user.click(ui.changeParentButton.get());
await act(async () => {
await user.click(ui.changeParentButton.get());
});
expect(await ui.dialog.find()).toBeInTheDocument();
expect(ui.changeButton.get()).toBeDisabled();
await selectEvent.select(ui.selectField.get(), 'PHP Sonar way 2');
await user.click(ui.changeButton.get());
await act(async () => {
await user.click(ui.changeButton.get());
});
expect(ui.dialog.query()).not.toBeInTheDocument();

await ui.waitForDataLoaded();

// Parents
expect(ui.inheritanceSection.byText('PHP Sonar way 2').get()).toBeInTheDocument();
expect(ui.inheritanceSection.byText('PHP Sonar way 1').query()).not.toBeInTheDocument();
@@ -242,6 +279,7 @@ describe('Admin or user with permission', () => {

it("should not be able to change a Built-in quality profile's parents", async () => {
renderQualityProfile('php-sonar-way-1');
await ui.waitForDataLoaded();

expect(await ui.inheritanceSection.find()).toBeInTheDocument();
expect(ui.changeParentButton.query()).not.toBeInTheDocument();
@@ -252,6 +290,7 @@ describe('Admin or user with permission', () => {
it('should be able to activate more rules', async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

await user.click(await ui.qualityProfileActions.find());
expect(ui.activateMoreRulesLink.get()).toBeInTheDocument();
@@ -264,6 +303,7 @@ describe('Admin or user with permission', () => {
it('should be able to extend a quality profile', async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await screen.findByText('Good old PHP quality profile')).toBeInTheDocument();

@@ -281,6 +321,8 @@ describe('Admin or user with permission', () => {

expect(ui.dialog.query()).not.toBeInTheDocument();

await ui.waitForDataLoaded();

expect(screen.getAllByText('Bad new PHP quality profile')).toHaveLength(2);
expect(screen.getByText('Good old PHP quality profile')).toBeInTheDocument();
});
@@ -288,6 +330,7 @@ describe('Admin or user with permission', () => {
it('should be able to copy a quality profile', async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

await user.click(await ui.qualityProfileActions.find());
await user.click(ui.copyButton.get());
@@ -303,12 +346,14 @@ describe('Admin or user with permission', () => {

expect(ui.dialog.query()).not.toBeInTheDocument();

expect(screen.getAllByText('Good old PHP quality profile copy')).toHaveLength(2);
await ui.waitForDataLoaded();
expect(await screen.findAllByText('Good old PHP quality profile copy')).toHaveLength(2);
});

it('should be able to rename a quality profile', async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

await user.click(await ui.qualityProfileActions.find());
await user.click(ui.renameButton.get());
@@ -324,12 +369,14 @@ describe('Admin or user with permission', () => {

expect(ui.dialog.query()).not.toBeInTheDocument();

await ui.waitForDataLoaded();
expect(screen.getAllByText('Fossil PHP quality profile')).toHaveLength(2);
});

it('should be able to set a quality profile as default', async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

await user.click(await ui.qualityProfileActions.find());
await user.click(ui.setAsDefaultButton.get());
@@ -340,6 +387,7 @@ describe('Admin or user with permission', () => {
it('should NOT be able to set a quality profile as default if it has no active rules', async () => {
const user = userEvent.setup();
renderQualityProfile('no-rule-qp');
await ui.waitForDataLoaded();

await user.click(await ui.qualityProfileActions.find());
expect(ui.setAsDefaultButton.query()).not.toBeInTheDocument();
@@ -348,6 +396,7 @@ describe('Admin or user with permission', () => {
it("should be able to delete a Quality Profile and it's children", async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

await user.click(await ui.qualityProfileActions.find());
await user.click(ui.deleteQualityProfileButton.get());
@@ -373,6 +422,7 @@ describe('Admin or user with permission', () => {
describe('Users with no permission', () => {
it('should not be able to activate more rules', async () => {
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.rulesSection.find()).toBeInTheDocument();
expect(ui.activateMoreLink.query()).not.toBeInTheDocument();
@@ -386,6 +436,7 @@ describe('Users with no permission', () => {

it("should not be able to change a quality profile's parents", async () => {
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.inheritanceSection.find()).toBeInTheDocument();
expect(ui.inheritanceSection.byText('PHP Sonar way 1').get()).toBeInTheDocument();
@@ -396,6 +447,7 @@ describe('Users with no permission', () => {

it('should not be able to change projects for Quality Profile', async () => {
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.projectSection.find()).toBeInTheDocument();
expect(ui.changeProjectsButton.query()).not.toBeInTheDocument();
@@ -405,6 +457,7 @@ describe('Users with no permission', () => {
describe('Every Users', () => {
it('should be able to see active/inactive rules for a Quality Profile', async () => {
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.rulesSection.find()).toBeInTheDocument();

@@ -425,6 +478,7 @@ describe('Every Users', () => {

it('should be able to see a warning when some rules are missing compare to Sonar way', async () => {
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.rulesMissingSonarWayWarning.findAll()).toHaveLength(2);
expect(ui.rulesMissingSonarWayLink.get()).toBeInTheDocument();
@@ -436,6 +490,7 @@ describe('Every Users', () => {

it('should be able to see a warning when some rules are deprecated', async () => {
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.rulesDeprecatedWarning.findAll()).toHaveLength(1);
expect(ui.rulesDeprecatedLink.get()).toBeInTheDocument();
@@ -447,6 +502,7 @@ describe('Every Users', () => {

it('should be able to see exporters links when there are exporters for the language', async () => {
renderQualityProfile();
await ui.waitForDataLoaded();

expect(await ui.exportersSection.find()).toBeInTheDocument();
expect(ui.exportersSection.byText('SonarLint for Visual Studio').get()).toBeInTheDocument();
@@ -455,6 +511,7 @@ describe('Every Users', () => {

it('should be informed when the quality profile has not been found', async () => {
renderQualityProfile('i-dont-exist');
await ui.waitForDataLoaded();

expect(await screen.findByText('quality_profiles.not_found')).toBeInTheDocument();
expect(ui.qualityProfilePageLink.get()).toBeInTheDocument();
@@ -463,6 +520,7 @@ describe('Every Users', () => {
it('should be able to backup quality profile', async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

await user.click(await ui.qualityProfileActions.find());
expect(ui.backUpLink.get()).toHaveAttribute(
@@ -475,6 +533,7 @@ describe('Every Users', () => {
it('should not be able to backup a built-in quality profile', async () => {
const user = userEvent.setup();
renderQualityProfile('sonar');
await ui.waitForDataLoaded();

await user.click(await ui.qualityProfileActions.find());
expect(ui.backUpLink.query()).not.toBeInTheDocument();
@@ -483,6 +542,7 @@ describe('Every Users', () => {
it('should be able to compare quality profile', async () => {
const user = userEvent.setup();
renderQualityProfile();
await ui.waitForDataLoaded();

await user.click(await ui.qualityProfileActions.find());


+ 17
- 8
server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfilesApp-it.tsx View File

@@ -73,6 +73,9 @@ const ui = {
copyRadio: byRole('radio', {
name: 'quality_profiles.creation_from_copy quality_profiles.creation_from_copy_description_1 quality_profiles.creation_from_copy_description_2',
}),
extendRadio: byRole('radio', {
name: 'quality_profiles.creation_from_extend quality_profiles.creation_from_extend_description_1 quality_profiles.creation_from_extend_description_2',
}),
blankRadio: byRole('radio', {
name: 'quality_profiles.creation_from_blank quality_profiles.creation_from_blank_description',
}),
@@ -125,13 +128,11 @@ it('should list Quality Profiles and filter by language', async () => {
expect(ui.listLinkJavaQualityProfile.query()).not.toBeInTheDocument();

// Creation form should have language pre-selected
await user.click(await ui.createButton.find());
await act(async () => {
await user.click(await ui.createButton.find());
});
// eslint-disable-next-line testing-library/prefer-screen-queries
expect(getByText(ui.popup.get(), 'C')).toBeInTheDocument();
await selectEvent.select(ui.profileExtendSelect.get(), ui.cQualityProfileName);
await user.type(ui.nameCreatePopupInput.get(), ui.newCQualityProfileName);

expect(ui.createButton.get(ui.popup.get())).toBeEnabled();
});

describe('Evolution', () => {
@@ -191,7 +192,10 @@ describe('Create', () => {
expect(await ui.headingNewCQualityProfile.find()).toBeInTheDocument();

await user.click(ui.returnToList.get());
await user.click(ui.createButton.get());
await act(async () => {
await user.click(ui.createButton.get());
await user.click(ui.extendRadio.get());
});
await selectEvent.select(ui.languageSelect.get(), 'C');
await selectEvent.select(ui.profileExtendSelect.get(), ui.newCQualityProfileName);
await user.type(ui.nameCreatePopupInput.get(), ui.newCQualityProfileNameFromCreateButton);
@@ -218,7 +222,9 @@ describe('Create', () => {
expect(await ui.headingNewCQualityProfile.find()).toBeInTheDocument();

await user.click(ui.returnToList.get());
await user.click(ui.createButton.get());
await act(async () => {
await user.click(ui.createButton.get());
});
await user.click(ui.copyRadio.get());
await selectEvent.select(ui.languageSelect.get(), 'C');
await selectEvent.select(ui.profileCopySelect.get(), ui.newCQualityProfileName);
@@ -234,7 +240,10 @@ describe('Create', () => {
const user = userEvent.setup();
serviceMock.setAdmin();
renderQualityProfiles();
await user.click(await ui.createButton.find());

await act(async () => {
await user.click(await ui.createButton.find());
});
await user.click(ui.blankRadio.get());
await selectEvent.select(ui.languageSelect.get(), 'C');
await user.type(ui.nameCreatePopupInput.get(), ui.newCQualityProfileName);

+ 20
- 1
server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileModalForm.tsx View File

@@ -17,10 +17,11 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { ButtonPrimary, FormField, InputField, Modal } from 'design-system';
import { ButtonPrimary, FlagMessage, FormField, InputField, Modal } from 'design-system';
import * as React from 'react';
import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { useProfileInheritanceQuery } from '../../../queries/quality-profiles';
import { Dict } from '../../../types/types';
import { Profile, ProfileActionModals } from '../types';

@@ -45,6 +46,13 @@ export default function ProfileModalForm(props: ProfileModalFormProps) {
const submitDisabled = loading || !name || name === profile.name;
const labels = LABELS_FOR_ACTION[action];

const { data: { ancestors } = {} } = useProfileInheritanceQuery(props.profile);

const extendsBuiltIn = ancestors?.some((profile) => profile.isBuiltIn);
const showBuiltInWarning =
(action === ProfileActionModals.Copy && !extendsBuiltIn) ||
(action === ProfileActionModals.Extend && !profile.isBuiltIn && !extendsBuiltIn);

return (
<Modal
headerTitle={translateWithParameters(labels.header, profile.name, profile.languageName)}
@@ -52,6 +60,17 @@ export default function ProfileModalForm(props: ProfileModalFormProps) {
loading={loading}
body={
<>
{showBuiltInWarning && (
<FlagMessage variant="info" className="sw-mb-4">
<div className="sw-flex sw-flex-col">
{translate('quality_profiles.no_built_in_updates_warning.new_profile')}
<span className="sw-mt-2">
{translate('quality_profiles.no_built_in_updates_warning.new_profile.2')}
</span>
</div>
</FlagMessage>
)}

{action === ProfileActionModals.Copy && (
<p className="sw-mb-8">
{translateWithParameters('quality_profiles.copy_help', profile.name)}

+ 6
- 3
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileHeader.tsx View File

@@ -24,13 +24,16 @@ import { useLocation } from '../../../components/hoc/withRouter';
import DateFromNow from '../../../components/intl/DateFromNow';
import { AdminPageHeader } from '../../../components/ui/AdminPageHeader';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { getQualityProfileUrl } from '../../../helpers/urls';
import BuiltInQualityProfileBadge from '../components/BuiltInQualityProfileBadge';
import ProfileActions from '../components/ProfileActions';
import { PROFILE_PATH } from '../constants';
import { QualityProfilePath } from '../routes';
import { Profile } from '../types';
import { getProfileChangelogPath, isProfileComparePath } from '../utils';
import {
getProfileChangelogPath,
getProfilesForLanguagePath,
isProfileComparePath,
} from '../utils';

interface Props {
profile: Profile;
@@ -60,7 +63,7 @@ export default function ProfileHeader(props: Props) {

<Breadcrumbs className="sw-mb-6">
<HoverLink to={PROFILE_PATH}>{translate('quality_profiles.page')}</HoverLink>
<HoverLink to={getQualityProfileUrl(profile.name, profile.language)}>
<HoverLink to={getProfilesForLanguagePath(profile.language)}>
{profile.languageName}
</HoverLink>
</Breadcrumbs>

+ 104
- 157
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfileInheritance.tsx View File

@@ -20,9 +20,8 @@
import classNames from 'classnames';
import { ButtonSecondary, FlagMessage, Spinner, SubTitle, Table } from 'design-system';
import * as React from 'react';
import { getProfileInheritance } from '../../../api/quality-profiles';
import { translate } from '../../../helpers/l10n';
import { ProfileInheritanceDetails } from '../../../types/types';
import { useProfileInheritanceQuery } from '../../../queries/quality-profiles';
import { Profile } from '../types';
import ChangeParentForm from './ChangeParentForm';
import ProfileInheritanceBox from './ProfileInheritanceBox';
@@ -33,167 +32,115 @@ interface Props {
updateProfiles: () => Promise<void>;
}

interface State {
ancestors?: ProfileInheritanceDetails[];
children?: ProfileInheritanceDetails[];
formOpen: boolean;
loading: boolean;
profile?: ProfileInheritanceDetails;
}
export default function ProfileInheritance(props: Props) {
const { profile, profiles, updateProfiles } = props;
const [formOpen, setFormOpen] = React.useState(false);

export default class ProfileInheritance extends React.PureComponent<Props, State> {
mounted = false;
const { data: { ancestors, children, profile: profileInheritanceDetail } = {}, isLoading } =
useProfileInheritanceQuery(profile);

state: State = {
formOpen: false,
loading: true,
};
const handleChangeParentClick = React.useCallback(() => {
setFormOpen(true);
}, [setFormOpen]);

componentDidMount() {
this.mounted = true;
this.loadData();
}
const closeForm = React.useCallback(() => {
setFormOpen(false);
}, [setFormOpen]);

componentDidUpdate(prevProps: Props) {
if (prevProps.profile.key !== this.props.profile.key) {
this.loadData();
const handleParentChange = React.useCallback(async () => {
try {
await updateProfiles();
} catch (error) {
// ignore
} finally {
closeForm();
}
}

componentWillUnmount() {
this.mounted = false;
}

loadData() {
getProfileInheritance(this.props.profile).then(
(r) => {
if (this.mounted) {
const { ancestors, children } = r;
ancestors.reverse();

this.setState({
children,
ancestors,
profile: r.profile,
loading: false,
});
}
},
() => {
if (this.mounted) {
this.setState({ loading: false });
}
},
);
}

handleChangeParentClick = () => {
this.setState({ formOpen: true });
};

closeForm = () => {
this.setState({ formOpen: false });
};

handleParentChange = () => {
this.props.updateProfiles().then(
() => {
this.loadData();
},
() => {},
);
this.closeForm();
};

render() {
const { profile, profiles } = this.props;
const { ancestors, loading, formOpen, children } = this.state;

const highlightCurrent =
!this.state.loading &&
ancestors != null &&
this.state.children != null &&
(ancestors.length > 0 || this.state.children.length > 0);

const extendsBuiltIn = ancestors?.some((profile) => profile.isBuiltIn);

return (
<section
aria-label={translate('quality_profiles.profile_inheritance')}
className="it__quality-profiles__inheritance"
>
<div className="sw-flex sw-items-center sw-gap-3 sw-mb-6">
<SubTitle className="sw-mb-0">
{translate('quality_profiles.profile_inheritance')}
</SubTitle>
{profile.actions?.edit && !profile.isBuiltIn && (
<ButtonSecondary
className="it__quality-profiles__change-parent"
onClick={this.handleChangeParentClick}
>
{translate('quality_profiles.change_parent')}
</ButtonSecondary>
)}
</div>

{!extendsBuiltIn && (
<FlagMessage variant="info" className="sw-mb-4">
<div className="sw-flex sw-flex-col">
{translate('quality_profiles.no_built_in_updates_warning')}
{profile.actions?.edit && (
<span className="sw-mt-1">
{translate('quality_profiles.no_built_in_updates_warning_admin')}
</span>
)}
</div>
</FlagMessage>
}, [closeForm, updateProfiles]);

const highlightCurrent =
!isLoading &&
ancestors != null &&
children != null &&
(ancestors.length > 0 || children.length > 0);

const extendsBuiltIn = ancestors?.some((p) => p.isBuiltIn);

return (
<section
aria-label={translate('quality_profiles.profile_inheritance')}
className="it__quality-profiles__inheritance"
>
<div className="sw-flex sw-items-center sw-gap-3 sw-mb-6">
<SubTitle className="sw-mb-0">{translate('quality_profiles.profile_inheritance')}</SubTitle>
{profile.actions?.edit && !profile.isBuiltIn && (
<ButtonSecondary
className="it__quality-profiles__change-parent"
onClick={handleChangeParentClick}
>
{translate('quality_profiles.change_parent')}
</ButtonSecondary>
)}

<Spinner loading={loading}>
<Table columnCount={3} noSidePadding>
{ancestors?.map((ancestor, index) => (
<ProfileInheritanceBox
depth={index}
key={ancestor.key}
language={profile.language}
profile={ancestor}
type="ancestor"
/>
))}

{this.state.profile && (
<ProfileInheritanceBox
className={classNames({
selected: highlightCurrent,
})}
depth={ancestors ? ancestors.length : 0}
displayLink={false}
language={profile.language}
profile={this.state.profile}
/>
</div>

{!extendsBuiltIn && !profile.isBuiltIn && (
<FlagMessage variant="info" className="sw-mb-4">
<div className="sw-flex sw-flex-col">
{translate('quality_profiles.no_built_in_updates_warning')}
{profile.actions?.edit && (
<span className="sw-mt-1">
{translate('quality_profiles.no_built_in_updates_warning_admin')}
</span>
)}
</div>
</FlagMessage>
)}

<Spinner loading={isLoading}>
<Table columnCount={3} noSidePadding>
{ancestors?.map((ancestor, index) => (
<ProfileInheritanceBox
depth={index}
key={ancestor.key}
language={profile.language}
profile={ancestor}
type="ancestor"
/>
))}

{profileInheritanceDetail && (
<ProfileInheritanceBox
className={classNames({
selected: highlightCurrent,
})}
depth={ancestors ? ancestors.length : 0}
displayLink={false}
language={profile.language}
profile={profileInheritanceDetail}
/>
)}

{children?.map((child) => (
<ProfileInheritanceBox
depth={ancestors ? ancestors.length + 1 : 0}
key={child.key}
language={profile.language}
profile={child}
type="child"
/>
))}
</Table>
</Spinner>

{formOpen && (
<ChangeParentForm
onChange={this.handleParentChange}
onClose={this.closeForm}
profile={profile}
profiles={profiles.filter((p) => p !== profile && p.language === profile.language)}
/>
)}
</section>
);
}
{children?.map((child) => (
<ProfileInheritanceBox
depth={ancestors ? ancestors.length + 1 : 0}
key={child.key}
language={profile.language}
profile={child}
type="child"
/>
))}
</Table>
</Spinner>

{formOpen && (
<ChangeParentForm
onChange={handleParentChange}
onClose={closeForm}
profile={profile}
profiles={profiles.filter(
(p) => p !== profileInheritanceDetail && p.language === profile.language,
)}
/>
)}
</section>
);
}

+ 282
- 267
server/sonar-web/src/main/js/apps/quality-profiles/home/CreateProfileForm.tsx View File

@@ -17,6 +17,7 @@
* 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 { sortBy } from 'lodash';
import * as React from 'react';
import {
@@ -35,8 +36,10 @@ import CopyQualityProfileIcon from '../../../components/icons/CopyQualityProfile
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';

interface Props {
@@ -47,313 +50,325 @@ interface Props {
profiles: Profile[];
}

interface State {
importers: Array<{ key: string; languages: Array<string>; name: string }>;
action?: ProfileActionModals.Copy | ProfileActionModals.Extend;
language?: string;
loading: boolean;
name: string;
profile?: string;
preloading: boolean;
isValidName?: boolean;
isValidProflie?: boolean;
isValidLanguage?: boolean;
}

export default class CreateProfileForm extends React.PureComponent<Props, State> {
mounted = false;
state: State = {
importers: [],
loading: false,
name: '',
preloading: true,
action: ProfileActionModals.Extend,
};
export default function CreateProfileForm(props: Props) {
const { languages, profiles, onCreate } = props;
const [importers, setImporters] = React.useState<
Array<{ key: string; languages: string[]; name: string }>
>([]);
const [action, setAction] = React.useState<
ProfileActionModals.Copy | ProfileActionModals.Extend | undefined
>();
const [submitting, setSubmitting] = React.useState(false);
const [name, setName] = React.useState('');
const [loading, setLoading] = React.useState(true);
const [language, setLanguage] = React.useState<string>();
const [isValidLanguage, setIsValidLanguage] = React.useState<boolean>();
const [isValidName, setIsValidName] = React.useState<boolean>();
const [isValidProfile, setIsValidProfile] = React.useState<boolean>();
const [profile, setProfile] = React.useState<Profile>();

componentDidMount() {
this.mounted = true;
this.fetchImporters();
const languageQueryFilter = parseAsOptionalString(this.props.location.query.language);
if (languageQueryFilter !== undefined) {
this.setState({ language: languageQueryFilter, isValidLanguage: true });
const fetchImporters = React.useCallback(async () => {
setLoading(true);
try {
const importers = await getImporters();
setImporters(importers);
} finally {
setLoading(false);
}
}

componentWillUnmount() {
this.mounted = false;
}
}, [setImporters, setLoading]);

fetchImporters() {
getImporters().then(
(importers) => {
if (this.mounted) {
this.setState({ importers, preloading: false });
}
},
() => {
if (this.mounted) {
this.setState({ preloading: false });
}
},
);
function handleSelectExtend() {
setAction(ProfileActionModals.Extend);
}

handleSelectExtend = () => {
this.setState({ action: ProfileActionModals.Extend });
};
const handleSelectCopy = React.useCallback(() => {
setAction(ProfileActionModals.Copy);
}, [setAction]);

handleSelectCopy = () => {
this.setState({ action: ProfileActionModals.Copy });
};
const handleSelectBlank = React.useCallback(() => {
setAction(undefined);
}, [setAction]);

handleSelectBlank = () => {
this.setState({ action: undefined });
};
const handleNameChange = React.useCallback(
(event: React.SyntheticEvent<HTMLInputElement>) => {
setName(event.currentTarget.value);
setIsValidName(event.currentTarget.value.length > 0);
},
[setName, setIsValidName],
);

handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
this.setState({
name: event.currentTarget.value,
isValidName: event.currentTarget.value.length > 0,
});
};
const handleLanguageChange = React.useCallback(
(option: { value: string }) => {
setLanguage(option.value);
setIsValidLanguage(true);
setProfile(undefined);
setIsValidProfile(false);
},
[setLanguage, setIsValidLanguage],
);

handleLanguageChange = (option: { value: string }) => {
this.setState({ language: option.value, isValidLanguage: true });
};
const handleQualityProfileChange = React.useCallback(
(option: { value: Profile } | null) => {
setProfile(option?.value);
setIsValidProfile(option !== null);
},
[setProfile, setIsValidProfile],
);

handleQualityProfileChange = (option: { value: string } | null) => {
this.setState({ profile: option ? option.value : undefined, isValidProflie: option !== null });
};
const handleFormSubmit = React.useCallback(
async (event: React.SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();

handleFormSubmit = async (event: React.SyntheticEvent<HTMLFormElement>) => {
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 });

this.setState({ loading: true });
const { action, language, name, profile: parent } = this.state;
const parentProfile = profiles.find((p) => p.key === profileKey);
if (parentProfile) {
await changeProfileParent(profile, parentProfile);
}

try {
if (action === ProfileActionModals.Copy && parent && name) {
const profile = await copyProfile(parent, name);
this.props.onCreate(profile);
} else if (action === ProfileActionModals.Extend) {
const { profile } = await createQualityProfile({ language, name });

const parentProfile = this.props.profiles.find((p) => p.key === parent);
if (parentProfile) {
await changeProfileParent(profile, parentProfile);
onCreate(profile);
} else {
const data = new FormData(event.currentTarget);
const { profile } = await createQualityProfile(data);
onCreate(profile);
}

this.props.onCreate(profile);
} else {
const data = new FormData(event.currentTarget);
const { profile } = await createQualityProfile(data);
this.props.onCreate(profile);
}
} finally {
if (this.mounted) {
this.setState({ loading: false });
} finally {
setSubmitting(false);
}
}
};
},
[setSubmitting, onCreate, profiles, action, language, name, profile],
);

canSubmit() {
const { action, isValidName, isValidProflie, isValidLanguage } = this.state;
React.useEffect(() => {
fetchImporters();
const languageQueryFilter = parseAsOptionalString(props.location.query.language);
if (languageQueryFilter !== undefined) {
setLanguage(languageQueryFilter);
setIsValidLanguage(true);
}
}, [fetchImporters, props.location.query.language]);

return (
(action === undefined && isValidName && isValidLanguage) ||
(action !== undefined && isValidLanguage && isValidName && isValidProflie)
);
}
const { data: { ancestors } = {}, isLoading } = useProfileInheritanceQuery(
action === undefined || language === undefined || profile === undefined
? undefined
: {
language,
name: profile.name,
},
);

render() {
const header = translate('quality_profiles.new_profile');
const { action, isValidName, isValidProflie, isValidLanguage } = this.state;
const languageQueryFilter = parseAsOptionalString(this.props.location.query.language);
const languages = sortBy(this.props.languages, 'name');
const extendsBuiltIn = ancestors?.some((p) => p.isBuiltIn);
const showBuiltInWarning =
action === undefined ||
(action === ProfileActionModals.Copy && !extendsBuiltIn && profile !== undefined) ||
(action === ProfileActionModals.Extend &&
!extendsBuiltIn &&
profile !== undefined &&
!profile.isBuiltIn);
const canSubmit =
(action === undefined && isValidName && isValidLanguage) ||
(action !== undefined && isValidLanguage && isValidName && isValidProfile);
const header = translate('quality_profiles.new_profile');

const selectedLanguage = this.state.language || languageQueryFilter;
const importers = selectedLanguage
? this.state.importers.filter((importer) => importer.languages.includes(selectedLanguage))
: [];
const languageQueryFilter = parseAsOptionalString(props.location.query.language);
const selectedLanguage = language ?? languageQueryFilter;
const filteredImporters = selectedLanguage
? importers.filter((importer) => importer.languages.includes(selectedLanguage))
: [];

const languageProfiles = this.props.profiles.filter((p) => p.language === selectedLanguage);
const profiles = sortBy(languageProfiles, 'name').map((profile) => ({
label: profile.isBuiltIn
? `${profile.name} (${translate('quality_profiles.built_in')})`
: profile.name,
value: profile.key,
}));
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,
value: profile,
}));

const languagesOptions = languages.map((l) => ({
label: l.name,
value: l.key,
}));
const languagesOptions = sortBy(languages, 'name').map((l) => ({
label: l.name,
value: l.key,
}));

const canSubmit = this.canSubmit();
return (
<Modal contentLabel={header} onRequestClose={props.onClose} size="medium">
<form id="create-profile-form" onSubmit={handleFormSubmit}>
<div className="modal-head">
<h2>{header}</h2>
</div>

return (
<Modal contentLabel={header} onRequestClose={this.props.onClose} size="medium">
<form id="create-profile-form" onSubmit={this.handleFormSubmit}>
<div className="modal-head">
<h2>{header}</h2>
{loading ? (
<div className="modal-body">
<Spinner />
</div>
) : (
<div className="modal-body modal-container">
<fieldset className="modal-field big-spacer-bottom">
<label className="spacer-top">
{translate('quality_profiles.chose_creation_type')}
</label>
<div className="display-flex-row spacer-top">
<RadioCard
noRadio
selected={action === ProfileActionModals.Extend}
onClick={handleSelectExtend}
title={<ExtendQualityProfileIcon size={64} />}
>
<h3 className="spacer-bottom h4">
{translate('quality_profiles.creation_from_extend')}
</h3>
<p className="spacer-bottom">
{translate('quality_profiles.creation_from_extend_description_1')}
</p>
<p>{translate('quality_profiles.creation_from_extend_description_2')}</p>
</RadioCard>
<RadioCard
noRadio
selected={action === ProfileActionModals.Copy}
onClick={handleSelectCopy}
title={<CopyQualityProfileIcon size={64} />}
>
<h3 className="spacer-bottom h4">
{translate('quality_profiles.creation_from_copy')}
</h3>
<p className="spacer-bottom">
{translate('quality_profiles.creation_from_copy_description_1')}
</p>
<p>{translate('quality_profiles.creation_from_copy_description_2')}</p>
</RadioCard>
<RadioCard
noRadio
onClick={handleSelectBlank}
selected={action === undefined}
title={<NewQualityProfileIcon size={64} />}
>
<h3 className="spacer-bottom h4">
{translate('quality_profiles.creation_from_blank')}
</h3>
<p>{translate('quality_profiles.creation_from_blank_description')}</p>
</RadioCard>
</div>
</fieldset>

{this.state.preloading ? (
<div className="modal-body">
<i className="spinner" />
</div>
) : (
<div className="modal-body modal-container">
<fieldset className="modal-field big-spacer-bottom">
<label className="spacer-top">
{translate('quality_profiles.chose_creation_type')}
</label>
<div className="display-flex-row spacer-top">
<RadioCard
noRadio
selected={action === ProfileActionModals.Extend}
onClick={this.handleSelectExtend}
title={<ExtendQualityProfileIcon size={64} />}
>
<h3 className="spacer-bottom h4">
{translate('quality_profiles.creation_from_extend')}
</h3>
<p className="spacer-bottom">
{translate('quality_profiles.creation_from_extend_description_1')}
</p>
<p>{translate('quality_profiles.creation_from_extend_description_2')}</p>
</RadioCard>
<RadioCard
noRadio
selected={action === ProfileActionModals.Copy}
onClick={this.handleSelectCopy}
title={<CopyQualityProfileIcon size={64} />}
>
<h3 className="spacer-bottom h4">
{translate('quality_profiles.creation_from_copy')}
</h3>
<p className="spacer-bottom">
{translate('quality_profiles.creation_from_copy_description_1')}
</p>
<p>{translate('quality_profiles.creation_from_copy_description_2')}</p>
</RadioCard>
<RadioCard
noRadio
onClick={this.handleSelectBlank}
selected={action === undefined}
title={<NewQualityProfileIcon size={64} />}
>
<h3 className="spacer-bottom h4">
{translate('quality_profiles.creation_from_blank')}
</h3>
<p>{translate('quality_profiles.creation_from_blank_description')}</p>
</RadioCard>
{!isLoading && showBuiltInWarning && (
<FlagMessage variant="info" className="sw-mb-4">
<div className="sw-flex sw-flex-col">
{translate('quality_profiles.no_built_in_updates_warning.new_profile')}
<span className="sw-mt-1">
{translate('quality_profiles.no_built_in_updates_warning.new_profile.2')}
</span>
</div>
</fieldset>
</FlagMessage>
)}

<MandatoryFieldsExplanation className="modal-field" />
<MandatoryFieldsExplanation className="modal-field" />

<ValidationInput
className="form-field"
labelHtmlFor="create-profile-language-input"
label={translate('language')}
required
isInvalid={isValidLanguage !== undefined && !isValidLanguage}
isValid={!!isValidLanguage}
>
<Select
autoFocus
inputId="create-profile-language-input"
name="language"
isClearable={false}
onChange={handleLanguageChange}
options={languagesOptions}
isSearchable
value={languagesOptions.filter((o) => o.value === selectedLanguage)}
/>
</ValidationInput>
{action !== undefined && (
<ValidationInput
className="form-field"
labelHtmlFor="create-profile-language-input"
label={translate('language')}
labelHtmlFor="create-profile-parent-input"
label={translate(
action === ProfileActionModals.Copy
? 'quality_profiles.creation.choose_copy_quality_profile'
: 'quality_profiles.creation.choose_parent_quality_profile',
)}
required
isInvalid={isValidLanguage !== undefined && !isValidLanguage}
isValid={!!isValidLanguage}
isInvalid={isValidProfile !== undefined && !isValidProfile}
isValid={!!isValidProfile}
>
<Select
autoFocus
inputId="create-profile-language-input"
name="language"
inputId="create-profile-parent-input"
name="parentKey"
isClearable={false}
onChange={this.handleLanguageChange}
options={languagesOptions}
onChange={handleQualityProfileChange}
options={profileOptions}
isSearchable
value={languagesOptions.filter((o) => o.value === selectedLanguage)}
value={profileOptions.filter((o) => o.value === profile)}
/>
</ValidationInput>
{action !== undefined && (
<ValidationInput
className="form-field"
labelHtmlFor="create-profile-parent-input"
label={translate(
action === ProfileActionModals.Copy
? 'quality_profiles.creation.choose_copy_quality_profile'
: 'quality_profiles.creation.choose_parent_quality_profile',
)}
required
isInvalid={isValidProflie !== undefined && !isValidProflie}
isValid={!!isValidProflie}
)}
<ValidationInput
className="form-field"
labelHtmlFor="create-profile-name"
label={translate('name')}
error={translate('quality_profiles.name_invalid')}
required
isInvalid={isValidName !== undefined && !isValidName}
isValid={!!isValidName}
>
<input
autoFocus
id="create-profile-name"
maxLength={100}
name="name"
onChange={handleNameChange}
size={50}
type="text"
value={name}
/>
</ValidationInput>

{action === undefined &&
filteredImporters.map((importer) => (
<div
className="modal-field spacer-bottom js-importer"
data-key={importer.key}
key={importer.key}
>
<Select
autoFocus
inputId="create-profile-parent-input"
name="parentKey"
isClearable={false}
onChange={this.handleQualityProfileChange}
options={profiles}
isSearchable
value={profiles.filter((o) => o.value === this.state.profile)}
<label htmlFor={'create-profile-form-backup-' + importer.key}>
{importer.name}
</label>
<input
id={'create-profile-form-backup-' + importer.key}
name={'backup_' + importer.key}
type="file"
/>
</ValidationInput>
)}
<ValidationInput
className="form-field"
labelHtmlFor="create-profile-name"
label={translate('name')}
error={translate('quality_profiles.name_invalid')}
required
isInvalid={isValidName !== undefined && !isValidName}
isValid={!!isValidName}
>
<input
autoFocus
id="create-profile-name"
maxLength={100}
name="name"
onChange={this.handleNameChange}
size={50}
type="text"
value={this.state.name}
/>
</ValidationInput>
<p className="note">
{translate('quality_profiles.optional_configuration_file')}
</p>
</div>
))}
</div>
)}

{action === undefined &&
importers.map((importer) => (
<div
className="modal-field spacer-bottom js-importer"
data-key={importer.key}
key={importer.key}
>
<label htmlFor={'create-profile-form-backup-' + importer.key}>
{importer.name}
</label>
<input
id={'create-profile-form-backup-' + importer.key}
name={'backup_' + importer.key}
type="file"
/>
<p className="note">
{translate('quality_profiles.optional_configuration_file')}
</p>
</div>
))}
</div>
<div className="modal-foot">
{(submitting || isLoading) && <i className="spinner spacer-right" />}
{!loading && (
<SubmitButton disabled={submitting || !canSubmit} id="create-profile-submit">
{translate('create')}
</SubmitButton>
)}

<div className="modal-foot">
{this.state.loading && <i className="spinner spacer-right" />}
{!this.state.preloading && (
<SubmitButton disabled={this.state.loading || !canSubmit} id="create-profile-submit">
{translate('create')}
</SubmitButton>
)}
<ResetButtonLink id="create-profile-cancel" onClick={this.props.onClose}>
{translate('cancel')}
</ResetButtonLink>
</div>
</form>
</Modal>
);
}
<ResetButtonLink id="create-profile-cancel" onClick={props.onClose}>
{translate('cancel')}
</ResetButtonLink>
</div>
</form>
</Modal>
);
}

+ 43
- 0
server/sonar-web/src/main/js/queries/quality-profiles.ts View File

@@ -0,0 +1,43 @@
/*
* 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 { UseQueryResult, useQuery } from '@tanstack/react-query';
import { Profile, getProfileInheritance } from '../api/quality-profiles';
import { ProfileInheritanceDetails } from '../types/types';

export function useProfileInheritanceQuery(
profile?: Pick<Profile, 'language' | 'name' | 'parentKey'>,
): UseQueryResult<{
ancestors: ProfileInheritanceDetails[];
children: ProfileInheritanceDetails[];
profile: ProfileInheritanceDetails | null;
}> {
const { language, name, parentKey } = profile ?? {};
return useQuery({
queryKey: ['quality-profiles', 'inheritance', language, name, parentKey],
queryFn: async ({ queryKey: [, , language, name] }) => {
if (!language || !name) {
return { ancestors: [], children: [], profile: null };
}
const response = await getProfileInheritance({ language, name });
response.ancestors.reverse();
return response;
},
});
}

+ 2
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -2038,6 +2038,8 @@ quality_profiles.built_in.description=This is a built-in quality profile that mi
quality_profiles.extends_built_in=Because this quality profile inherits from a built-in quality profile, it might be updated automatically.
quality_profiles.no_built_in_updates_warning=This quality profile does not inherit from a built-in profile. It will not benefit from automatic updates when new rules are introduced.
quality_profiles.no_built_in_updates_warning_admin=To benefit from automatic updates, set the corresponding built-in profile as the parent of your quality profile.
quality_profiles.no_built_in_updates_warning.new_profile=This new quality profile won't inherit from a built-in profile. It will not benefit from automatic updates when new rules are introduced.
quality_profiles.no_built_in_updates_warning.new_profile.2=If you want to benefit from automatic updates, consider extending a built-in quality profile instead.
quality_profiles.default_permissions=Users with the global "Administer Quality Profiles" permission and those listed below can manage this quality profile.
quality_profiles.grant_permissions_to_more_users=Grant permissions to more users
quality_profiles.grant_permissions_to_user_or_group=Grant permissions to a user or a group

Loading…
Cancel
Save